Skip to main content

cersei_tools/
bash.rs

1//! Bash tool: execute shell commands with persistent shell state.
2//!
3//! Uses sentinel markers to capture pwd after each command execution,
4//! persisting the working directory across calls.
5
6use super::*;
7use crate::tool_primitives::process::{self as pproc, ExecOptions, Shell};
8use serde::Deserialize;
9
10/// Parse stdout to separate user output from sentinel-captured state.
11/// Returns (user_visible_output, Option<new_cwd>).
12fn parse_sentinel_output(stdout: &str, sentinel: &str) -> (String, Option<String>) {
13    if let Some(pos) = stdout.rfind(sentinel) {
14        let user_output = stdout[..pos].trim_end_matches('\n').to_string();
15        let state_section = &stdout[pos + sentinel.len()..];
16        let new_cwd = state_section
17            .trim()
18            .lines()
19            .next()
20            .map(|s| s.trim().to_string());
21        (user_output, new_cwd)
22    } else {
23        // Sentinel not found (command may have failed before reaching it)
24        (stdout.to_string(), None)
25    }
26}
27
28pub struct BashTool;
29
30#[async_trait]
31impl Tool for BashTool {
32    fn name(&self) -> &str {
33        "Bash"
34    }
35
36    fn description(&self) -> &str {
37        "Execute a bash command and return its output. The working directory persists between commands."
38    }
39
40    fn permission_level(&self) -> PermissionLevel {
41        PermissionLevel::Execute
42    }
43    fn category(&self) -> ToolCategory {
44        ToolCategory::Shell
45    }
46
47    fn input_schema(&self) -> Value {
48        serde_json::json!({
49            "type": "object",
50            "properties": {
51                "command": {
52                    "type": "string",
53                    "description": "The bash command to execute"
54                },
55                "timeout": {
56                    "type": "integer",
57                    "description": "Optional timeout in milliseconds (max 600000)"
58                }
59            },
60            "required": ["command"]
61        })
62    }
63
64    async fn execute(&self, input: Value, ctx: &ToolContext) -> ToolResult {
65        #[derive(Deserialize)]
66        struct Input {
67            command: String,
68            timeout: Option<u64>,
69        }
70
71        let input: Input = match serde_json::from_value(input) {
72            Ok(i) => i,
73            Err(e) => return ToolResult::error(format!("Invalid input: {}", e)),
74        };
75
76        let shell_state = session_shell_state(&ctx.session_id);
77        let (cwd, env_vars) = {
78            let state = shell_state.lock();
79            (
80                state.cwd.clone().unwrap_or_else(|| ctx.working_dir.clone()),
81                state.env_vars.clone(),
82            )
83        };
84
85        let timeout_ms = input.timeout.unwrap_or(120_000).min(600_000);
86
87        // Transparent sandbox routing: if a Sandbox handle is in the tool
88        // context extensions, run the command inside it instead of on the host.
89        #[cfg(feature = "vms")]
90        if let Some(sandbox) =
91            ctx.extensions.get::<std::sync::Arc<dyn cersei_vms::Sandbox>>()
92        {
93            let req = cersei_vms::RunRequest::new(input.command.clone())
94                .timeout(std::time::Duration::from_millis(timeout_ms));
95            return match sandbox.commands().run(req).await {
96                Ok(out) => {
97                    if out.timed_out {
98                        return ToolResult::error(format!(
99                            "Command timed out after {}ms (sandbox: {})",
100                            timeout_ms,
101                            sandbox.id()
102                        ));
103                    }
104                    let mut content = out.stdout;
105                    if !out.stderr.is_empty() {
106                        if !content.is_empty() {
107                            content.push('\n');
108                        }
109                        content.push_str(&out.stderr);
110                    }
111                    if out.exit_code == 0 {
112                        if content.is_empty() {
113                            ToolResult::success("(Bash completed with no output)")
114                        } else {
115                            ToolResult::success(content)
116                        }
117                    } else {
118                        ToolResult::error(format!("Exit code {}\n{}", out.exit_code, content))
119                    }
120                }
121                Err(e) => ToolResult::error(format!("Sandbox exec failed: {e}")),
122            };
123        }
124
125        // Wrap command with sentinel-based state capture
126        // After the user's command runs, we capture pwd to persist cwd
127        const SENTINEL: &str = "__ABSTRACT_STATE_7f2a9b__";
128        let wrapped_command = format!(
129            "cd '{}' 2>/dev/null; {} ; __abstract_exit=$?; echo '{}'; pwd; exit $__abstract_exit",
130            cwd.display(),
131            input.command,
132            SENTINEL,
133        );
134
135        let opts = ExecOptions {
136            cwd: Some(ctx.working_dir.clone()), // base cwd, actual cd is in the script
137            env: env_vars,
138            timeout: Some(std::time::Duration::from_millis(timeout_ms)),
139            shell: Shell::Sh,
140        };
141
142        match pproc::exec(&wrapped_command, opts).await {
143            Ok(output) => {
144                if output.timed_out {
145                    return ToolResult::error(format!("Command timed out after {}ms", timeout_ms));
146                }
147
148                // Parse sentinel-based output to extract new cwd
149                let (user_output, new_cwd) = parse_sentinel_output(&output.stdout, SENTINEL);
150
151                // Persist new cwd
152                if let Some(new_dir) = new_cwd {
153                    let path = PathBuf::from(&new_dir);
154                    if path.exists() {
155                        shell_state.lock().cwd = Some(path);
156                    }
157                }
158
159                let mut content = user_output;
160                if !output.stderr.is_empty() {
161                    if !content.is_empty() {
162                        content.push('\n');
163                    }
164                    content.push_str(&output.stderr);
165                }
166
167                if output.exit_code == 0 {
168                    if content.is_empty() {
169                        ToolResult::success("(Bash completed with no output)")
170                    } else {
171                        ToolResult::success(content)
172                    }
173                } else {
174                    ToolResult::error(format!("Exit code {}\n{}", output.exit_code, content))
175                }
176            }
177            Err(e) => ToolResult::error(format!("Failed to execute: {}", e)),
178        }
179    }
180}