Skip to main content

oxicode_agent/tools/
bash_session.rs

1//! Persistent-session bash tool — the `coding-omp-v1` routed `bash`
2//! implementation backed by [`ShellSession`](crate::runtime::ShellSession).
3//!
4//! Schema-compatible with the legacy
5//! [`BashTool`](super::bash::BashTool) (`command`/`timeout`/`cwd`/`env`),
6//! but every call executes in one long-lived bash session: the working
7//! directory and exported environment persist across calls, matching the
8//! OMP compatibility target. The session init merges stderr into stdout
9//! and traps SIGINT so a cancel aborts only the foreground command
10//! (exit code 130) while the session survives.
11//!
12//! The pack picks this variant when the host provides a `ShellSession`
13//! service and falls back to the legacy per-invocation tool otherwise.
14
15use super::bash::{BashTool, is_dangerous_command, validate_cwd};
16use super::truncate;
17use super::{
18    AgentTool, AgentToolResult, ProgressCallback, ToolContext, ToolError, ToolExecutionMode,
19};
20use crate::runtime::ShellSession;
21use async_trait::async_trait;
22use serde_json::{Value, json};
23use std::sync::Arc;
24use std::time::Instant;
25use tokio::sync::oneshot;
26
27/// Quote an arbitrary string as a single-quoted POSIX shell word.
28fn sh_quote(value: &str) -> String {
29    format!("'{}'", value.replace('\'', "'\\''"))
30}
31
32/// `bash` agent tool routed through a persistent [`ShellSession`].
33pub struct SessionBashTool {
34    session: Arc<dyn ShellSession>,
35    progress_callback: Arc<std::sync::Mutex<Option<ProgressCallback>>>,
36}
37
38impl SessionBashTool {
39    /// Route executions through `session`.
40    pub fn new(session: Arc<dyn ShellSession>) -> Self {
41        Self {
42            session,
43            progress_callback: Arc::new(std::sync::Mutex::new(None)),
44        }
45    }
46
47    fn report(&self, message: String) {
48        // SAFETY: a poisoned lock means the previous holder panicked while
49        // holding it — a real bug that must surface, not be swallowed.
50        #[allow(clippy::expect_used)]
51        if let Some(cb) = self
52            .progress_callback
53            .lock()
54            .expect("progress callback lock poisoned")
55            .as_ref()
56        {
57            cb(message);
58        }
59    }
60}
61
62#[async_trait]
63impl AgentTool for SessionBashTool {
64    fn name(&self) -> &str {
65        "bash"
66    }
67
68    fn label(&self) -> &str {
69        "Bash (persistent session)"
70    }
71
72    fn essential(&self) -> bool {
73        true
74    }
75
76    fn description(&self) -> &str {
77        "Execute a bash command in a persistent shell session. The working \
78         directory and exported environment persist across calls (OMP-style \
79         session semantics). Returns combined stdout/stderr. Output is \
80         truncated to 2000 lines or 50KB. Set timeout to limit execution \
81         time; cancellation aborts only the running command (exit code 130) \
82         and the session stays alive."
83    }
84
85    fn parameters_schema(&self) -> Value {
86        json!({
87            "type": "object",
88            "properties": {
89                "command": {
90                    "type": "string",
91                    "description": "The bash command to execute"
92                },
93                "timeout": {
94                    "type": "integer",
95                    "description": "Timeout in seconds (default: 120)",
96                    "default": 120
97                },
98                "cwd": {
99                    "type": "string",
100                    "description": "Working directory for the command; the change persists for subsequent calls (optional)"
101                },
102                "env": {
103                    "type": "object",
104                    "description": "Environment variables exported for this and subsequent calls (optional)",
105                    "additionalProperties": {
106                        "type": "string"
107                    }
108                }
109            },
110            "required": ["command"]
111        })
112    }
113
114    fn intent(&self) -> Option<&str> {
115        Some("Execute bash in a persistent session")
116    }
117
118    fn execution_mode(&self) -> ToolExecutionMode {
119        // One shell session = one mutable terminal; parallel calls would
120        // interleave inside the same interpreter.
121        ToolExecutionMode::SequentialOnly
122    }
123
124    async fn execute(
125        &self,
126        _tool_call_id: &str,
127        params: Value,
128        signal: Option<oneshot::Receiver<()>>,
129        ctx: &ToolContext,
130    ) -> Result<AgentToolResult, ToolError> {
131        let command = params
132            .get("command")
133            .and_then(|v| v.as_str())
134            .ok_or_else(|| "Missing required parameter: command".to_string())?
135            .to_string();
136        if command.trim().is_empty() {
137            return Err("Parameter `command` must be a non-empty string".to_string());
138        }
139
140        if std::env::var_os("OXICODE_STRICT_BASH").as_deref() == Some(std::ffi::OsStr::new("1"))
141            && let Some(reason) = is_dangerous_command(&command)
142        {
143            return Err(format!(
144                "OXICODE_STRICT_BASH=1 blocked dangerous command: {reason}"
145            ));
146        }
147
148        let timeout_secs = params
149            .get("timeout")
150            .and_then(|v| v.as_u64())
151            .unwrap_or(120);
152        let cwd = params.get("cwd").and_then(|v| v.as_str());
153        let env = params.get("env").and_then(|v| v.as_object());
154
155        // Compose the session command: persistent exports first, then a
156        // persistent `cd`, then the raw command. Prefixes are shell-quoted.
157        let mut prefixes = String::new();
158        if let Some(env) = env {
159            for (key, value) in env {
160                let Some(value) = value.as_str() else {
161                    return Err(format!("Parameter `env.{key}` must be a string"));
162                };
163                prefixes.push_str(&format!("export {}={}\n", key, sh_quote(value)));
164            }
165        }
166        if let Some(cwd) = cwd.filter(|c| !c.is_empty()) {
167            // Same workspace containment rule as the legacy tool.
168            let _ = validate_cwd(cwd, Some(ctx.root()))?;
169            prefixes.push_str(&format!("cd {}\n", sh_quote(cwd)));
170        }
171        let session_command = format!("{prefixes}{command}");
172
173        // Bridge the abort signal to session cancellation: SIGINT aborts
174        // only the foreground command; the session survives. The receiver
175        // resolving (send OR sender drop) both count as an abort request.
176        let cancel_session = self.session.clone();
177        let cancel_task = signal.map(|rx| {
178            tokio::spawn(async move {
179                let resolved = rx.await.is_ok();
180                eprintln!("PROBE cancel task resolved ok={resolved}");
181                cancel_session.cancel();
182                eprintln!("PROBE cancel() called");
183            })
184        });
185        self.report(format!("Executing (persistent session): {command}"));
186        let start = Instant::now();
187        let outcome = self
188            .session
189            .execute(
190                &session_command,
191                std::time::Duration::from_secs(timeout_secs),
192            )
193            .await;
194        let elapsed = start.elapsed();
195        if let Some(task) = cancel_task {
196            task.abort();
197        }
198        self.report(format!(
199            "Session command completed in {}",
200            BashTool::format_duration(elapsed)
201        ));
202
203        let out = outcome.map_err(|e| -> ToolError { format!("persistent bash session: {e}") })?;
204
205        let combined = if out.stdout.is_empty() {
206            "(no output)".to_string()
207        } else {
208            out.stdout.clone()
209        };
210        let truncation = truncate::truncate_head(&combined, &Default::default());
211        let mut text = BashTool::build_output(&truncation, elapsed, Some(out.exit_code));
212        if out.truncated {
213            // The session applied its own byte bound before the shared
214            // head-truncation ran.
215            text.push_str("\n[Session output bound applied]");
216        }
217        if let Some(reason) = is_dangerous_command(&command) {
218            text.push_str(&format!("\n{reason}"));
219        }
220        if out.exit_code == 0 {
221            Ok(AgentToolResult::success(text))
222        } else {
223            Ok(AgentToolResult::error(text))
224        }
225    }
226
227    fn on_progress(&self, callback: ProgressCallback) {
228        // SAFETY: a poisoned lock means the previous holder panicked while
229        // holding it — a real bug that must surface, not be swallowed.
230        #[allow(clippy::expect_used)]
231        let mut guard = self
232            .progress_callback
233            .lock()
234            .expect("progress callback lock poisoned");
235        *guard = Some(callback);
236    }
237}