mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat: merge http/web_fetch tools, add tool output stash for large responses (#578)
* feat: merge http/web_fetch tools, add tool output stash for large responses Merge `web_fetch` into `http` tool with smart approval: plain GETs (no headers, no body) run without approval and follow redirects with SSRF re-validation per hop; all other requests require approval as before. Add `tool_output_stash` on JobContext so full tool outputs are preserved before safety-layer truncation. The `json` tool gains a `source_tool_call_id` parameter to reference stashed outputs, enabling reliable parsing of large API responses that exceed the 100KB context limit. Other improvements: - Descriptive User-Agent header using CARGO_PKG_VERSION - Truncation now keeps partial data + hint about source_tool_call_id - System prompt reinforces tool_calls over narration - json tool query/stringify handle pre-parsed (non-string) data [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * chore: delete dead web_fetch.rs (merged into http tool) Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: fix rustfmt formatting Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: rename shadowed data binding for clarity in json tool Address PR review: rename owned `data` to `data_value` before re-binding as `let data = &data_value` to make ownership explicit. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(ci): mark network-dependent trace tests as #[ignore] The weather_sf and baseball_stats tests hit live external APIs (wttr.in, ESPN) which are unreliable in CI. Mark them #[ignore] so they don't block the pipeline. Run locally with `--ignored` to include them. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: replay recorded HTTP exchanges in trace tests instead of hitting live APIs Wire ReplayingHttpInterceptor into TestRig when the trace fixture contains http_exchanges. This replays recorded responses instead of making live network calls, making tests deterministic and CI-stable. Add captured HTTP responses to weather_sf.json (wttr.in) and baseball_stats.json (ESPN API) fixtures. Revert #[ignore] on both tests — they now run offline. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: recover inline bracket-format tool calls from LLM text responses When flatten_tool_messages converts tool calls to text like `[Called tool `http` with arguments: {...}]` for NEAR AI compatibility, the LLM sometimes echoes this format back in its text responses instead of using proper tool_calls. Add recovery for this bracket format in recover_tool_calls_from_content and strip it in clean_response so users don't see raw tool call syntax. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
69cddb10fd
commit
470de5bd2d
@@ -688,6 +688,15 @@ impl Agent {
|
|||||||
deferred_auth = Some(instructions);
|
deferred_auth = Some(instructions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stash full output so subsequent tools can reference it
|
||||||
|
if let Ok(ref output) = tool_result {
|
||||||
|
job_ctx
|
||||||
|
.tool_output_stash
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(tc.id.clone(), output.clone());
|
||||||
|
}
|
||||||
|
|
||||||
// Sanitize and add tool result to context
|
// Sanitize and add tool result to context
|
||||||
let result_content = match tool_result {
|
let result_content = match tool_result {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
|
|||||||
@@ -156,6 +156,14 @@ pub struct JobContext {
|
|||||||
/// returns pre-recorded responses.
|
/// returns pre-recorded responses.
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub http_interceptor: Option<Arc<dyn HttpInterceptor>>,
|
pub http_interceptor: Option<Arc<dyn HttpInterceptor>>,
|
||||||
|
/// Stash of full tool outputs keyed by tool_call_id.
|
||||||
|
///
|
||||||
|
/// Tool outputs may be truncated before reaching the LLM context window,
|
||||||
|
/// but subsequent tools (e.g., `json`) may need the full output. This
|
||||||
|
/// stash stores the complete, unsanitized output so tools can reference
|
||||||
|
/// previous results by ID via `$tool_call_id` parameter syntax.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub tool_output_stash: Arc<tokio::sync::RwLock<HashMap<String, String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JobContext {
|
impl JobContext {
|
||||||
@@ -194,6 +202,7 @@ impl JobContext {
|
|||||||
extra_env: Arc::new(HashMap::new()),
|
extra_env: Arc::new(HashMap::new()),
|
||||||
http_interceptor: None,
|
http_interceptor: None,
|
||||||
metadata: serde_json::Value::Null,
|
metadata: serde_json::Value::Null,
|
||||||
|
tool_output_stash: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ impl JobStore for LibSqlBackend {
|
|||||||
metadata: serde_json::Value::Null,
|
metadata: serde_json::Value::Null,
|
||||||
extra_env: std::sync::Arc::new(std::collections::HashMap::new()),
|
extra_env: std::sync::Arc::new(std::collections::HashMap::new()),
|
||||||
http_interceptor: None,
|
http_interceptor: None,
|
||||||
|
tool_output_stash: std::sync::Arc::new(tokio::sync::RwLock::new(
|
||||||
|
std::collections::HashMap::new(),
|
||||||
|
)),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
|
|||||||
@@ -238,6 +238,9 @@ impl Store {
|
|||||||
max_tokens: 0,
|
max_tokens: 0,
|
||||||
extra_env: std::sync::Arc::new(std::collections::HashMap::new()),
|
extra_env: std::sync::Arc::new(std::collections::HashMap::new()),
|
||||||
http_interceptor: None,
|
http_interceptor: None,
|
||||||
|
tool_output_stash: std::sync::Arc::new(tokio::sync::RwLock::new(
|
||||||
|
std::collections::HashMap::new(),
|
||||||
|
)),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
|
|||||||
@@ -689,6 +689,8 @@ Example:
|
|||||||
- If tools return empty or irrelevant results, answer with what you already know rather than retrying
|
- If tools return empty or irrelevant results, answer with what you already know rather than retrying
|
||||||
|
|
||||||
## Tool Call Style
|
## Tool Call Style
|
||||||
|
- ALWAYS call tools via tool_calls — never just describe what you would do
|
||||||
|
- If you say "let me fetch/check/look up X", you MUST include the actual tool call in the same response
|
||||||
- Do not narrate routine, low-risk tool calls; just call the tool
|
- Do not narrate routine, low-risk tool calls; just call the tool
|
||||||
- Narrate only when it helps: multi-step work, sensitive actions, or when the user asks
|
- Narrate only when it helps: multi-step work, sensitive actions, or when the user asks
|
||||||
- For multi-step tasks, call independent tools in parallel when possible
|
- For multi-step tasks, call independent tools in parallel when possible
|
||||||
@@ -1131,6 +1133,51 @@ fn recover_tool_calls_from_content(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bracket format from flatten_tool_messages:
|
||||||
|
// [Called tool `name` with arguments: {...}]
|
||||||
|
{
|
||||||
|
let mut remaining = content;
|
||||||
|
while let Some(start) = remaining.find("[Called tool `") {
|
||||||
|
let after_prefix = &remaining[start + "[Called tool `".len()..];
|
||||||
|
let Some(backtick_end) = after_prefix.find('`') else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let name = &after_prefix[..backtick_end];
|
||||||
|
let after_name = &after_prefix[backtick_end + 1..];
|
||||||
|
|
||||||
|
if !tool_names.contains(name) {
|
||||||
|
remaining = after_name;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for " with arguments: " followed by JSON until "]"
|
||||||
|
if let Some(args_start) = after_name.strip_prefix(" with arguments: ") {
|
||||||
|
// Find the closing "]" — but the JSON itself may contain "]",
|
||||||
|
// so find the last "]" on this logical line.
|
||||||
|
if let Some(bracket_end) = args_start.rfind(']') {
|
||||||
|
let args_str = &args_start[..bracket_end];
|
||||||
|
let arguments = serde_json::from_str::<serde_json::Value>(args_str)
|
||||||
|
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||||
|
calls.push(ToolCall {
|
||||||
|
id: format!("recovered_{}", calls.len()),
|
||||||
|
name: name.to_string(),
|
||||||
|
arguments,
|
||||||
|
});
|
||||||
|
remaining = &args_start[bracket_end + 1..];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No arguments or malformed — call with empty args
|
||||||
|
calls.push(ToolCall {
|
||||||
|
id: format!("recovered_{}", calls.len()),
|
||||||
|
name: name.to_string(),
|
||||||
|
arguments: serde_json::Value::Object(Default::default()),
|
||||||
|
});
|
||||||
|
remaining = after_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
calls
|
calls
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1174,10 +1221,39 @@ fn clean_response(text: &str) -> String {
|
|||||||
result = strip_pipe_tag(&result, tag);
|
result = strip_pipe_tag(&result, tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6b. Strip bracket-format inline tool calls: [Called tool `name` with arguments: {...}]
|
||||||
|
result = strip_bracket_tool_calls(&result);
|
||||||
|
|
||||||
// 7. Collapse triple+ newlines, trim
|
// 7. Collapse triple+ newlines, trim
|
||||||
collapse_newlines(&result)
|
collapse_newlines(&result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Strip bracket-format inline tool calls produced by `flatten_tool_messages`.
|
||||||
|
///
|
||||||
|
/// Removes patterns like `[Called tool `name` with arguments: {...}]` from text
|
||||||
|
/// so the user doesn't see raw tool call syntax when the model echoes it back.
|
||||||
|
fn strip_bracket_tool_calls(text: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(text.len());
|
||||||
|
let mut remaining = text;
|
||||||
|
while let Some(start) = remaining.find("[Called tool `") {
|
||||||
|
result.push_str(&remaining[..start]);
|
||||||
|
let after = &remaining[start..];
|
||||||
|
// Find the closing "]" for this bracket expression
|
||||||
|
if let Some(end) = after.find("]\n").map(|i| i + 2).or_else(|| {
|
||||||
|
// If it's at the end of the string, just find "]"
|
||||||
|
after.rfind(']').map(|i| i + 1)
|
||||||
|
}) {
|
||||||
|
remaining = &after[end..];
|
||||||
|
} else {
|
||||||
|
// Malformed — keep the rest
|
||||||
|
result.push_str(after);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push_str(remaining);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
/// Tool-related tags stripped with simple string matching (no code-awareness needed).
|
/// Tool-related tags stripped with simple string matching (no code-awareness needed).
|
||||||
const TOOL_TAGS: &[&str] = &["tool_call", "function_call", "tool_calls"];
|
const TOOL_TAGS: &[&str] = &["tool_call", "function_call", "tool_calls"];
|
||||||
|
|
||||||
@@ -1841,4 +1917,32 @@ That's my plan."#;
|
|||||||
assert_eq!(calls.len(), 1);
|
assert_eq!(calls.len(), 1);
|
||||||
assert_eq!(calls[0].name, "tool_list");
|
assert_eq!(calls[0].name, "tool_list");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_recover_bracket_format_tool_call() {
|
||||||
|
let tools = make_tools(&["http"]);
|
||||||
|
let content = "Let me try that. [Called tool `http` with arguments: {\"method\":\"GET\",\"url\":\"https://example.com\"}]";
|
||||||
|
let calls = recover_tool_calls_from_content(content, &tools);
|
||||||
|
assert_eq!(calls.len(), 1);
|
||||||
|
assert_eq!(calls[0].name, "http");
|
||||||
|
assert_eq!(calls[0].arguments["method"], "GET");
|
||||||
|
assert_eq!(calls[0].arguments["url"], "https://example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_recover_bracket_format_unknown_tool_ignored() {
|
||||||
|
let tools = make_tools(&["http"]);
|
||||||
|
let content = "[Called tool `unknown_tool` with arguments: {}]";
|
||||||
|
let calls = recover_tool_calls_from_content(content, &tools);
|
||||||
|
assert!(calls.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clean_response_strips_bracket_tool_calls() {
|
||||||
|
let input = "Let me fetch that.\n[Called tool `http` with arguments: {\"method\":\"GET\",\"url\":\"https://example.com\"}]\nHere are the results.";
|
||||||
|
let cleaned = clean_response(input);
|
||||||
|
assert!(!cleaned.contains("[Called tool"));
|
||||||
|
assert!(cleaned.contains("Let me fetch that."));
|
||||||
|
assert!(cleaned.contains("Here are the results."));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-6
@@ -47,14 +47,22 @@ impl SafetyLayer {
|
|||||||
|
|
||||||
/// Sanitize tool output before it reaches the LLM.
|
/// Sanitize tool output before it reaches the LLM.
|
||||||
pub fn sanitize_tool_output(&self, tool_name: &str, output: &str) -> SanitizedOutput {
|
pub fn sanitize_tool_output(&self, tool_name: &str, output: &str) -> SanitizedOutput {
|
||||||
// Check length limits first
|
// Check length limits — keep the beginning so the LLM has partial data
|
||||||
if output.len() > self.config.max_output_length {
|
if output.len() > self.config.max_output_length {
|
||||||
|
// Find a safe truncation point on a char boundary
|
||||||
|
let mut cut = self.config.max_output_length;
|
||||||
|
while cut > 0 && !output.is_char_boundary(cut) {
|
||||||
|
cut -= 1;
|
||||||
|
}
|
||||||
|
let truncated = &output[..cut];
|
||||||
|
let notice = format!(
|
||||||
|
"\n\n[... truncated: showing {}/{} bytes. Use the json tool with \
|
||||||
|
source_tool_call_id to query the full output.]",
|
||||||
|
cut,
|
||||||
|
output.len()
|
||||||
|
);
|
||||||
return SanitizedOutput {
|
return SanitizedOutput {
|
||||||
content: format!(
|
content: format!("{}{}", truncated, notice),
|
||||||
"[Output truncated: {} bytes exceeded maximum of {} bytes]",
|
|
||||||
output.len(),
|
|
||||||
self.config.max_output_length
|
|
||||||
),
|
|
||||||
warnings: vec![InjectionWarning {
|
warnings: vec![InjectionWarning {
|
||||||
pattern: "output_too_large".to_string(),
|
pattern: "output_too_large".to_string(),
|
||||||
severity: Severity::Low,
|
severity: Severity::Low,
|
||||||
|
|||||||
+181
-29
@@ -1,4 +1,12 @@
|
|||||||
//! HTTP request tool.
|
//! HTTP request tool.
|
||||||
|
//!
|
||||||
|
//! Unified HTTP tool that handles both simple page/API fetches (GET, no auth)
|
||||||
|
//! and full API calls (any method, custom headers, credential injection).
|
||||||
|
//!
|
||||||
|
//! - Plain GET without auth headers/body → no approval needed, follows redirects
|
||||||
|
//! - Everything else → requires approval
|
||||||
|
//!
|
||||||
|
//! Replaces the former `web_fetch` tool which was a separate GET-only tool.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::{IpAddr, ToSocketAddrs};
|
use std::net::{IpAddr, ToSocketAddrs};
|
||||||
@@ -25,6 +33,16 @@ use crate::tools::builtin::convert_html_to_markdown;
|
|||||||
/// HTTP wrapper uses the same limit for consistency.
|
/// HTTP wrapper uses the same limit for consistency.
|
||||||
const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024;
|
const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Maximum number of redirects to follow for simple GET requests.
|
||||||
|
const MAX_REDIRECTS: usize = 3;
|
||||||
|
|
||||||
|
/// Descriptive User-Agent so public APIs don't reject bare requests.
|
||||||
|
const USER_AGENT: &str = concat!(
|
||||||
|
"IronClaw-Agent/",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
" (https://github.com/nearai/ironclaw)"
|
||||||
|
);
|
||||||
|
|
||||||
/// Tool for making HTTP requests.
|
/// Tool for making HTTP requests.
|
||||||
pub struct HttpTool {
|
pub struct HttpTool {
|
||||||
client: Client,
|
client: Client,
|
||||||
@@ -38,6 +56,7 @@ impl HttpTool {
|
|||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.timeout(Duration::from_secs(30))
|
.timeout(Duration::from_secs(30))
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.user_agent(USER_AGENT)
|
||||||
.build()
|
.build()
|
||||||
.expect("Failed to create HTTP client");
|
.expect("Failed to create HTTP client");
|
||||||
|
|
||||||
@@ -201,7 +220,10 @@ impl Tool for HttpTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Make HTTP requests to external APIs. Supports GET, POST, PUT, DELETE methods."
|
"Make HTTP requests. Simple GET requests (no auth, no custom headers) run without \
|
||||||
|
approval and follow redirects — use for fetching weather, public JSON APIs, web pages, \
|
||||||
|
and documentation. Requests with authentication, custom headers, or non-GET methods \
|
||||||
|
(POST, PUT, DELETE, PATCH) require user approval."
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
@@ -368,25 +390,108 @@ impl Tool for HttpTool {
|
|||||||
return Ok(ToolOutput::success(result, start.elapsed()).with_raw(recorded.body));
|
return Ok(ToolOutput::success(result, start.elapsed()).with_raw(recorded.body));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute request
|
// Determine if this is a simple GET (eligible for redirect following).
|
||||||
let response = request.send().await.map_err(|e| {
|
let is_simple_get =
|
||||||
if e.is_timeout() {
|
method.eq_ignore_ascii_case("GET") && headers_vec.is_empty() && body_bytes.is_none();
|
||||||
ToolError::Timeout(Duration::from_secs(30))
|
|
||||||
} else {
|
// Execute request, optionally following redirects for simple GETs.
|
||||||
ToolError::ExternalService(e.to_string())
|
let response = if is_simple_get {
|
||||||
|
let mut redirects_remaining = MAX_REDIRECTS;
|
||||||
|
loop {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(parsed_url.clone())
|
||||||
|
.header(
|
||||||
|
reqwest::header::ACCEPT,
|
||||||
|
"text/markdown, text/html;q=0.9, application/json;q=0.9, */*;q=0.8",
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
if e.is_timeout() {
|
||||||
|
ToolError::Timeout(Duration::from_secs(30))
|
||||||
|
} else {
|
||||||
|
ToolError::ExternalService(e.to_string())
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
if (300..400).contains(&status) {
|
||||||
|
if redirects_remaining == 0 {
|
||||||
|
return Err(ToolError::ExecutionFailed(format!(
|
||||||
|
"too many redirects (max {})",
|
||||||
|
MAX_REDIRECTS
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let location = resp
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::LOCATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed(format!(
|
||||||
|
"redirect (HTTP {}) has no Location header",
|
||||||
|
status
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let next_url_str =
|
||||||
|
if location.starts_with("http://") || location.starts_with("https://") {
|
||||||
|
location.to_string()
|
||||||
|
} else {
|
||||||
|
parsed_url
|
||||||
|
.join(location)
|
||||||
|
.map(|u| u.to_string())
|
||||||
|
.map_err(|e| {
|
||||||
|
ToolError::ExecutionFailed(format!(
|
||||||
|
"could not resolve relative redirect '{}': {}",
|
||||||
|
location, e
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
// SSRF re-validation on every hop.
|
||||||
|
parsed_url = validate_url(&next_url_str)?;
|
||||||
|
let detector = LeakDetector::new();
|
||||||
|
detector
|
||||||
|
.scan_http_request(parsed_url.as_str(), &[], None)
|
||||||
|
.map_err(|e| ToolError::NotAuthorized(e.to_string()))?;
|
||||||
|
|
||||||
|
redirects_remaining -= 1;
|
||||||
|
tracing::debug!(
|
||||||
|
to = %parsed_url,
|
||||||
|
hops_left = redirects_remaining,
|
||||||
|
"http tool following redirect"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break resp;
|
||||||
}
|
}
|
||||||
})?;
|
} else {
|
||||||
|
let resp = request.send().await.map_err(|e| {
|
||||||
|
if e.is_timeout() {
|
||||||
|
ToolError::Timeout(Duration::from_secs(30))
|
||||||
|
} else {
|
||||||
|
ToolError::ExternalService(e.to_string())
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
|
||||||
|
// Block redirects for non-simple requests (potential SSRF)
|
||||||
|
if (300..400).contains(&status) {
|
||||||
|
return Err(ToolError::NotAuthorized(format!(
|
||||||
|
"request returned redirect (HTTP {}), which is blocked to prevent SSRF",
|
||||||
|
status
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
resp
|
||||||
|
};
|
||||||
|
|
||||||
let status = response.status().as_u16();
|
let status = response.status().as_u16();
|
||||||
|
|
||||||
// Block redirects: the server tried to send us elsewhere (potential SSRF)
|
|
||||||
if (300..400).contains(&status) {
|
|
||||||
return Err(ToolError::NotAuthorized(format!(
|
|
||||||
"request returned redirect (HTTP {}), which is blocked to prevent SSRF",
|
|
||||||
status
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let headers: HashMap<String, String> = response
|
let headers: HashMap<String, String> = response
|
||||||
.headers()
|
.headers()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -496,6 +601,25 @@ impl Tool for HttpTool {
|
|||||||
{
|
{
|
||||||
return ApprovalRequirement::Always;
|
return ApprovalRequirement::Always;
|
||||||
}
|
}
|
||||||
|
// 3. Plain GET without headers or body → no approval needed
|
||||||
|
let method = params
|
||||||
|
.get("method")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("GET");
|
||||||
|
let has_headers = params
|
||||||
|
.get("headers")
|
||||||
|
.map(|h| match h {
|
||||||
|
serde_json::Value::Array(a) => !a.is_empty(),
|
||||||
|
serde_json::Value::Object(o) => !o.is_empty(),
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
let has_body = params.get("body").is_some();
|
||||||
|
|
||||||
|
if method.eq_ignore_ascii_case("GET") && !has_headers && !has_body {
|
||||||
|
return ApprovalRequirement::Never;
|
||||||
|
}
|
||||||
|
|
||||||
// Default: outbound HTTP still needs approval unless auto-approved
|
// Default: outbound HTTP still needs approval unless auto-approved
|
||||||
ApprovalRequirement::UnlessAutoApproved
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
}
|
}
|
||||||
@@ -622,12 +746,37 @@ mod tests {
|
|||||||
// ── Approval requirement tests ──────────────────────────────────────
|
// ── Approval requirement tests ──────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_no_auth_headers_returns_unless_auto_approved() {
|
fn test_plain_get_returns_never() {
|
||||||
let tool = HttpTool::new();
|
let tool = HttpTool::new();
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "https://api.example.com/data"
|
"url": "https://api.example.com/data"
|
||||||
});
|
});
|
||||||
|
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_post_returns_unless_auto_approved() {
|
||||||
|
let tool = HttpTool::new();
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.example.com/data",
|
||||||
|
"body": {"key": "value"}
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
tool.requires_approval(¶ms),
|
||||||
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_with_headers_returns_unless_auto_approved() {
|
||||||
|
let tool = HttpTool::new();
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"method": "GET",
|
||||||
|
"url": "https://api.example.com/data",
|
||||||
|
"headers": [{"name": "X-Custom", "value": "test"}]
|
||||||
|
});
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tool.requires_approval(¶ms),
|
tool.requires_approval(¶ms),
|
||||||
ApprovalRequirement::UnlessAutoApproved
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
@@ -725,30 +874,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_empty_headers_return_unless_auto_approved() {
|
fn test_empty_headers_get_returns_never() {
|
||||||
let tool = HttpTool::new();
|
let tool = HttpTool::new();
|
||||||
|
|
||||||
// Empty object
|
// Empty object — still a plain GET
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "https://example.com",
|
"url": "https://example.com",
|
||||||
"headers": {}
|
"headers": {}
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never);
|
||||||
tool.requires_approval(¶ms),
|
|
||||||
ApprovalRequirement::UnlessAutoApproved
|
|
||||||
);
|
|
||||||
|
|
||||||
// Empty array
|
// Empty array — still a plain GET
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "https://example.com",
|
"url": "https://example.com",
|
||||||
"headers": []
|
"headers": []
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never);
|
||||||
tool.requires_approval(¶ms),
|
|
||||||
ApprovalRequirement::UnlessAutoApproved
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Credential registry approval tests ─────────────────────────────
|
// ── Credential registry approval tests ─────────────────────────────
|
||||||
@@ -783,7 +926,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_host_without_credential_mapping_returns_unless_auto_approved() {
|
fn test_host_without_credential_mapping_get_returns_never() {
|
||||||
use crate::tools::wasm::SharedCredentialRegistry;
|
use crate::tools::wasm::SharedCredentialRegistry;
|
||||||
|
|
||||||
let registry = Arc::new(SharedCredentialRegistry::new());
|
let registry = Arc::new(SharedCredentialRegistry::new());
|
||||||
@@ -799,10 +942,19 @@ mod tests {
|
|||||||
))),
|
))),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Plain GET with no credentials → Never
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "https://api.example.com/data"
|
"url": "https://api.example.com/data"
|
||||||
});
|
});
|
||||||
|
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never);
|
||||||
|
|
||||||
|
// POST with no credentials → UnlessAutoApproved
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.example.com/data",
|
||||||
|
"body": {"key": "value"}
|
||||||
|
});
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tool.requires_approval(¶ms),
|
tool.requires_approval(¶ms),
|
||||||
ApprovalRequirement::UnlessAutoApproved
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ impl Tool for JsonTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Parse, query, and transform JSON data. Supports JSONPath-like queries."
|
"Parse, query, and transform JSON data. Supports JSONPath-like queries. \
|
||||||
|
Use `source_tool_call_id` to reference the full output of a previous tool call \
|
||||||
|
(avoids truncation issues with large responses)."
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
@@ -28,27 +30,48 @@ impl Tool for JsonTool {
|
|||||||
"description": "The JSON operation to perform"
|
"description": "The JSON operation to perform"
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"description": "JSON input data. Pass a string for parse, or any JSON value (object, array, string, number, boolean, null) otherwise."
|
"description": "JSON input data. Pass a string for parse, or any JSON value otherwise. Not required when source_tool_call_id is provided."
|
||||||
|
},
|
||||||
|
"source_tool_call_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Reference a previous tool call's full output by its ID (e.g., 'call_abc123'). Use this instead of data when the previous tool output was large and may have been truncated."
|
||||||
},
|
},
|
||||||
"path": {
|
"path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "JSONPath-like path for query operation (e.g., 'foo.bar[0].baz')"
|
"description": "JSONPath-like path for query operation (e.g., 'foo.bar[0].baz')"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["operation", "data"]
|
"required": ["operation"]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
_ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let operation = require_str(¶ms, "operation")?;
|
let operation = require_str(¶ms, "operation")?;
|
||||||
|
|
||||||
let data = require_param(¶ms, "data")?;
|
// Resolve data: from stash (via source_tool_call_id) or from params
|
||||||
|
let data_value =
|
||||||
|
if let Some(ref_id) = params.get("source_tool_call_id").and_then(|v| v.as_str()) {
|
||||||
|
let stash = ctx.tool_output_stash.read().await;
|
||||||
|
let full_output = stash.get(ref_id).ok_or_else(|| {
|
||||||
|
ToolError::InvalidParameters(format!(
|
||||||
|
"no tool output found for call ID '{}'. Available IDs: {:?}",
|
||||||
|
ref_id,
|
||||||
|
stash.keys().collect::<Vec<_>>()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
// Parse the stashed output as JSON, or wrap as string
|
||||||
|
serde_json::from_str::<serde_json::Value>(full_output)
|
||||||
|
.unwrap_or_else(|_| serde_json::Value::String(full_output.clone()))
|
||||||
|
} else {
|
||||||
|
require_param(¶ms, "data")?.clone()
|
||||||
|
};
|
||||||
|
let data = &data_value;
|
||||||
|
|
||||||
let result = match operation {
|
let result = match operation {
|
||||||
"parse" => {
|
"parse" => {
|
||||||
@@ -64,7 +87,11 @@ impl Tool for JsonTool {
|
|||||||
parsed
|
parsed
|
||||||
}
|
}
|
||||||
"stringify" => {
|
"stringify" => {
|
||||||
let value = parse_json_input(data)?;
|
let value = if data.is_string() {
|
||||||
|
parse_json_input(data)?
|
||||||
|
} else {
|
||||||
|
data.clone()
|
||||||
|
};
|
||||||
let json_str = serde_json::to_string_pretty(&value).map_err(|e| {
|
let json_str = serde_json::to_string_pretty(&value).map_err(|e| {
|
||||||
ToolError::ExecutionFailed(format!("failed to stringify: {}", e))
|
ToolError::ExecutionFailed(format!("failed to stringify: {}", e))
|
||||||
})?;
|
})?;
|
||||||
@@ -76,7 +103,11 @@ impl Tool for JsonTool {
|
|||||||
ToolError::InvalidParameters("missing 'path' parameter for query".to_string())
|
ToolError::InvalidParameters("missing 'path' parameter for query".to_string())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let value = parse_json_input(data)?;
|
let value = if data.is_string() {
|
||||||
|
parse_json_input(data)?
|
||||||
|
} else {
|
||||||
|
data.clone()
|
||||||
|
};
|
||||||
query_json(&value, path)?
|
query_json(&value, path)?
|
||||||
}
|
}
|
||||||
"validate" => {
|
"validate" => {
|
||||||
@@ -190,6 +221,54 @@ mod tests {
|
|||||||
assert!(err.to_string().contains("invalid JSON input"));
|
assert!(err.to_string().contains("invalid JSON input"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_query_with_object_data_from_stash() {
|
||||||
|
use crate::context::JobContext;
|
||||||
|
|
||||||
|
let ctx = JobContext::with_user("test", "chat", "test-session");
|
||||||
|
|
||||||
|
// Simulate stashed output: the http tool stores serialized JSON
|
||||||
|
// containing {"status": 200, "body": {"leagues": [{"name": "MLB"}]}}
|
||||||
|
let stashed = r#"{"status": 200, "body": {"leagues": [{"name": "MLB"}]}}"#;
|
||||||
|
ctx.tool_output_stash
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert("call_http_01".to_string(), stashed.to_string());
|
||||||
|
|
||||||
|
let tool = JsonTool;
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"operation": "query",
|
||||||
|
"source_tool_call_id": "call_http_01",
|
||||||
|
"path": "body.leagues[0].name"
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = tool.execute(params, &ctx).await.unwrap();
|
||||||
|
assert_eq!(result.result, serde_json::json!("MLB"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_stringify_with_object_data_from_stash() {
|
||||||
|
use crate::context::JobContext;
|
||||||
|
|
||||||
|
let ctx = JobContext::with_user("test", "chat", "test-session");
|
||||||
|
|
||||||
|
let stashed = r#"{"key": "value"}"#;
|
||||||
|
ctx.tool_output_stash
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert("call_01".to_string(), stashed.to_string());
|
||||||
|
|
||||||
|
let tool = JsonTool;
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"operation": "stringify",
|
||||||
|
"source_tool_call_id": "call_01"
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = tool.execute(params, &ctx).await.unwrap();
|
||||||
|
let stringified = result.result.as_str().unwrap();
|
||||||
|
assert!(stringified.contains("\"key\": \"value\""));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_json_tool_schema_data_is_freeform() {
|
fn test_json_tool_schema_data_is_freeform() {
|
||||||
let schema = JsonTool.parameters_schema();
|
let schema = JsonTool.parameters_schema();
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ pub mod secrets_tools;
|
|||||||
pub(crate) mod shell;
|
pub(crate) mod shell;
|
||||||
pub mod skill_tools;
|
pub mod skill_tools;
|
||||||
mod time;
|
mod time;
|
||||||
mod web_fetch;
|
|
||||||
|
|
||||||
pub use echo::EchoTool;
|
pub use echo::EchoTool;
|
||||||
pub use extension_tools::{
|
pub use extension_tools::{
|
||||||
@@ -36,8 +35,6 @@ pub use secrets_tools::{SecretDeleteTool, SecretListTool};
|
|||||||
pub use shell::ShellTool;
|
pub use shell::ShellTool;
|
||||||
pub use skill_tools::{SkillInstallTool, SkillListTool, SkillRemoveTool, SkillSearchTool};
|
pub use skill_tools::{SkillInstallTool, SkillListTool, SkillRemoveTool, SkillSearchTool};
|
||||||
pub use time::TimeTool;
|
pub use time::TimeTool;
|
||||||
pub use web_fetch::WebFetchTool;
|
|
||||||
|
|
||||||
mod html_converter;
|
mod html_converter;
|
||||||
|
|
||||||
pub use html_converter::convert_html_to_markdown;
|
pub use html_converter::convert_html_to_markdown;
|
||||||
|
|||||||
@@ -1,378 +0,0 @@
|
|||||||
//! Web fetch tool — GET a URL and return its content as clean Markdown.
|
|
||||||
//!
|
|
||||||
//! Distinct from the generic `http` tool (which handles API calls with full
|
|
||||||
//! method/header/body control). `web_fetch` is purpose-built for reading web
|
|
||||||
//! pages, articles, and documentation:
|
|
||||||
//!
|
|
||||||
//! - GET-only, no custom headers or body
|
|
||||||
//! - Always attempts HTML → Markdown conversion via Readability
|
|
||||||
//! - Returns structured output: `{url, final_url, status, title, content, word_count}`
|
|
||||||
//! - Auto-approved (no confirmation prompt)
|
|
||||||
//! - Follows up to 3 redirects, SSRF-validating each hop
|
|
||||||
//!
|
|
||||||
//! All the same security infrastructure as `http`:
|
|
||||||
//! HTTPS-only, SSRF protection, DNS rebinding defence, outbound/inbound leak
|
|
||||||
//! scanning, 5 MB response cap.
|
|
||||||
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use futures::StreamExt;
|
|
||||||
use reqwest::Client;
|
|
||||||
|
|
||||||
use crate::context::JobContext;
|
|
||||||
use crate::safety::LeakDetector;
|
|
||||||
use crate::tools::builtin::http::validate_url;
|
|
||||||
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, ToolRateLimitConfig};
|
|
||||||
|
|
||||||
#[cfg(feature = "html-to-markdown")]
|
|
||||||
use crate::tools::builtin::convert_html_to_markdown;
|
|
||||||
|
|
||||||
/// Maximum response body size — matches the `http` tool limit.
|
|
||||||
const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024;
|
|
||||||
|
|
||||||
/// Maximum number of redirects to follow before giving up.
|
|
||||||
const MAX_REDIRECTS: usize = 3;
|
|
||||||
|
|
||||||
/// Chrome-like User-Agent — many sites block default `reqwest` strings.
|
|
||||||
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
|
|
||||||
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
|
||||||
|
|
||||||
/// Extract the `<title>` text from raw HTML without a full DOM parser.
|
|
||||||
///
|
|
||||||
/// Uses `to_ascii_lowercase()` (not `to_lowercase()`) so that byte offsets
|
|
||||||
/// remain valid across both strings. HTML tag names are ASCII-only, so
|
|
||||||
/// ASCII-only case folding is sufficient. Unicode `to_lowercase()` can
|
|
||||||
/// change byte lengths (e.g. `İ` → `i\u{307}`), making offsets derived
|
|
||||||
/// from the lowercased string invalid when used to index into the original.
|
|
||||||
fn extract_title(html: &str) -> Option<String> {
|
|
||||||
let lower = html.to_ascii_lowercase();
|
|
||||||
let tag_start = lower.find("<title")?;
|
|
||||||
let tag_end = html[tag_start..].find('>')? + tag_start + 1;
|
|
||||||
let close = lower[tag_end..].find("</title>")? + tag_end;
|
|
||||||
let title = html[tag_end..close].trim().to_string();
|
|
||||||
if title.is_empty() { None } else { Some(title) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Web fetch tool — retrieve a URL and return clean Markdown content.
|
|
||||||
pub struct WebFetchTool {
|
|
||||||
client: Client,
|
|
||||||
leak_detector: LeakDetector,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WebFetchTool {
|
|
||||||
/// Create a new `WebFetchTool` with a Chrome-like UA and no auto-redirects.
|
|
||||||
///
|
|
||||||
/// Redirects are followed manually (up to [`MAX_REDIRECTS`] hops) so that
|
|
||||||
/// each `Location` URL is SSRF-validated before the next request is sent.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let client = Client::builder()
|
|
||||||
.timeout(Duration::from_secs(30))
|
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
|
||||||
.user_agent(USER_AGENT)
|
|
||||||
.build()
|
|
||||||
.expect("Failed to create HTTP client for web_fetch");
|
|
||||||
|
|
||||||
Self {
|
|
||||||
client,
|
|
||||||
leak_detector: LeakDetector::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for WebFetchTool {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for WebFetchTool {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"web_fetch"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
|
||||||
"Fetch a URL and extract its content as clean Markdown. \
|
|
||||||
Use for reading articles, documentation, and web pages. \
|
|
||||||
For API calls (POST, custom headers, authentication), use the `http` tool instead."
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"url": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "HTTPS URL to fetch. Must be a public URL (no localhost or private IPs)."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["url"],
|
|
||||||
"additionalProperties": false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
params: serde_json::Value,
|
|
||||||
_ctx: &JobContext,
|
|
||||||
) -> Result<ToolOutput, ToolError> {
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
let url_str = params
|
|
||||||
.get("url")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("'url' is required".to_string()))?;
|
|
||||||
|
|
||||||
// SSRF defence: HTTPS-only, no localhost, no private IPs, DNS rebinding check.
|
|
||||||
let mut current_url = validate_url(url_str)?;
|
|
||||||
|
|
||||||
// Outbound leak scan — reject if URL contains secrets.
|
|
||||||
self.leak_detector
|
|
||||||
.scan_http_request(current_url.as_str(), &[], None)
|
|
||||||
.map_err(|e| ToolError::NotAuthorized(e.to_string()))?;
|
|
||||||
|
|
||||||
// Follow redirects manually so every hop is SSRF-validated.
|
|
||||||
let response = {
|
|
||||||
let mut redirects_remaining = MAX_REDIRECTS;
|
|
||||||
loop {
|
|
||||||
let resp = self
|
|
||||||
.client
|
|
||||||
.get(current_url.clone())
|
|
||||||
.header(
|
|
||||||
reqwest::header::ACCEPT,
|
|
||||||
"text/markdown, text/html;q=0.9, */*;q=0.8",
|
|
||||||
)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
if e.is_timeout() {
|
|
||||||
ToolError::Timeout(Duration::from_secs(30))
|
|
||||||
} else {
|
|
||||||
ToolError::ExternalService(e.to_string())
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let status = resp.status().as_u16();
|
|
||||||
|
|
||||||
if (300..400).contains(&status) {
|
|
||||||
if redirects_remaining == 0 {
|
|
||||||
return Err(ToolError::ExecutionFailed(format!(
|
|
||||||
"too many redirects (max {})",
|
|
||||||
MAX_REDIRECTS
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let location = resp
|
|
||||||
.headers()
|
|
||||||
.get(reqwest::header::LOCATION)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::ExecutionFailed(format!(
|
|
||||||
"redirect (HTTP {}) has no Location header",
|
|
||||||
status
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Resolve relative redirects against the current URL.
|
|
||||||
let next_url_str =
|
|
||||||
if location.starts_with("http://") || location.starts_with("https://") {
|
|
||||||
location.to_string()
|
|
||||||
} else {
|
|
||||||
// Relative redirect — join with current URL.
|
|
||||||
current_url
|
|
||||||
.join(location)
|
|
||||||
.map(|u| u.to_string())
|
|
||||||
.map_err(|e| {
|
|
||||||
ToolError::ExecutionFailed(format!(
|
|
||||||
"could not resolve relative redirect '{}': {}",
|
|
||||||
location, e
|
|
||||||
))
|
|
||||||
})?
|
|
||||||
};
|
|
||||||
|
|
||||||
// SSRF re-validation on every hop.
|
|
||||||
current_url = validate_url(&next_url_str)?;
|
|
||||||
self.leak_detector
|
|
||||||
.scan_http_request(current_url.as_str(), &[], None)
|
|
||||||
.map_err(|e| ToolError::NotAuthorized(e.to_string()))?;
|
|
||||||
|
|
||||||
redirects_remaining -= 1;
|
|
||||||
tracing::debug!(
|
|
||||||
to = %current_url,
|
|
||||||
hops_left = redirects_remaining,
|
|
||||||
"web_fetch following redirect"
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
break resp;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let status = response.status().as_u16();
|
|
||||||
|
|
||||||
// Detect content type before consuming the response.
|
|
||||||
let content_type = response
|
|
||||||
.headers()
|
|
||||||
.get(reqwest::header::CONTENT_TYPE)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_lowercase();
|
|
||||||
|
|
||||||
// Pre-check Content-Length to reject obviously oversized responses.
|
|
||||||
if let Some(content_length) = response.headers().get(reqwest::header::CONTENT_LENGTH)
|
|
||||||
&& let Ok(s) = content_length.to_str()
|
|
||||||
&& let Ok(len) = s.parse::<usize>()
|
|
||||||
&& len > MAX_RESPONSE_SIZE
|
|
||||||
{
|
|
||||||
return Err(ToolError::ExecutionFailed(format!(
|
|
||||||
"Response Content-Length ({} bytes) exceeds maximum allowed size ({} bytes)",
|
|
||||||
len, MAX_RESPONSE_SIZE
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream body with a hard 5 MB cap.
|
|
||||||
let mut body: Vec<u8> = Vec::new();
|
|
||||||
let mut stream = response.bytes_stream();
|
|
||||||
while let Some(chunk) = StreamExt::next(&mut stream).await {
|
|
||||||
let chunk = chunk.map_err(|e| {
|
|
||||||
ToolError::ExternalService(format!("failed to read response body: {}", e))
|
|
||||||
})?;
|
|
||||||
if body.len() + chunk.len() > MAX_RESPONSE_SIZE {
|
|
||||||
return Err(ToolError::ExecutionFailed(format!(
|
|
||||||
"Response body exceeds maximum allowed size ({} bytes)",
|
|
||||||
MAX_RESPONSE_SIZE
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
body.extend_from_slice(&chunk);
|
|
||||||
}
|
|
||||||
|
|
||||||
let raw_text = String::from_utf8_lossy(&body).into_owned();
|
|
||||||
|
|
||||||
// HTML → Markdown conversion (always attempted for HTML responses).
|
|
||||||
let is_html = content_type.contains("text/html");
|
|
||||||
|
|
||||||
let (content, title) = if is_html {
|
|
||||||
let title = extract_title(&raw_text);
|
|
||||||
|
|
||||||
#[cfg(feature = "html-to-markdown")]
|
|
||||||
let content = match convert_html_to_markdown(&raw_text, current_url.as_str()) {
|
|
||||||
Ok(md) => md,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
url = %current_url,
|
|
||||||
error = %e,
|
|
||||||
"HTML-to-markdown conversion failed, returning raw text"
|
|
||||||
);
|
|
||||||
raw_text.clone()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(not(feature = "html-to-markdown"))]
|
|
||||||
let content = raw_text.clone();
|
|
||||||
|
|
||||||
(content, title)
|
|
||||||
} else {
|
|
||||||
(raw_text.clone(), None)
|
|
||||||
};
|
|
||||||
|
|
||||||
let word_count = content.split_whitespace().count();
|
|
||||||
|
|
||||||
let result = serde_json::json!({
|
|
||||||
"url": url_str,
|
|
||||||
"final_url": current_url.as_str(),
|
|
||||||
"status": status,
|
|
||||||
"title": title,
|
|
||||||
"content": content,
|
|
||||||
"word_count": word_count,
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(ToolOutput::success(result, start.elapsed()).with_raw(raw_text))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn estimated_duration(&self, _params: &serde_json::Value) -> Option<Duration> {
|
|
||||||
Some(Duration::from_secs(5))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn requires_sanitization(&self) -> bool {
|
|
||||||
true // External data always needs sanitization
|
|
||||||
}
|
|
||||||
|
|
||||||
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
|
||||||
// Web fetch is always auto-approved — the SSRF/leak protections are
|
|
||||||
// unconditional, and reading public web pages doesn't require confirmation.
|
|
||||||
ApprovalRequirement::Never
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rate_limit_config(&self) -> Option<ToolRateLimitConfig> {
|
|
||||||
Some(ToolRateLimitConfig::new(30, 500)) // same as http tool
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_title_finds_basic_title() {
|
|
||||||
let html = "<html><head><title>Hello World</title></head><body></body></html>";
|
|
||||||
assert_eq!(extract_title(html), Some("Hello World".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_title_trims_whitespace() {
|
|
||||||
let html = "<html><head><title> Spaced Title </title></head></html>";
|
|
||||||
assert_eq!(extract_title(html), Some("Spaced Title".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_title_returns_none_when_absent() {
|
|
||||||
let html = "<html><head></head><body>No title</body></html>";
|
|
||||||
assert_eq!(extract_title(html), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_title_handles_case_insensitive_tag() {
|
|
||||||
let html = "<html><head><TITLE>Case Test</TITLE></head></html>";
|
|
||||||
assert_eq!(extract_title(html), Some("Case Test".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_title_with_non_ascii_before_tag() {
|
|
||||||
// Turkish dotless-ı (U+0131) is 2 bytes in UTF-8 and lowercases to
|
|
||||||
// ASCII 'i' (1 byte). Using to_lowercase() would shift the byte offset
|
|
||||||
// of '<title>' so that html[tag_start..] panics at a non-char boundary.
|
|
||||||
// to_ascii_lowercase() preserves byte lengths and must not panic.
|
|
||||||
let html = "<html><head><meta charset=\"utf-8\"/><title>ıTitle</title></head></html>";
|
|
||||||
let result = extract_title(html);
|
|
||||||
assert!(
|
|
||||||
result.is_some(),
|
|
||||||
"should extract title with non-ASCII content"
|
|
||||||
);
|
|
||||||
assert!(result.unwrap().contains("Title"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_title_with_tag_attributes() {
|
|
||||||
// <title lang="en"> has attributes — ensure the '>' scan still lands correctly.
|
|
||||||
let html = "<html><head><title lang=\"en\">Attributed</title></head></html>";
|
|
||||||
assert_eq!(extract_title(html), Some("Attributed".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn web_fetch_tool_name_and_schema() {
|
|
||||||
let tool = WebFetchTool::new();
|
|
||||||
assert_eq!(tool.name(), "web_fetch");
|
|
||||||
let schema = tool.parameters_schema();
|
|
||||||
assert_eq!(schema["required"][0], "url");
|
|
||||||
assert_eq!(schema["properties"]["url"]["type"], "string");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn web_fetch_never_requires_approval() {
|
|
||||||
let tool = WebFetchTool::new();
|
|
||||||
let params = serde_json::json!({"url": "https://example.com"});
|
|
||||||
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,7 @@ use crate::tools::builtin::{
|
|||||||
JobStatusTool, JsonTool, ListDirTool, ListJobsTool, MemoryReadTool, MemorySearchTool,
|
JobStatusTool, JsonTool, ListDirTool, ListJobsTool, MemoryReadTool, MemorySearchTool,
|
||||||
MemoryTreeTool, MemoryWriteTool, PromptQueue, ReadFileTool, ShellTool, SkillInstallTool,
|
MemoryTreeTool, MemoryWriteTool, PromptQueue, ReadFileTool, ShellTool, SkillInstallTool,
|
||||||
SkillListTool, SkillRemoveTool, SkillSearchTool, TimeTool, ToolActivateTool, ToolAuthTool,
|
SkillListTool, SkillRemoveTool, SkillSearchTool, TimeTool, ToolActivateTool, ToolAuthTool,
|
||||||
ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool, WebFetchTool, WriteFileTool,
|
ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool, WriteFileTool,
|
||||||
};
|
};
|
||||||
use crate::tools::rate_limiter::RateLimiter;
|
use crate::tools::rate_limiter::RateLimiter;
|
||||||
use crate::tools::tool::{Tool, ToolDomain};
|
use crate::tools::tool::{Tool, ToolDomain};
|
||||||
@@ -68,7 +68,6 @@ const PROTECTED_TOOL_NAMES: &[&str] = &[
|
|||||||
"skill_install",
|
"skill_install",
|
||||||
"skill_remove",
|
"skill_remove",
|
||||||
"message",
|
"message",
|
||||||
"web_fetch",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Registry of available tools.
|
/// Registry of available tools.
|
||||||
@@ -230,7 +229,6 @@ impl ToolRegistry {
|
|||||||
http = http.with_credentials(Arc::clone(cr), Arc::clone(ss));
|
http = http.with_credentials(Arc::clone(cr), Arc::clone(ss));
|
||||||
}
|
}
|
||||||
self.register_sync(Arc::new(http));
|
self.register_sync(Arc::new(http));
|
||||||
self.register_sync(Arc::new(WebFetchTool::new()));
|
|
||||||
|
|
||||||
tracing::info!("Registered {} built-in tools", self.count());
|
tracing::info!("Registered {} built-in tools", self.count());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,4 +15,17 @@ mod recorded_trace_tests {
|
|||||||
async fn recorded_telegram_check() {
|
async fn recorded_telegram_check() {
|
||||||
run_recorded_trace("telegram_check.json").await;
|
run_recorded_trace("telegram_check.json").await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recorded trace: weather query for San Francisco.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recorded_weather_sf() {
|
||||||
|
run_recorded_trace("weather_sf.json").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recorded trace: baseball stats with large HTTP response exercising
|
||||||
|
/// tool_output_stash + source_tool_call_id for untruncated data access.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recorded_baseball_stats() {
|
||||||
|
run_recorded_trace("baseball_stats.json").await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -429,7 +429,13 @@ impl TestRigBuilder {
|
|||||||
let session = Arc::new(SessionManager::new(SessionConfig::default()));
|
let session = Arc::new(SessionManager::new(SessionConfig::default()));
|
||||||
let log_broadcaster = Arc::new(LogBroadcaster::new());
|
let log_broadcaster = Arc::new(LogBroadcaster::new());
|
||||||
|
|
||||||
// 4. Create TraceLlm + InstrumentedLlm.
|
// 4. Create TraceLlm + InstrumentedLlm, extract HTTP exchanges for replay.
|
||||||
|
let http_exchanges = self
|
||||||
|
.trace
|
||||||
|
.as_ref()
|
||||||
|
.map(|t| t.http_exchanges.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let base_llm: Arc<dyn LlmProvider> = if let Some(llm) = self.llm {
|
let base_llm: Arc<dyn LlmProvider> = if let Some(llm) = self.llm {
|
||||||
llm
|
llm
|
||||||
} else if let Some(trace) = self.trace {
|
} else if let Some(trace) = self.trace {
|
||||||
@@ -483,7 +489,13 @@ impl TestRigBuilder {
|
|||||||
hooks: components.hooks,
|
hooks: components.hooks,
|
||||||
cost_guard: components.cost_guard,
|
cost_guard: components.cost_guard,
|
||||||
sse_tx: None,
|
sse_tx: None,
|
||||||
http_interceptor: None,
|
http_interceptor: if http_exchanges.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(Arc::new(
|
||||||
|
ironclaw::llm::recording::ReplayingHttpInterceptor::new(http_exchanges),
|
||||||
|
))
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 7. Create TestChannel and ChannelManager.
|
// 7. Create TestChannel and ChannelManager.
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ async fn core_registration_covers_expected_tools() {
|
|||||||
"read_file",
|
"read_file",
|
||||||
"shell",
|
"shell",
|
||||||
"time",
|
"time",
|
||||||
"web_fetch",
|
|
||||||
"write_file",
|
"write_file",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user