| 1 | //! Actual Core ToolSpecs, committed pinned host and local HTTP/file fixtures. |
| 2 | //! No provider traffic. Host selection is independent of third-party Native policy. |
| 3 | use super::*; |
| 4 | use crate::extension_host::tests::node_for_tests; |
| 5 | use crate::extension_host::{ExtensionHostManager, ExtensionHostOptions, TestManagerGuard}; |
| 6 | use crate::features::{Feature, Features}; |
| 7 | use crate::plugins::activation::TestPolicyGuard; |
| 8 | use crate::tools::validate_data::ValidateDataTool; |
| 9 | use std::sync::Arc; |
| 10 | use wiremock::matchers::{method, path}; |
| 11 | use wiremock::{Mock, MockServer, ResponseTemplate}; |
| 12 | |
| 13 | fn manager(node: std::path::PathBuf, home: &std::path::Path) -> Arc<ExtensionHostManager> { |
| 14 | Arc::new(ExtensionHostManager::new(ExtensionHostOptions { |
| 15 | runtime: crate::config::ExtensionHostRuntime::Node, |
| 16 | node_override: Some(node), |
| 17 | root: Some(home.join("host")), |
| 18 | ..ExtensionHostOptions::default() |
| 19 | })) |
| 20 | } |
| 21 | fn context(root: &std::path::Path, feature: Option<Feature>) -> ToolContext { |
| 22 | let mut features = Features::with_defaults(); |
| 23 | if let Some(feature) = feature { |
| 24 | features.enable(feature); |
| 25 | } |
| 26 | ToolContext::new(root).with_features(features) |
| 27 | } |
| 28 | fn equal_tool_results(rust: &ToolResult, host: &ToolResult) { |
| 29 | assert_eq!(host.success, rust.success); |
| 30 | assert_eq!(host.content, rust.content, "exact user/model result bytes"); |
| 31 | assert_eq!(host.metadata, rust.metadata); |
| 32 | } |
| 33 | |
| 34 | #[test] |
| 35 | fn finance_and_data_host_flags_are_separate_and_default_to_rust() { |
| 36 | let defaults = Features::with_defaults(); |
| 37 | assert!(!defaults.enabled(Feature::FinanceHost)); |
| 38 | assert!(!defaults.enabled(Feature::DataHost)); |
| 39 | assert_eq!( |
| 40 | crate::features::feature_from_key("finance_host"), |
| 41 | Some(Feature::FinanceHost) |
| 42 | ); |
| 43 | assert_eq!( |
| 44 | crate::features::feature_from_key("data_host"), |
| 45 | Some(Feature::DataHost) |
| 46 | ); |
| 47 | } |
| 48 | |
| 49 | #[tokio::test(flavor = "current_thread")] |
| 50 | async fn real_host_data_recorded_results_match_rust_without_native_activation() { |
| 51 | let _home = crate::test_support::SealedHome::new(); |
| 52 | let _policy = TestPolicyGuard::extension_host(false); |
| 53 | let Some(node) = node_for_tests("real_host_data_recorded_results") else { |
| 54 | return; |
| 55 | }; |
| 56 | let home = tempfile::tempdir().unwrap(); |
| 57 | let manager = manager(node, home.path()); |
| 58 | let _manager = TestManagerGuard::install(Arc::clone(&manager)); |
| 59 | let rust = context(home.path(), None); |
| 60 | let host = context(home.path(), Some(Feature::DataHost)); |
| 61 | let cases = [ |
| 62 | json!({"content":"{\"😀\":1,\"\":2,\"__proto__\":3,\"a\":4}"}), |
| 63 | json!({"content":"[1,true,null]", "format":"json"}), |
| 64 | json!({"content":"42", "format":"json"}), |
| 65 | json!({"content":"-0.0", "format":"json"}), |
| 66 | json!({"content":"title = \"hello\"\ndate = 2026-10-02\n[a]\nvalue = 2\n"}), |
| 67 | json!({"content":"{bad 😀", "format":"json"}), |
| 68 | json!({"content":"key = [", "format":"toml"}), |
| 69 | json!({"content":"neither valid JSON nor TOML"}), |
| 70 | ]; |
| 71 | for input in cases { |
| 72 | let expected = ValidateDataTool |
| 73 | .execute(input.clone(), &rust) |
| 74 | .await |
| 75 | .unwrap(); |
| 76 | let actual = ValidateDataTool.execute(input, &host).await.unwrap(); |
| 77 | equal_tool_results(&expected, &actual); |
| 78 | } |
| 79 | // File extension must pin a failing JSON parser even if TOML could succeed. |
| 80 | std::fs::write(home.path().join("pinned.json"), "value = 1").unwrap(); |
| 81 | let input = json!({"path":"pinned.json"}); |
| 82 | equal_tool_results( |
| 83 | &ValidateDataTool |
| 84 | .execute(input.clone(), &rust) |
| 85 | .await |
| 86 | .unwrap(), |
| 87 | &ValidateDataTool.execute(input, &host).await.unwrap(), |
| 88 | ); |
| 89 | assert!(!crate::plugins::activation::extension_host_policy_enabled()); |
| 90 | manager.shutdown().await; |
| 91 | } |
| 92 | |
| 93 | #[tokio::test(flavor = "current_thread")] |
| 94 | async fn real_host_finance_quote_and_chart_results_match_rust_exactly() { |
| 95 | let _home = crate::test_support::SealedHome::new(); |
| 96 | let _policy = TestPolicyGuard::extension_host(false); |
| 97 | let Some(node) = node_for_tests("real_host_finance_quote_and_chart") else { |
| 98 | return; |
| 99 | }; |
| 100 | let home = tempfile::tempdir().unwrap(); |
| 101 | let manager = manager(node, home.path()); |
| 102 | let _manager = TestManagerGuard::install(Arc::clone(&manager)); |
| 103 | let server = MockServer::start().await; |
| 104 | let tool = FinanceTool::with_endpoints( |
| 105 | format!("{}/quote", server.uri()), |
| 106 | format!("{}/chart", server.uri()), |
| 107 | ); |
| 108 | let rust = context(home.path(), None); |
| 109 | let host = context(home.path(), Some(Feature::FinanceHost)); |
| 110 | let fixtures = [ |
| 111 | ( |
| 112 | json!({"quoteResponse":{"result":[{"symbol":"btc-usd","regularMarketPrice":125.0,"regularMarketPreviousClose":100.0,"regularMarketTime":9223372036854775807i64,"longName":"Long","shortName":"Short","fullExchangeName":"Full","exchange":"Low"}]}}), |
| 113 | json!({"chart":{"result":[]}}), |
| 114 | ), |
| 115 | ( |
| 116 | json!({"quoteResponse":{"result":[{"symbol":"BTC-USD","regularMarketPrice":-0.0,"regularMarketPreviousClose":0.0}]}}), |
| 117 | json!({"chart":{"result":[]}}), |
| 118 | ), |
| 119 | ( |
| 120 | json!({"quoteResponse":{"result":[{"symbol":"BTC-USD","regularMarketPrice":1e308,"regularMarketPreviousClose":-1e308}]}}), |
| 121 | json!({"chart":{"result":[]}}), |
| 122 | ), |
| 123 | ( |
| 124 | json!({"quoteResponse":{"result":[]}}), |
| 125 | json!({"chart":{"result":[{"meta":{"symbol":"BTC-USD","regularMarketPrice":125.0,"chartPreviousClose":100.0,"previousClose":50.0,"regularMarketTime":-9223372036854775808i64,"instrumentType":"CRYPTOCURRENCY"}}],"error":null}}), |
| 126 | ), |
| 127 | ]; |
| 128 | for (quote, chart) in fixtures { |
| 129 | server.reset().await; |
| 130 | Mock::given(method("GET")) |
| 131 | .and(path("/quote")) |
| 132 | .respond_with(ResponseTemplate::new(200).set_body_json(quote)) |
| 133 | .mount(&server) |
| 134 | .await; |
| 135 | Mock::given(method("GET")) |
| 136 | .and(path("/chart/BTC-USD")) |
| 137 | .respond_with(ResponseTemplate::new(200).set_body_json(chart)) |
| 138 | .mount(&server) |
| 139 | .await; |
| 140 | let input = json!({"ticker":"BTC","timeout_ms":60000}); |
| 141 | let expected = tool.execute(input.clone(), &rust).await.unwrap(); |
| 142 | let actual = tool.execute(input, &host).await.unwrap(); |
| 143 | equal_tool_results(&expected, &actual); |
| 144 | } |
| 145 | manager.shutdown().await; |
| 146 | } |
| 147 | |
| 148 | #[tokio::test(flavor = "current_thread")] |
| 149 | async fn real_host_finance_domain_errors_match_and_do_not_change_fallback_policy() { |
| 150 | let _home = crate::test_support::SealedHome::new(); |
| 151 | let _policy = TestPolicyGuard::extension_host(false); |
| 152 | let Some(node) = node_for_tests("real_host_finance_domain_errors") else { |
| 153 | return; |
| 154 | }; |
| 155 | let home = tempfile::tempdir().unwrap(); |
| 156 | let manager = manager(node, home.path()); |
| 157 | let _manager = TestManagerGuard::install(Arc::clone(&manager)); |
| 158 | let server = MockServer::start().await; |
| 159 | let tool = FinanceTool::with_endpoints( |
| 160 | format!("{}/quote", server.uri()), |
| 161 | format!("{}/chart", server.uri()), |
| 162 | ); |
| 163 | for (quote, chart) in [ |
| 164 | ( |
| 165 | json!({"quoteResponse":{"result":[]}}), |
| 166 | json!({"chart":{"error":{"code":"Not Found","description":"Symbol may be delisted"}}}), |
| 167 | ), |
| 168 | ( |
| 169 | json!({"quoteResponse":{"result":[{"symbol":"BTC-USD"}]}}), |
| 170 | json!({"chart":{"error":{"code":"rate limit","description":"Try later"}}}), |
| 171 | ), |
| 172 | ] { |
| 173 | server.reset().await; |
| 174 | Mock::given(method("GET")) |
| 175 | .and(path("/quote")) |
| 176 | .respond_with(ResponseTemplate::new(200).set_body_json(quote)) |
| 177 | .mount(&server) |
| 178 | .await; |
| 179 | Mock::given(method("GET")) |
| 180 | .and(path("/chart/BTC-USD")) |
| 181 | .respond_with(ResponseTemplate::new(200).set_body_json(chart)) |
| 182 | .mount(&server) |
| 183 | .await; |
| 184 | let input = json!({"ticker":"BTC","timeout_ms":60000}); |
| 185 | let expected = tool |
| 186 | .execute(input.clone(), &context(home.path(), None)) |
| 187 | .await |
| 188 | .unwrap_err(); |
| 189 | let actual = tool |
| 190 | .execute(input, &context(home.path(), Some(Feature::FinanceHost))) |
| 191 | .await |
| 192 | .unwrap_err(); |
| 193 | assert_eq!(actual.to_string(), expected.to_string()); |
| 194 | } |
| 195 | manager.shutdown().await; |
| 196 | } |
| 197 | |
| 198 | #[tokio::test(flavor = "current_thread")] |
| 199 | async fn selected_host_missing_runtime_refuses_both_tools_without_rust_fallback() { |
| 200 | let _home = crate::test_support::SealedHome::new(); |
| 201 | let _policy = TestPolicyGuard::extension_host(false); |
| 202 | let home = tempfile::tempdir().unwrap(); |
| 203 | let manager = manager(home.path().join("absent-node"), home.path()); |
| 204 | let _manager = TestManagerGuard::install(Arc::clone(&manager)); |
| 205 | let data = ValidateDataTool |
| 206 | .execute( |
| 207 | json!({"content":"{}"}), |
| 208 | &context(home.path(), Some(Feature::DataHost)), |
| 209 | ) |
| 210 | .await; |
| 211 | assert!(data.is_err(), "Rust validation would have succeeded"); |
| 212 | let server = MockServer::start().await; |
| 213 | Mock::given(method("GET")) |
| 214 | .and(path("/quote")) |
| 215 | .respond_with(ResponseTemplate::new(200).set_body_json( |
| 216 | json!({"quoteResponse":{"result":[{"symbol":"AAPL","regularMarketPrice":1.0}]}}), |
| 217 | )) |
| 218 | .mount(&server) |
| 219 | .await; |
| 220 | Mock::given(method("GET")) |
| 221 | .and(path("/chart/AAPL")) |
| 222 | .respond_with(ResponseTemplate::new(200).set_body_json( |
| 223 | json!({"chart":{"result":[{"meta":{"symbol":"AAPL","regularMarketPrice":2.0}}]}}), |
| 224 | )) |
| 225 | .expect(0) |
| 226 | .mount(&server) |
| 227 | .await; |
| 228 | let tool = FinanceTool::with_endpoints( |
| 229 | format!("{}/quote", server.uri()), |
| 230 | format!("{}/chart", server.uri()), |
| 231 | ); |
| 232 | assert!( |
| 233 | tool.execute( |
| 234 | json!({"ticker":"AAPL"}), |
| 235 | &context(home.path(), Some(Feature::FinanceHost)) |
| 236 | ) |
| 237 | .await |
| 238 | .is_err() |
| 239 | ); |
| 240 | server.verify().await; |
| 241 | manager.shutdown().await; |
| 242 | } |
| 243 | |
| 244 | #[tokio::test(flavor = "current_thread")] |
| 245 | async fn host_input_bound_and_cancel_refuse_before_admitting_transform() { |
| 246 | let _home = crate::test_support::SealedHome::new(); |
| 247 | let _policy = TestPolicyGuard::extension_host(false); |
| 248 | let home = tempfile::tempdir().unwrap(); |
| 249 | let host = context(home.path(), Some(Feature::DataHost)); |
| 250 | assert!(matches!( |
| 251 | ValidateDataTool |
| 252 | .execute(json!({"content":"x".repeat(1024*1024+1)}), &host) |
| 253 | .await, |
| 254 | Err(ToolError::InvalidInput { .. }) |
| 255 | )); |
| 256 | let manager = manager(home.path().join("absent-node"), home.path()); |
| 257 | let _manager = TestManagerGuard::install(Arc::clone(&manager)); |
| 258 | let cancel = tokio_util::sync::CancellationToken::new(); |
| 259 | cancel.cancel(); |
| 260 | let host = host.with_cancel_token(cancel); |
| 261 | let error = ValidateDataTool |
| 262 | .execute(json!({"content":"{}"}), &host) |
| 263 | .await |
| 264 | .unwrap_err(); |
| 265 | assert!(error.to_string().contains("cancelled")); |
| 266 | manager.shutdown().await; |
| 267 | } |
| 268 |