| 1 | //! Real pinned Builtin + actual Core captures; no external provider calls. |
| 2 | use super::*; |
| 3 | use crate::extension_host::tests::node_for_tests; |
| 4 | use crate::extension_host::{ExtensionHostManager, ExtensionHostOptions, TestManagerGuard}; |
| 5 | use crate::features::{Feature, Features}; |
| 6 | use crate::plugins::activation::TestPolicyGuard; |
| 7 | use std::sync::Arc; |
| 8 | |
| 9 | fn host_context(root: &std::path::Path) -> ToolContext { |
| 10 | let mut flags = Features::with_defaults(); |
| 11 | flags.enable(Feature::WebSearchHost); |
| 12 | ToolContext::new(root).with_features(flags) |
| 13 | } |
| 14 | fn new_manager(node: std::path::PathBuf, root: &std::path::Path) -> Arc<ExtensionHostManager> { |
| 15 | Arc::new(ExtensionHostManager::new(ExtensionHostOptions { |
| 16 | runtime: crate::config::ExtensionHostRuntime::Node, |
| 17 | node_override: Some(node), |
| 18 | root: Some(root.join("host")), |
| 19 | ..ExtensionHostOptions::default() |
| 20 | })) |
| 21 | } |
| 22 | #[test] |
| 23 | fn web_flags_are_separate_and_default_to_rust() { |
| 24 | let mut flags = Features::with_defaults(); |
| 25 | assert!(!flags.enabled(Feature::WebSearchHost)); |
| 26 | assert!(!flags.enabled(Feature::WebExtractHost)); |
| 27 | flags.enable(Feature::WebSearchHost); |
| 28 | assert!(!flags.enabled(Feature::ExtensionHost)); |
| 29 | assert!(!flags.enabled(Feature::WebExtractHost)); |
| 30 | assert_eq!( |
| 31 | crate::features::feature_from_key("web_search_host"), |
| 32 | Some(Feature::WebSearchHost) |
| 33 | ); |
| 34 | assert_eq!( |
| 35 | crate::features::feature_from_key("web_extract_host"), |
| 36 | Some(Feature::WebExtractHost) |
| 37 | ); |
| 38 | } |
| 39 | #[test] |
| 40 | fn provider_snapshot_removes_unknown_credentials_and_uses_opaque_source_handles() { |
| 41 | let raw = json!({"api_key":"fixture-secret","private":{"token":"fixture-secret"},"results":[{"title":"Echo fixture-secret","url":" https://user:password@example.com/page?token=fixture-secret ","content":"Visible fixture-secret","credential":"fixture-secret"}]}); |
| 42 | let mut urls = OpaqueUrls::default(); |
| 43 | let captured = provider_projection(&raw, &mut urls, Some("fixture-secret")); |
| 44 | let wire = captured.to_string(); |
| 45 | assert!(!wire.contains("fixture-secret")); |
| 46 | assert!(!wire.contains("password")); |
| 47 | assert!(!wire.contains("example.com")); |
| 48 | assert!(!wire.contains("api_key")); |
| 49 | let handle = captured["results"][0]["url"].as_str().unwrap(); |
| 50 | assert_eq!( |
| 51 | urls.restore(handle).unwrap(), |
| 52 | raw["results"][0]["url"].as_str().unwrap() |
| 53 | ); |
| 54 | assert_eq!( |
| 55 | urls.restore(handle.trim()).unwrap(), |
| 56 | raw["results"][0]["url"].as_str().unwrap().trim() |
| 57 | ); |
| 58 | assert!(!urls.restore("invented-source").unwrap_err().content()); |
| 59 | } |
| 60 | #[tokio::test(flavor = "current_thread")] |
| 61 | async fn real_host_all_nine_provider_decoders_match_current_rust_semantics() { |
| 62 | let _home = crate::test_support::SealedHome::new(); |
| 63 | let _policy = TestPolicyGuard::extension_host(false); |
| 64 | let Some(node) = node_for_tests("real_host_web_provider_parity") else { |
| 65 | return; |
| 66 | }; |
| 67 | let root = tempfile::tempdir().unwrap(); |
| 68 | let manager = new_manager(node, root.path()); |
| 69 | let _manager = TestManagerGuard::install(Arc::clone(&manager)); |
| 70 | let context = host_context(root.path()); |
| 71 | let rows = json!([{"title":" \u{0085}A\u{0085} ","url":" https://example.com/a?private=1 ","content":null,"snippet":" fallback ","score":-0.0}, |
| 72 | {"title":"B","url":"https://example.com/b","content":" body ","score":"3.2"}, |
| 73 | {"title":"","url":"https://example.com/empty"}]); |
| 74 | let cases = vec![ |
| 75 | ( |
| 76 | "tavily", |
| 77 | json!({"results":rows}), |
| 78 | parse_tavily_results as fn(&Value, usize) -> Vec<WebSearchEntry>, |
| 79 | ), |
| 80 | ( |
| 81 | "firecrawl", |
| 82 | json!({"data":{"web":rows},"success":true}), |
| 83 | parse_firecrawl_results, |
| 84 | ), |
| 85 | ( |
| 86 | "metaso", |
| 87 | json!({"webpages":[{"title":" A ","link":" https://example.com/a ","snippet":null,"summary":" fallback "}],"code":0}), |
| 88 | parse_metaso_results, |
| 89 | ), |
| 90 | ( |
| 91 | "bocha", |
| 92 | json!({"pages":[{"name":null,"title":"unused","url":"https://example.com/unused"},{"name":" A ","link":" https://example.com/a ","summary":null,"snippet":"unused"}],"code":200}), |
| 93 | parse_bocha_results, |
| 94 | ), |
| 95 | ( |
| 96 | "baidu", |
| 97 | json!({"references":[{"title":" A ","link":" https://example.com/a ","content":null,"snippet":"unused"}],"error_code":0}), |
| 98 | parse_baidu_results, |
| 99 | ), |
| 100 | ("searxng", json!({"results":rows}), parse_searxng_results), |
| 101 | ("sofya", json!({"results":rows}), parse_sofya_results), |
| 102 | ( |
| 103 | "serply", |
| 104 | json!({"results":[{"title":" A ","link":" https://example.com/a ","description":" body "}]}), |
| 105 | parse_serply_results, |
| 106 | ), |
| 107 | ]; |
| 108 | for (name, raw, parse) in cases { |
| 109 | let expected = parse(&raw, 5); |
| 110 | let actual = host_provider(name, &raw, 5, None, &context, 5_000) |
| 111 | .await |
| 112 | .unwrap() |
| 113 | .unwrap(); |
| 114 | assert_eq!(actual, expected, "{name}"); |
| 115 | } |
| 116 | let text = "```json\n{\"results\":[{\"title\":\" A \",\"url\":\" https://example.com/a?token=private \",\"snippet\":\" body \"}]}\n```"; |
| 117 | let raw = json!({"output":[{"type":"message","content":[{"text":text}]}]}); |
| 118 | assert_eq!( |
| 119 | host_provider("volcengine", &raw, 5, None, &context, 5_000) |
| 120 | .await |
| 121 | .unwrap() |
| 122 | .unwrap(), |
| 123 | parse_volcengine_results(text, 5) |
| 124 | ); |
| 125 | for (raw, expected) in [ |
| 126 | (json!({"code":2005}), Some("API key rejected")), |
| 127 | (json!({"code":2005.0}), None), |
| 128 | (json!({"code":"2005"}), None), |
| 129 | ] { |
| 130 | let result = host_provider("metaso", &raw, 5, None, &context, 5_000).await; |
| 131 | if let Some(expected) = expected { |
| 132 | let error = result.unwrap_err(); |
| 133 | assert!(error.content()); |
| 134 | assert!(error.to_string().contains(expected)); |
| 135 | } else { |
| 136 | assert!(result.unwrap().unwrap().is_empty()); |
| 137 | } |
| 138 | } |
| 139 | assert!(!crate::plugins::activation::extension_host_policy_enabled()); |
| 140 | manager.shutdown().await; |
| 141 | } |
| 142 | #[tokio::test(flavor = "current_thread")] |
| 143 | async fn real_host_keyless_requests_and_final_receipt_match_rust() { |
| 144 | let _home = crate::test_support::SealedHome::new(); |
| 145 | let _policy = TestPolicyGuard::extension_host(false); |
| 146 | let Some(node) = node_for_tests("real_host_web_request_parity") else { |
| 147 | return; |
| 148 | }; |
| 149 | let root = tempfile::tempdir().unwrap(); |
| 150 | let manager = new_manager(node, root.path()); |
| 151 | let _manager = TestManagerGuard::install(Arc::clone(&manager)); |
| 152 | let context = host_context(root.path()); |
| 153 | let query = SearchQuery::new( |
| 154 | "authorized 漢字 query".into(), |
| 155 | 5, |
| 156 | Some(Recency::Days(10)), |
| 157 | vec!["example.com".into()], |
| 158 | Some("de_DE".into()), |
| 159 | ); |
| 160 | let filters = QueryFilters::of(&query); |
| 161 | let plan = host_request("tavily", &query.query, filters, 5, &context, 5_000) |
| 162 | .await |
| 163 | .unwrap() |
| 164 | .unwrap(); |
| 165 | let mut expected = tavily_search_payload("private-key", &query.query, filters, 5); |
| 166 | expected.as_object_mut().unwrap().remove("api_key"); |
| 167 | assert_eq!(plan.payload, expected); |
| 168 | assert_eq!( |
| 169 | host_request("baidu", &query.query, filters, 5, &context, 5_000) |
| 170 | .await |
| 171 | .unwrap() |
| 172 | .unwrap() |
| 173 | .payload, |
| 174 | baidu_search_payload(&query.query, 5) |
| 175 | ); |
| 176 | let mut expected = volcengine_search_payload(&query.query, 5); |
| 177 | for key in ["model", "stream", "tools"] { |
| 178 | expected.as_object_mut().unwrap().remove(key); |
| 179 | } |
| 180 | assert_eq!( |
| 181 | host_request("volcengine", &query.query, filters, 5, &context, 5_000) |
| 182 | .await |
| 183 | .unwrap() |
| 184 | .unwrap() |
| 185 | .payload, |
| 186 | expected |
| 187 | ); |
| 188 | let entries = vec![ |
| 189 | SearchResult::new(1, "A".into(), "https://example.com/a".into(), None, None), |
| 190 | SearchResult::new( |
| 191 | 2, |
| 192 | "Other".into(), |
| 193 | "https://other.example/a".into(), |
| 194 | None, |
| 195 | None, |
| 196 | ), |
| 197 | ]; |
| 198 | let make = || BackendSearch { |
| 199 | backend: BackendId::ProviderNative, |
| 200 | source: "native".into(), |
| 201 | backend_detail: Some("api.example.com".into()), |
| 202 | results: entries.clone(), |
| 203 | degraded: vec![DegradedReason::AnswerCutByProvider], |
| 204 | note: Some("private answer retained in Core".into()), |
| 205 | }; |
| 206 | let started = Instant::now(); |
| 207 | let expected = finalize_search_response( |
| 208 | query.clone(), |
| 209 | crate::tools::web::contract::QueryCapabilities::count_only(), |
| 210 | make(), |
| 211 | started, |
| 212 | ); |
| 213 | let actual = finalize_search_response_for_context( |
| 214 | query, |
| 215 | crate::tools::web::contract::QueryCapabilities::count_only(), |
| 216 | make(), |
| 217 | started, |
| 218 | &context, |
| 219 | Duration::from_secs(5), |
| 220 | ) |
| 221 | .await |
| 222 | .unwrap(); |
| 223 | assert_eq!(actual.message, expected.message); |
| 224 | assert_eq!(actual.results, expected.results); |
| 225 | assert_eq!(actual.receipt.honored, expected.receipt.honored); |
| 226 | assert_eq!(actual.receipt.degraded, expected.receipt.degraded); |
| 227 | manager.shutdown().await; |
| 228 | } |
| 229 | #[tokio::test(flavor = "current_thread")] |
| 230 | async fn real_core_http_capture_and_host_request_are_bounded_without_credentials_on_wire() { |
| 231 | let _home = crate::test_support::SealedHome::new(); |
| 232 | let _policy = TestPolicyGuard::extension_host(false); |
| 233 | let Some(node) = node_for_tests("real_core_web_http_capture") else { |
| 234 | return; |
| 235 | }; |
| 236 | let root = tempfile::tempdir().unwrap(); |
| 237 | let manager = new_manager(node, root.path()); |
| 238 | let _manager = TestManagerGuard::install(Arc::clone(&manager)); |
| 239 | let context = host_context(root.path()); |
| 240 | let server = wiremock::MockServer::start().await; |
| 241 | wiremock::Mock::given(wiremock::matchers::method("POST")).and(wiremock::matchers::path("/search")) |
| 242 | .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({"success":true,"data":{"web":[{"title":" A ","url":" https://example.com/a ","description":" body "}]}}))) |
| 243 | .mount(&server).await; |
| 244 | let endpoint = format!("{}/search", server.uri()); |
| 245 | let (entries, note) = WebSearchTool |
| 246 | .run_firecrawl_search_at_for_context( |
| 247 | &endpoint, |
| 248 | "approved query", |
| 249 | QueryFilters::default(), |
| 250 | 5, |
| 251 | 5_000, |
| 252 | Some("private-test-key"), |
| 253 | &context, |
| 254 | ) |
| 255 | .await |
| 256 | .unwrap(); |
| 257 | assert_eq!(entries[0].url, "https://example.com/a"); |
| 258 | assert_eq!(note, "Firecrawl authenticated"); |
| 259 | let requests = server.received_requests().await.unwrap(); |
| 260 | assert_eq!(requests.len(), 1); |
| 261 | assert_eq!( |
| 262 | requests[0] |
| 263 | .headers |
| 264 | .get("authorization") |
| 265 | .unwrap() |
| 266 | .to_str() |
| 267 | .unwrap(), |
| 268 | "Bearer private-test-key" |
| 269 | ); |
| 270 | assert_eq!( |
| 271 | serde_json::from_slice::<Value>(&requests[0].body).unwrap()["query"], |
| 272 | "approved query" |
| 273 | ); |
| 274 | wiremock::Mock::given(wiremock::matchers::path("/oversize")) |
| 275 | .respond_with( |
| 276 | wiremock::ResponseTemplate::new(200).set_body_string("x".repeat(1024 * 1024 + 1)), |
| 277 | ) |
| 278 | .mount(&server) |
| 279 | .await; |
| 280 | let response = crate::tls::reqwest_client_builder() |
| 281 | .build() |
| 282 | .unwrap() |
| 283 | .get(format!("{}/oversize", server.uri())) |
| 284 | .send() |
| 285 | .await |
| 286 | .unwrap(); |
| 287 | let error = adapter::read_response(response, &context) |
| 288 | .await |
| 289 | .unwrap_err(); |
| 290 | assert_eq!(error.origin, adapter::FailureOrigin::CaptureGuard); |
| 291 | manager.shutdown().await; |
| 292 | } |
| 293 | #[tokio::test(flavor = "current_thread")] |
| 294 | async fn selected_web_host_refuses_unavailable_runtime_and_pre_cancel_without_transport_fallback() { |
| 295 | let _home = crate::test_support::SealedHome::new(); |
| 296 | let root = tempfile::tempdir().unwrap(); |
| 297 | let manager = new_manager(root.path().join("missing-node"), root.path()); |
| 298 | let _manager = TestManagerGuard::install(manager); |
| 299 | let context = host_context(root.path()); |
| 300 | let error = host_provider("tavily", &json!({"results":[]}), 5, None, &context, 1_000) |
| 301 | .await |
| 302 | .unwrap_err(); |
| 303 | assert_eq!(error.origin, adapter::FailureOrigin::Host); |
| 304 | let token = tokio_util::sync::CancellationToken::new(); |
| 305 | token.cancel(); |
| 306 | let context = context.with_cancel_token(token); |
| 307 | assert!( |
| 308 | !host_request( |
| 309 | "tavily", |
| 310 | "query", |
| 311 | QueryFilters::default(), |
| 312 | 5, |
| 313 | &context, |
| 314 | 1_000 |
| 315 | ) |
| 316 | .await |
| 317 | .unwrap_err() |
| 318 | .content() |
| 319 | ); |
| 320 | } |
| 321 |