use super::{MAX_TOOL_OUTPUT_BYTES, sanitize_multiline, sanitize_name, truncate_tool_output};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::io::Read;
use std::{collections::HashMap, path::Path, time::Duration};
use tracing::debug;
use ureq::RequestBuilder;
const MAX_HTTP_BODY_BYTES: usize = MAX_TOOL_OUTPUT_BYTES + 64 * 1024;
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 10;
const MIN_HTTP_TIMEOUT_SECS: u64 = 1;
const MAX_HTTP_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Serialize, Deserialize, thiserror::Error)]
pub enum HttpError {
#[error("unsupported method: {0}")]
UnsupportedMethod(String),
#[error("invalid url: {0}")]
InvalidUrl(String),
#[error("unsupported URL scheme: {0}")]
UnsupportedUrlScheme(String),
#[error("invalid header {name}: {error}")]
InvalidHeader { name: String, error: String },
#[error("request failed: {0}")]
RequestFailed(String),
}
fn default_method() -> String {
"GET".to_string()
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct HttpRequestArgs {
#[serde(default = "default_method")]
pub method: String,
pub url: String,
#[serde(default)]
pub headers: HashMap<String, String>,
pub body: Option<String>,
pub timeout_secs: Option<u64>,
}
fn effective_timeout_secs(args: &HttpRequestArgs) -> u64 {
args.timeout_secs
.unwrap_or(DEFAULT_HTTP_TIMEOUT_SECS)
.clamp(MIN_HTTP_TIMEOUT_SECS, MAX_HTTP_TIMEOUT_SECS)
}
pub fn execute_http_request_tool(
args: &HttpRequestArgs,
_working_dir: Option<&Path>,
) -> Result<String, HttpError> {
let method = args.method.to_ascii_uppercase();
match method.as_str() {
"GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" => {}
other => return Err(HttpError::UnsupportedMethod(other.to_string())),
}
let parsed_url =
url::Url::parse(&args.url).map_err(|e| HttpError::InvalidUrl(e.to_string()))?;
match parsed_url.scheme() {
"http" | "https" => {}
other => return Err(HttpError::UnsupportedUrlScheme(other.to_string())),
}
let timeout_secs = effective_timeout_secs(args);
let agent = ureq::Agent::new_with_config(
ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(timeout_secs)))
.http_status_as_error(false)
.build(),
);
for (name, value) in &args.headers {
if name.is_empty() {
return Err(HttpError::InvalidHeader {
name: "(empty)".into(),
error: "header name must not be empty".into(),
});
}
if name.bytes().any(|b| b <= 0x1f || b == 0x7f || b == b':') {
return Err(HttpError::InvalidHeader {
name: name.clone(),
error: "header name contains invalid characters".into(),
});
}
if value.bytes().any(|b| b == b'\n' || b == b'\r') {
return Err(HttpError::InvalidHeader {
name: name.clone(),
error: "header value contains newline characters".into(),
});
}
}
let response = match method.as_str() {
"GET" => apply_headers(agent.get(&args.url), &args.headers).call(),
"POST" => {
let req = apply_headers(agent.post(&args.url), &args.headers);
if let Some(body) = &args.body {
req.send(body.as_str())
} else {
req.send_empty()
}
}
"PUT" => {
let req = apply_headers(agent.put(&args.url), &args.headers);
if let Some(body) = &args.body {
req.send(body.as_str())
} else {
req.send_empty()
}
}
"DELETE" => apply_headers(agent.delete(&args.url), &args.headers).call(),
"PATCH" => {
let req = apply_headers(agent.patch(&args.url), &args.headers);
if let Some(body) = &args.body {
req.send(body.as_str())
} else {
req.send_empty()
}
}
"HEAD" => apply_headers(agent.head(&args.url), &args.headers).call(),
other => return Err(HttpError::UnsupportedMethod(other.to_string())),
}
.map_err(|e| HttpError::RequestFailed(e.to_string()))?;
let status = response.status();
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let headers: Vec<(String, String)> = response
.headers()
.iter()
.map(|(name, value)| {
(
name.as_str().to_ascii_lowercase(),
value.to_str().unwrap_or("<non-utf8>").to_string(),
)
})
.collect();
let body = if method == "HEAD" {
String::new()
} else if is_text_content_type(&content_type) {
read_bounded_text_body(response)
} else {
"body omitted: non-text response".to_string()
};
Ok(format_http_response(status, &headers, &body))
}
fn read_bounded_text_body(response: ureq::http::Response<ureq::Body>) -> String {
let mut bytes = Vec::with_capacity(64 * 1024);
let mut reader = response
.into_body()
.into_reader()
.take(MAX_HTTP_BODY_BYTES as u64);
if let Err(e) = reader.read_to_end(&mut bytes) {
debug!(
error = %e,
bytes_read = bytes.len(),
"http: body read failed before the byte cap"
);
return format!("body omitted: failed to read response body: {e}");
}
let truncated = bytes.len() as u64 >= MAX_HTTP_BODY_BYTES as u64;
if truncated {
debug!(
cap_bytes = MAX_HTTP_BODY_BYTES,
"http: response body cut at the read cap (hostile or huge body)"
);
}
let text = if truncated {
String::from_utf8_lossy(&bytes).into_owned()
} else {
match String::from_utf8(bytes) {
Ok(s) => s,
Err(e) => return format!("body omitted: failed to decode response text: {e}"),
}
};
truncate_tool_output(&sanitize_multiline(&text))
}
fn is_text_content_type(content_type: &str) -> bool {
let mime = content_type
.split(';')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
mime.starts_with("text/")
|| matches!(
mime.as_str(),
"application/json"
| "application/xml"
| "application/javascript"
| "application/x-javascript"
| "application/x-ndjson"
| "application/graphql-response+json"
)
|| mime.ends_with("+json")
|| mime.ends_with("+xml")
}
fn apply_headers<B>(
req: RequestBuilder<B>,
headers: &HashMap<String, String>,
) -> RequestBuilder<B> {
let caller_supplied_ua = headers
.keys()
.any(|name| name.eq_ignore_ascii_case("user-agent"));
let mut req = if caller_supplied_ua {
req
} else {
req.header("User-Agent", crate::providers::daemon_user_agent())
};
for (name, value) in headers {
req = req.header(name.as_str(), value.as_str());
}
req
}
fn format_http_response(
status: ureq::http::StatusCode,
headers: &[(String, String)],
body: &str,
) -> String {
let mut output = format!("status: {status}");
let mut sorted = headers.to_vec();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
for (name, value) in &sorted {
output.push('\n');
output.push_str(name);
output.push_str(": ");
output.push_str(&sanitize_name(value));
}
output.push_str("\n\n");
output.push_str(body);
output
}
pub(crate) struct HttpRequest;
impl crate::tools::Tool for HttpRequest {
type Args = HttpRequestArgs;
type Return = String;
type Error = HttpError;
fn name(&self) -> &'static str {
"http_request"
}
fn group(&self) -> &'static str {
"core"
}
fn description(&self) -> &'static str {
"Make an HTTP request to an absolute URL and return status, response headers, and response body text. HTTP method defaults to GET when omitted (lowercase method names are accepted). Supports custom headers such as Range for partial content requests."
}
fn describe_invocation(&self, args: &Self::Args) -> String {
let mut parts = vec![format!(
"Making {} HTTP request to {}.",
args.method.to_ascii_uppercase(),
args.url
)];
if !args.headers.is_empty() {
parts.push(format!(" {} header(s).", args.headers.len()));
}
if let Some(ref body) = args.body {
parts.push(format!(" Body: {} bytes.", body.len()));
}
parts.push(format!(" Timeout: {}s.", effective_timeout_secs(args)));
parts.concat()
}
fn execute(
&self,
args: Self::Args,
_x_credentials: Option<&crate::tools::ServiceCredential>,
working_dir: Option<&std::path::Path>,
_ctx: Option<&crate::tools::context::ToolContext>,
) -> Result<Self::Return, Self::Error> {
execute_http_request_tool(&args, working_dir)
}
fn return_string(ret: &Self::Return) -> String {
ret.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::Tool;
#[test]
fn header_validation_rejects_empty_name() {
let args = HttpRequestArgs {
method: "GET".into(),
url: "http://example.com".into(),
headers: [(String::new(), "value".into())].into(),
body: None,
timeout_secs: None,
};
let err = execute_http_request_tool(&args, None).unwrap_err();
assert!(matches!(err, HttpError::InvalidHeader { .. }));
}
#[test]
fn header_validation_rejects_colon_in_name() {
let args = HttpRequestArgs {
method: "GET".into(),
url: "http://example.com".into(),
headers: [("bad:header".into(), "value".into())].into(),
body: None,
timeout_secs: None,
};
let err = execute_http_request_tool(&args, None).unwrap_err();
assert!(matches!(err, HttpError::InvalidHeader { .. }));
}
#[test]
fn header_validation_rejects_newline_in_value() {
let args = HttpRequestArgs {
method: "GET".into(),
url: "http://example.com".into(),
headers: [("name".into(), "value\ninjected".into())].into(),
body: None,
timeout_secs: None,
};
let err = execute_http_request_tool(&args, None).unwrap_err();
assert!(matches!(err, HttpError::InvalidHeader { .. }));
}
#[test]
fn header_validation_rejects_carriage_return_in_value() {
let args = HttpRequestArgs {
method: "GET".into(),
url: "http://example.com".into(),
headers: [("name".into(), "value\rinjected".into())].into(),
body: None,
timeout_secs: None,
};
let err = execute_http_request_tool(&args, None).unwrap_err();
assert!(matches!(err, HttpError::InvalidHeader { .. }));
}
#[test]
fn header_validation_rejects_control_char_in_name() {
let args = HttpRequestArgs {
method: "GET".into(),
url: "http://example.com".into(),
headers: [("header\x00name".into(), "value".into())].into(),
body: None,
timeout_secs: None,
};
let err = execute_http_request_tool(&args, None).unwrap_err();
assert!(matches!(err, HttpError::InvalidHeader { .. }));
}
#[test]
fn header_validation_accepts_valid_headers() {
let args = HttpRequestArgs {
method: "GET".into(),
url: "http://example.com".into(),
headers: [("Accept".into(), "text/html".into())].into(),
body: None,
timeout_secs: None,
};
let result = execute_http_request_tool(&args, None);
match result {
Ok(_) => {} Err(e) => assert!(
matches!(e, HttpError::RequestFailed(_)),
"expected RequestFailed, got {e}"
),
}
}
#[test]
fn unsupported_method_rejected() {
let args = HttpRequestArgs {
method: "OPTIONS".into(),
url: "http://example.com".into(),
headers: [].into(),
body: None,
timeout_secs: None,
};
let err = execute_http_request_tool(&args, None).unwrap_err();
assert!(matches!(err, HttpError::UnsupportedMethod(_)));
}
#[test]
fn invalid_url_rejected() {
let args = HttpRequestArgs {
method: "GET".into(),
url: "\0invalid".into(),
headers: [].into(),
body: None,
timeout_secs: None,
};
let err = execute_http_request_tool(&args, None).unwrap_err();
assert!(matches!(err, HttpError::InvalidUrl(_)));
}
#[test]
fn unsupported_scheme_rejected() {
let args = HttpRequestArgs {
method: "GET".into(),
url: "ftp://example.com".into(),
headers: [].into(),
body: None,
timeout_secs: None,
};
let err = execute_http_request_tool(&args, None).unwrap_err();
assert!(matches!(err, HttpError::UnsupportedUrlScheme(_)));
}
#[test]
fn http_error_postcard_round_trip() {
let errors = vec![
HttpError::UnsupportedMethod("PATCH".into()),
HttpError::InvalidUrl("bad".into()),
HttpError::UnsupportedUrlScheme("file".into()),
HttpError::InvalidHeader {
name: "X-Foo".into(),
error: "bad value".into(),
},
HttpError::RequestFailed("timeout".into()),
];
for err in &errors {
let encoded = postcard::to_allocvec(err).unwrap();
let decoded: HttpError = postcard::from_bytes(&encoded).unwrap();
assert_eq!(err.to_string(), decoded.to_string());
}
}
#[test]
fn format_http_response_includes_status_body() {
let status = ureq::http::StatusCode::OK;
let headers = vec![("content-type".into(), "text/plain".into())];
let body = "hello";
let output = format_http_response(status, &headers, body);
assert!(output.contains("200 OK"));
assert!(output.contains("content-type: text/plain"));
assert!(output.contains("hello"));
}
#[test]
fn format_http_response_sorts_headers() {
let status = ureq::http::StatusCode::OK;
let headers = vec![
("z-header".into(), "z".into()),
("a-header".into(), "a".into()),
];
let output = format_http_response(status, &headers, "");
let a_pos = output.find("a-header").unwrap();
let z_pos = output.find("z-header").unwrap();
assert!(a_pos < z_pos, "headers should be sorted alphabetically");
}
#[test]
fn describe_invocation_includes_method_and_url() {
let tool = HttpRequest;
let args = HttpRequestArgs {
method: "POST".into(),
url: "https://api.example.com/data".into(),
headers: [("Authorization".into(), "Bearer token123".into())].into(),
body: Some("{\"key\":\"value\"}".into()),
timeout_secs: Some(60),
};
let desc = tool.describe_invocation(&args);
assert!(desc.contains("Making POST HTTP request to https://api.example.com/data."));
assert!(desc.contains("1 header(s)."));
assert!(desc.contains("Body: 15 bytes."));
assert!(desc.contains("Timeout: 30s."));
}
#[test]
fn describe_invocation_no_body() {
let tool = HttpRequest;
let args = HttpRequestArgs {
method: "GET".into(),
url: "https://example.com".into(),
headers: std::collections::HashMap::new(),
body: None,
timeout_secs: None,
};
let desc = tool.describe_invocation(&args);
assert!(desc.contains("Making GET HTTP request to https://example.com."));
assert!(desc.contains("Timeout: 10s."));
}
#[test]
fn method_omitted_defaults_to_get() {
let args: HttpRequestArgs = serde_json::from_str(r#"{"url": "http://example.com"}"#)
.expect("omitted method must deserialize to the GET default");
assert_eq!(args.method, "GET");
}
#[test]
fn lowercase_method_accepted() {
let args = HttpRequestArgs {
method: "get".into(),
url: "http://example.com".into(),
headers: [].into(),
body: None,
timeout_secs: None,
};
let result = execute_http_request_tool(&args, None);
match result {
Ok(_) => {}
Err(e) => assert!(
matches!(e, HttpError::RequestFailed(_)),
"expected RequestFailed, got {e}"
),
}
}
#[test]
fn describe_invocation_normalizes_lowercase_method() {
let tool = HttpRequest;
let args = HttpRequestArgs {
method: "get".into(),
url: "https://example.com".into(),
headers: std::collections::HashMap::new(),
body: None,
timeout_secs: None,
};
let desc = tool.describe_invocation(&args);
assert!(
desc.contains("Making GET HTTP request to https://example.com."),
"{desc}"
);
assert!(desc.contains("Timeout: 10s."), "{desc}");
}
fn user_agent_values<B>(req: &RequestBuilder<B>) -> Vec<String> {
req.headers_ref()
.expect("valid builder")
.get_all("user-agent")
.iter()
.map(|v| v.to_str().expect("ascii UA").to_string())
.collect()
}
#[test]
fn default_user_agent_applied_when_caller_omits_it() {
let req = apply_headers(ureq::get("http://example.com"), &HashMap::new());
let uas = user_agent_values(&req);
assert_eq!(uas.len(), 1, "exactly one default UA expected: {uas:?}");
assert!(
uas[0].starts_with("choreographr/"),
"default UA should name the daemon: {uas:?}"
);
}
#[test]
fn caller_user_agent_replaces_default() {
let headers = HashMap::from([("User-Agent".to_string(), "custom/1".to_string())]);
let req = apply_headers(ureq::get("http://example.com"), &headers);
let uas = user_agent_values(&req);
assert_eq!(
uas.len(),
1,
"caller UA must not be appended to ours: {uas:?}"
);
assert_eq!(uas[0], "custom/1");
}
#[test]
fn caller_user_agent_match_is_case_insensitive() {
let headers = HashMap::from([("user-agent".to_string(), "custom/2".to_string())]);
let req = apply_headers(ureq::get("http://example.com"), &headers);
let uas = user_agent_values(&req);
assert_eq!(
uas.len(),
1,
"lowercase user-agent key must suppress the default: {uas:?}"
);
assert_eq!(uas[0], "custom/2");
}
}