Skip to main content

harness_bash/
schema.rs

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.";