oxicode_agent/tools/
bash_session.rs1use 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
27fn sh_quote(value: &str) -> String {
29 format!("'{}'", value.replace('\'', "'\\''"))
30}
31
32pub struct SessionBashTool {
34 session: Arc<dyn ShellSession>,
35 progress_callback: Arc<std::sync::Mutex<Option<ProgressCallback>>>,
36}
37
38impl SessionBashTool {
39 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 #[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 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 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 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 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 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 #[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}