1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::HashSet;
4
5use crate::constants::MAX_COMMAND_LENGTH;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8#[serde(deny_unknown_fields)]
9pub struct BashParams {
10 pub command: String,
11 #[serde(default, skip_serializing_if = "Option::is_none")]
12 pub cwd: Option<String>,
13 #[serde(default, skip_serializing_if = "Option::is_none")]
14 pub timeout_ms: Option<u64>,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub description: Option<String>,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub background: Option<bool>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub env: Option<std::collections::HashMap<String, String>>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct BashOutputParams {
26 pub job_id: String,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub since_byte: Option<u64>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub head_limit: Option<usize>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct BashKillParams {
36 pub job_id: String,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub signal: Option<String>,
39}
40
41#[derive(Debug, Clone, thiserror::Error)]
42pub enum BashParseError {
43 #[error("{0}")]
44 Message(String),
45}
46
47fn known_alias_hint(key: &str) -> Option<&'static str> {
48 match key {
49 "cmd" => Some("unknown parameter 'cmd'. Use 'command' instead."),
50 "shell_command" => Some("unknown parameter 'shell_command'. Use 'command' instead."),
51 "script" => Some("unknown parameter 'script'. Use 'command' instead."),
52 "run" => Some("unknown parameter 'run'. Use 'command' instead."),
53 "directory" => Some("unknown parameter 'directory'. Use 'cwd' instead."),
54 "dir" => Some("unknown parameter 'dir'. Use 'cwd' instead."),
55 "path" => Some("unknown parameter 'path'. Use 'cwd' instead."),
56 "working_directory" => Some("unknown parameter 'working_directory'. Use 'cwd' instead."),
57 "timeout" => Some(
58 "unknown parameter 'timeout'. Use 'timeout_ms' instead (milliseconds, not seconds). For 30s pass timeout_ms: 30000.",
59 ),
60 "time_limit" => Some("unknown parameter 'time_limit'. Use 'timeout_ms' instead (milliseconds)."),
61 "timeout_seconds" => Some("unknown parameter 'timeout_seconds'. Use 'timeout_ms' instead (multiply by 1000)."),
62 "env_vars" => Some("unknown parameter 'env_vars'. Use 'env' instead."),
63 "environment" => Some("unknown parameter 'environment'. Use 'env' instead."),
64 "lang" => Some(
65 "unknown parameter 'lang'. Bash runs shell commands; invoke other languages via the command itself (e.g. 'python -c \"...\"', 'node -e \"...\"').",
66 ),
67 "language" => Some(
68 "unknown parameter 'language'. Invoke other languages via the command (e.g. 'python -c \"...\"', 'node -e \"...\"').",
69 ),
70 "interpreter" => Some(
71 "unknown parameter 'interpreter'. Invoke the interpreter inside the command itself (e.g. 'python -c \"...\"').",
72 ),
73 "runtime" => Some(
74 "unknown parameter 'runtime'. Invoke the runtime inside the command itself (e.g. 'node -e \"...\"').",
75 ),
76 "stdin" => Some(
77 "unknown parameter 'stdin'. Interactive stdin is not supported in v1. Pipe data into the command instead (e.g. 'echo \"y\" | npm init').",
78 ),
79 "input" => Some(
80 "unknown parameter 'input'. Interactive input is not supported in v1. Make the command non-interactive with flags like --yes.",
81 ),
82 "sandbox" => Some("unknown parameter 'sandbox'. Sandboxing is configured on the session, not per-call."),
83 "sandbox_mode" => Some("unknown parameter 'sandbox_mode'. Sandboxing is configured on the session, not per-call."),
84 "permissions" => Some("unknown parameter 'permissions'. The permission hook is configured on the session."),
85 "network" => Some("unknown parameter 'network'. Network access is configured on the session / executor adapter."),
86 "network_access" => Some("unknown parameter 'network_access'. Network access is configured on the session / executor adapter."),
87 "shell" => Some("unknown parameter 'shell'. Shell binary is configured on the session."),
88 "shell_binary" => Some("unknown parameter 'shell_binary'. Shell binary is configured on the session."),
89 _ => None,
90 }
91}
92
93fn canonical_bash_fields() -> HashSet<&'static str> {
94 [
95 "command",
96 "cwd",
97 "timeout_ms",
98 "description",
99 "background",
100 "env",
101 ]
102 .into_iter()
103 .collect()
104}
105
106pub fn safe_parse_bash_params(input: &Value) -> Result<BashParams, BashParseError> {
107 if let Some(obj) = input.as_object() {
108 let canonical = canonical_bash_fields();
109 let mut alias_hints: Vec<String> = Vec::new();
110 let mut unknown: Vec<String> = Vec::new();
111 for key in obj.keys() {
112 if canonical.contains(key.as_str()) {
113 continue;
114 }
115 if let Some(hint) = known_alias_hint(key.as_str()) {
116 alias_hints.push(hint.to_string());
117 } else {
118 unknown.push(format!("unknown parameter '{}'.", key));
119 }
120 }
121 if !alias_hints.is_empty() || !unknown.is_empty() {
122 let mut msgs = alias_hints;
123 msgs.extend(unknown);
124 return Err(BashParseError::Message(msgs.join("; ")));
125 }
126 }
127
128 let parsed: BashParams = serde_json::from_value(input.clone())
129 .map_err(|e| BashParseError::Message(e.to_string()))?;
130
131 if parsed.command.trim().is_empty() {
132 return Err(BashParseError::Message("command is required".to_string()));
133 }
134 if parsed.command.len() > MAX_COMMAND_LENGTH {
135 return Err(BashParseError::Message(format!(
136 "command exceeds {} bytes",
137 MAX_COMMAND_LENGTH
138 )));
139 }
140 if let Some(ms) = parsed.timeout_ms {
141 if ms < 100 {
142 return Err(BashParseError::Message(
143 "timeout_ms must be >= 100 ms".to_string(),
144 ));
145 }
146 }
147 Ok(parsed)
148}
149
150pub fn safe_parse_bash_output_params(
151 input: &Value,
152) -> Result<BashOutputParams, BashParseError> {
153 let parsed: BashOutputParams = serde_json::from_value(input.clone())
154 .map_err(|e| BashParseError::Message(e.to_string()))?;
155 if parsed.job_id.is_empty() {
156 return Err(BashParseError::Message("job_id is required".to_string()));
157 }
158 Ok(parsed)
159}
160
161pub fn safe_parse_bash_kill_params(input: &Value) -> Result<BashKillParams, BashParseError> {
162 let parsed: BashKillParams = serde_json::from_value(input.clone())
163 .map_err(|e| BashParseError::Message(e.to_string()))?;
164 if parsed.job_id.is_empty() {
165 return Err(BashParseError::Message("job_id is required".to_string()));
166 }
167 if let Some(ref sig) = parsed.signal {
168 if sig != "SIGTERM" && sig != "SIGKILL" {
169 return Err(BashParseError::Message(
170 "signal must be 'SIGTERM' or 'SIGKILL'".to_string(),
171 ));
172 }
173 }
174 Ok(parsed)
175}
176
177pub const BASH_TOOL_NAME: &str = "bash";
178pub const BASH_TOOL_DESCRIPTION: &str = "Run a single shell command in a bash subprocess. Output is captured and returned with the exit code. See design/bash.md for the full contract.";
179
180pub const BASH_OUTPUT_TOOL_NAME: &str = "bash_output";
181pub const BASH_OUTPUT_TOOL_DESCRIPTION: &str = "Poll a backgrounded bash job's output since a given byte offset.";
182
183pub const BASH_KILL_TOOL_NAME: &str = "bash_kill";
184pub const BASH_KILL_TOOL_DESCRIPTION: &str = "Send a termination signal to a backgrounded bash job.";