Files
optimclaw/tests/tool_schema_validation.rs
T
8751a5a9bc feat: add web_fetch built-in tool (#435)
* feat: add web_fetch built-in tool and web-fetch skill

- New web_fetch Rust built-in tool (GET-only, auto-approved, structured
  output: url/title/content/word_count) with HTML to Markdown via Readability
- Full SSRF protection: HTTPS-only, no private IPs, DNS rebinding defence,
  outbound/inbound leak scanning, 5 MB cap, no redirect following
- Rate limited: 30 req/min, 500/hr (same as http tool)
- Protected tool name; registered in register_builtin_tools()
- validate_url made pub(crate) so web_fetch can reuse it from http.rs
- New skills/web-fetch/SKILL.md for agent guidance on web browsing
- Fixes unicode panic in extract_title: use to_ascii_lowercase not
  to_lowercase to preserve byte offsets when indexing original string

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* chore: remove web-fetch skill (tool description is self-sufficient)

The web_fetch tool's schema description already tells the LLM when and
how to use it. A SKILL.md would only add redundant prompt context.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix: include HTTP status in web_fetch output

The LLM had no way to distinguish a 404 error page from a 200 success.
Including status in the structured output (alongside url/title/content/
word_count) lets the agent report failures correctly and matches the
behaviour of the http tool which always returns status.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* feat(web_fetch): add Chrome UA and safe redirect following

- Set a Chrome-like User-Agent so sites that block the default reqwest
  string return real content instead of bot-rejection pages.
- Add Accept: text/markdown, text/html header (mirrors OpenClaw).
- Follow up to 3 redirects manually instead of blocking all 3xx.
  Every Location URL is run through validate_url() before the next
  request is sent, so SSRF protection applies to every hop identically
  to how it applies to the original URL.
- Resolve relative Location values against the current URL before
  SSRF-validating them.
- Log each followed hop at DEBUG level.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(web_fetch): expose final_url after redirect following

When redirects are followed, the original `url` field no longer
reflects where the content actually came from. Add `final_url` so
the LLM can cite the canonical source correctly. Equals `url` when
no redirects occurred.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(web_fetch): address review comments and fix CI failures

- Store LeakDetector in WebFetchTool struct (init once in new(), not per execute() call)
- Use self.leak_detector for both outbound scan and redirect re-validation
- Simplify HTML/cfg blocks to reduce duplication (gemini-code-assist suggestion)
- Fix pub use ordering in mod.rs (cargo fmt)
- Add web_fetch to core_registration_covers_expected_tools snapshot test

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-02-28 19:58:22 -08:00

142 lines
4.6 KiB
Rust

//! Validates that all built-in tool schemas conform to OpenAI strict-mode rules.
//!
//! This catches the class of bugs where `required` keys aren't in `properties`,
//! properties are missing `type` (intentional freeform is allowed), or nested
//! objects/arrays are malformed.
//!
//! See: <https://github.com/nearai/ironclaw/issues/352> (QA plan, item 1.1)
use ironclaw::tools::validate_tool_schema;
use ironclaw::tools::{Tool, ToolRegistry};
/// Validate schemas of all tools registered via `register_builtin_tools()` and
/// `register_dev_tools()` (echo, time, json, http, shell, file tools).
///
/// These tools can be constructed without external dependencies (no DB, no
/// workspace, no extension manager). Tools requiring dependencies (memory, job,
/// skill, extension, routine) are validated individually below where test
/// construction helpers exist.
#[tokio::test]
async fn all_core_builtin_tool_schemas_are_valid() {
let registry = ToolRegistry::new();
registry.register_builtin_tools();
registry.register_dev_tools();
let tools = registry.all().await;
assert!(
!tools.is_empty(),
"registry should have tools after registration"
);
let mut all_errors = Vec::new();
for tool in &tools {
let schema = tool.parameters_schema();
let errors = validate_tool_schema(&schema, tool.name());
if !errors.is_empty() {
all_errors.push(format!(
"Tool '{}' has schema errors:\n {}",
tool.name(),
errors.join("\n ")
));
}
}
assert!(
all_errors.is_empty(),
"Tool schema validation failures:\n{}",
all_errors.join("\n\n")
);
}
/// Verify the exact set of tools registered by the core registration methods.
/// This guards against a new tool being added without schema validation coverage.
#[tokio::test]
async fn core_registration_covers_expected_tools() {
let registry = ToolRegistry::new();
registry.register_builtin_tools();
registry.register_dev_tools();
let mut names = registry.list().await;
names.sort();
let expected = &[
"apply_patch",
"echo",
"http",
"json",
"list_dir",
"read_file",
"shell",
"time",
"web_fetch",
"write_file",
];
assert_eq!(
names, expected,
"Core tool set changed. Update this test and ensure new tools have valid schemas."
);
}
/// Validate individual tool schemas that are known to use non-trivial patterns.
/// These are regression tests for specific bugs.
#[test]
fn json_tool_freeform_data_field_is_valid() {
// Regression: json tool's "data" field intentionally has no "type" for
// OpenAI compatibility (union types with arrays require "items").
let tool = ironclaw::tools::builtin::JsonTool;
let schema = tool.parameters_schema();
let errors = validate_tool_schema(&schema, "json");
assert!(errors.is_empty(), "json tool schema errors: {errors:?}");
// Verify the freeform pattern is still in place
let data = schema
.get("properties")
.and_then(|p| p.get("data"))
.expect("json tool should have 'data' property");
assert!(
data.get("type").is_none(),
"json.data should be freeform (no type) for OpenAI compatibility"
);
}
#[test]
fn http_tool_headers_array_is_valid() {
// Regression: http tool's "headers" is an array of {name, value} objects.
let tool = ironclaw::tools::builtin::HttpTool::new();
let schema = tool.parameters_schema();
let errors = validate_tool_schema(&schema, "http");
assert!(errors.is_empty(), "http tool schema errors: {errors:?}");
// Verify array structure
let headers = schema
.get("properties")
.and_then(|p| p.get("headers"))
.expect("http tool should have 'headers' property");
assert_eq!(
headers.get("type").and_then(|t| t.as_str()),
Some("array"),
"headers should be an array"
);
assert!(
headers.get("items").is_some(),
"headers array should have items defined"
);
}
#[test]
fn time_tool_schema_is_valid() {
let tool = ironclaw::tools::builtin::TimeTool;
let schema = tool.parameters_schema();
let errors = validate_tool_schema(&schema, "time");
assert!(errors.is_empty(), "time tool schema errors: {errors:?}");
}
#[test]
fn shell_tool_schema_is_valid() {
let tool = ironclaw::tools::builtin::ShellTool::new();
let schema = tool.parameters_schema();
let errors = validate_tool_schema(&schema, "shell");
assert!(errors.is_empty(), "shell tool schema errors: {errors:?}");
}