| 1 | use super::{ |
| 2 | CODE_EXECUTION_DESCRIPTION, DEFAULT_ACTIVE_NATIVE_TOOLS, ToolMode, |
| 3 | allowlist_is_native_file_and_shell_only, apply_mcp_tool_deferral, apply_native_tool_deferral, |
| 4 | build_model_tool_catalog_with_surface, default_synthetic_catalog_tool_names, |
| 5 | ensure_advanced_tooling, execute_tool_search_with_cache, initial_active_tools, |
| 6 | is_synthetic_catalog_tool, remove_evicted_cache_activations, requested_tool_mode, |
| 7 | tool_matches_any_rule, touch_cached_tool_after_execution, |
| 8 | }; |
| 9 | use crate::core::session::ToolActivationCache; |
| 10 | use codewhale_config::AppMode; |
| 11 | use codewhale_models::Tool; |
| 12 | use serde_json::json; |
| 13 | use std::collections::{BTreeSet, HashSet}; |
| 14 | |
| 15 | fn tool(name: &str) -> Tool { |
| 16 | Tool { |
| 17 | tool_type: None, |
| 18 | name: name.to_string(), |
| 19 | description: format!("{name} test tool"), |
| 20 | input_schema: json!({"type": "object", "properties": {}}), |
| 21 | allowed_callers: None, |
| 22 | defer_loading: None, |
| 23 | input_examples: None, |
| 24 | strict: None, |
| 25 | cache_control: None, |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | /// The shared launcher applies policy only where enforcement is available. |
| 30 | /// The model-facing description must not promise unconditional isolation. |
| 31 | #[test] |
| 32 | fn code_execution_description_does_not_claim_process_sandboxing() { |
| 33 | assert!(CODE_EXECUTION_DESCRIPTION.contains("local Python interpreter")); |
| 34 | assert!(!CODE_EXECUTION_DESCRIPTION.contains("sandbox")); |
| 35 | } |
| 36 | |
| 37 | /// Python output must match our UTF-8 decoder even with non-UTF-8 parent stdio. |
| 38 | #[tokio::test] |
| 39 | async fn code_execution_returns_utf8_stdout_and_stderr() { |
| 40 | use crate::dependencies::ExternalTool as _; |
| 41 | use crate::test_support::{EnvVarGuard, lock_test_env}; |
| 42 | |
| 43 | let _env_lock = lock_test_env(); |
| 44 | if !crate::dependencies::Python::available() { |
| 45 | return; |
| 46 | } |
| 47 | let _encoding = EnvVarGuard::set("PYTHONIOENCODING", "gbk"); |
| 48 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 49 | let result = super::execute_code_execution_tool( |
| 50 | &json!({"code": r#"import sys; print("\u4e2d\u6587"); print("\u9519\u8bef", file=sys.stderr)"#}), |
| 51 | tmp.path(), |
| 52 | &crate::tools::spec::ToolContext::new(tmp.path()), |
| 53 | ) |
| 54 | .await |
| 55 | .expect("code execution should run"); |
| 56 | let payload = result.metadata.expect("payload"); |
| 57 | assert_eq!( |
| 58 | ( |
| 59 | payload["stdout"].as_str().map(str::trim_end), |
| 60 | payload["stderr"].as_str().map(str::trim_end), |
| 61 | ), |
| 62 | (Some("中文"), Some("错误")), |
| 63 | ); |
| 64 | } |
| 65 | |
| 66 | /// The published synthetic-name list and the predicate that classifies a |
| 67 | /// catalog entry as synthetic must agree. A name that appears in the list |
| 68 | /// but is not classified synthetic would let the request projection report |
| 69 | /// a provenance the engine itself disputes. |
| 70 | #[test] |
| 71 | fn published_synthetic_names_agree_with_the_synthetic_predicate() { |
| 72 | let names = default_synthetic_catalog_tool_names(); |
| 73 | assert!(!names.is_empty()); |
| 74 | for name in &names { |
| 75 | assert!( |
| 76 | is_synthetic_catalog_tool(name), |
| 77 | "'{name}' is published as synthetic but the predicate disagrees" |
| 78 | ); |
| 79 | } |
| 80 | let mut sorted = names.clone(); |
| 81 | sorted.sort(); |
| 82 | sorted.dedup(); |
| 83 | assert_eq!(names, sorted, "the list must be sorted and deduplicated"); |
| 84 | |
| 85 | // MCP names resolve through the real pool, so they are deliberately |
| 86 | // absent here even though the predicate accepts them. |
| 87 | assert!(!names.iter().any(|name| name.starts_with("mcp_"))); |
| 88 | |
| 89 | // `multi_tool_use.parallel` is a call name, never a catalog entry, so |
| 90 | // it has no catalog provenance and must not be published as synthetic. |
| 91 | assert!( |
| 92 | !names |
| 93 | .iter() |
| 94 | .any(|name| name == super::MULTI_TOOL_PARALLEL_NAME) |
| 95 | ); |
| 96 | } |
| 97 | |
| 98 | #[test] |
| 99 | fn first_turn_surface_is_stable_across_plan_work_and_operate() { |
| 100 | assert_eq!( |
| 101 | DEFAULT_ACTIVE_NATIVE_TOOLS, |
| 102 | &[ |
| 103 | "read", |
| 104 | "write", |
| 105 | "edit", |
| 106 | "bash", |
| 107 | "agent", |
| 108 | "workflow", |
| 109 | "todo_write", |
| 110 | "create_goal", |
| 111 | "get_goal", |
| 112 | "update_goal", |
| 113 | "load_skill" |
| 114 | ] |
| 115 | ); |
| 116 | let expected = [ |
| 117 | "agent", |
| 118 | "bash", |
| 119 | "create_goal", |
| 120 | "get_goal", |
| 121 | "update_goal", |
| 122 | "edit", |
| 123 | "load_skill", |
| 124 | "read", |
| 125 | "todo_write", |
| 126 | "tool_search", |
| 127 | "workflow", |
| 128 | "write", |
| 129 | ] |
| 130 | .into_iter() |
| 131 | .map(str::to_string) |
| 132 | .collect::<BTreeSet<_>>(); |
| 133 | let mut expected_prefix = None; |
| 134 | for mode in [AppMode::Plan, AppMode::Agent, AppMode::Operate] { |
| 135 | let mut catalog = [ |
| 136 | "read", |
| 137 | "write", |
| 138 | "edit", |
| 139 | "bash", |
| 140 | "agent", |
| 141 | "workflow", |
| 142 | "todo_write", |
| 143 | "create_goal", |
| 144 | "get_goal", |
| 145 | "update_goal", |
| 146 | "Git", |
| 147 | "Run", |
| 148 | "tasks", |
| 149 | "load_skill", |
| 150 | ] |
| 151 | .into_iter() |
| 152 | .map(tool) |
| 153 | .collect::<Vec<_>>(); |
| 154 | let always_load = HashSet::new(); |
| 155 | apply_native_tool_deferral(&mut catalog, &always_load); |
| 156 | ensure_advanced_tooling(&mut catalog, mode, &always_load, ToolMode::Direct); |
| 157 | let active_names = initial_active_tools(&catalog); |
| 158 | let active = active_names.iter().cloned().collect::<BTreeSet<_>>(); |
| 159 | assert_eq!(active, expected, "{mode:?}"); |
| 160 | let prefix = serde_json::to_string(&super::active_tools_for_step(&catalog, &active_names)) |
| 161 | .expect("serialize first-turn tool prefix"); |
| 162 | if let Some(expected) = &expected_prefix { |
| 163 | assert_eq!(&prefix, expected, "{mode:?} must preserve schema bytes"); |
| 164 | } else { |
| 165 | expected_prefix = Some(prefix); |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | #[test] |
| 171 | fn eager_workflow_still_respects_command_allow_and_deny_gates() { |
| 172 | for mode in [AppMode::Plan, AppMode::Agent, AppMode::Operate] { |
| 173 | for (allow, deny, expected) in [ |
| 174 | (None, None, true), |
| 175 | (Some("read"), None, false), |
| 176 | (Some("workflow"), None, true), |
| 177 | (None, Some("workflow"), false), |
| 178 | (Some("workflow"), Some("workflow"), false), |
| 179 | ] { |
| 180 | let catalog = build_model_tool_catalog_with_surface( |
| 181 | ["read", "agent", "workflow"] |
| 182 | .into_iter() |
| 183 | .map(tool) |
| 184 | .collect(), |
| 185 | Vec::new(), |
| 186 | mode, |
| 187 | &HashSet::new(), |
| 188 | crate::model_profile::ToolSurfaceBudget::Standard, |
| 189 | ); |
| 190 | let policy = super::ToolSurfacePolicy::new( |
| 191 | crate::tools::ToolRegistry::new(crate::tools::ToolContext::for_empty_registry()), |
| 192 | Some(catalog), |
| 193 | mode, |
| 194 | &HashSet::new(), |
| 195 | &["workflow"], // Cached activation cannot restore a denied tool. |
| 196 | false, |
| 197 | allow.map(|name| vec![name.to_string()]), |
| 198 | deny.map(|name| vec![name.to_string()]), |
| 199 | None, |
| 200 | ToolMode::Direct, |
| 201 | ); |
| 202 | assert_eq!(policy.allows_tool("workflow"), expected); |
| 203 | assert_eq!(policy.active_names.contains("workflow"), expected); |
| 204 | assert_eq!( |
| 205 | policy.catalog.iter().any(|tool| tool.name == "workflow"), |
| 206 | expected |
| 207 | ); |
| 208 | } |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | #[test] |
| 213 | fn mcp_tools_are_searchable_not_eager_in_every_mode() { |
| 214 | for mode in [AppMode::Plan, AppMode::Agent] { |
| 215 | let mut catalog = vec![tool("read_mcp_resource"), tool("mcp_acme_lookup")]; |
| 216 | apply_mcp_tool_deferral(&mut catalog, mode, &HashSet::new()); |
| 217 | assert!( |
| 218 | catalog |
| 219 | .iter() |
| 220 | .all(|definition| definition.defer_loading == Some(true)), |
| 221 | "{mode:?}: {catalog:?}" |
| 222 | ); |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | #[test] |
| 227 | fn cache_eviction_does_not_hide_a_tool_that_became_eager() { |
| 228 | let mut catalog = vec![tool("promoted")]; |
| 229 | catalog[0].defer_loading = Some(true); |
| 230 | let mut cache = ToolActivationCache::default(); |
| 231 | cache.activate(&catalog, &["promoted".to_string()]); |
| 232 | |
| 233 | catalog[0].defer_loading = Some(false); |
| 234 | let mut active = initial_active_tools(&catalog); |
| 235 | let evicted = cache.revalidate(&catalog); |
| 236 | remove_evicted_cache_activations(&catalog, &mut active, evicted); |
| 237 | |
| 238 | assert!(active.contains("promoted")); |
| 239 | assert_eq!(cache.names().count(), 0); |
| 240 | } |
| 241 | |
| 242 | #[test] |
| 243 | fn successful_cached_execution_updates_lru_without_granting_uncached_names() { |
| 244 | let catalog = (0..=8) |
| 245 | .map(|index| { |
| 246 | let mut definition = tool(&format!("deferred-{index}")); |
| 247 | definition.defer_loading = Some(true); |
| 248 | definition |
| 249 | }) |
| 250 | .collect::<Vec<_>>(); |
| 251 | let mut cache = ToolActivationCache::default(); |
| 252 | let first = (0..8) |
| 253 | .map(|index| format!("deferred-{index}")) |
| 254 | .collect::<Vec<_>>(); |
| 255 | let mut active = HashSet::new(); |
| 256 | let delta = cache.activate(&catalog, &first); |
| 257 | active.extend(delta.admitted); |
| 258 | |
| 259 | assert!(touch_cached_tool_after_execution( |
| 260 | &catalog, |
| 261 | &mut active, |
| 262 | &mut cache, |
| 263 | "deferred-0" |
| 264 | )); |
| 265 | let delta = cache.activate(&catalog, &["deferred-8".to_string()]); |
| 266 | remove_evicted_cache_activations(&catalog, &mut active, delta.evicted); |
| 267 | active.extend(delta.admitted); |
| 268 | assert!(cache.names().any(|name| name == "deferred-0")); |
| 269 | assert!(!cache.names().any(|name| name == "deferred-1")); |
| 270 | |
| 271 | assert!(!touch_cached_tool_after_execution( |
| 272 | &catalog, |
| 273 | &mut active, |
| 274 | &mut cache, |
| 275 | "never-activated" |
| 276 | )); |
| 277 | assert!(!active.contains("never-activated")); |
| 278 | } |
| 279 | |
| 280 | #[test] |
| 281 | fn searching_for_an_eager_tool_is_not_reported_as_cache_rejected() { |
| 282 | let mut catalog = vec![tool("read")]; |
| 283 | catalog[0].defer_loading = Some(false); |
| 284 | let mut active = initial_active_tools(&catalog); |
| 285 | let mut cache = ToolActivationCache::default(); |
| 286 | |
| 287 | let result = execute_tool_search_with_cache( |
| 288 | super::TOOL_SEARCH_NAME, |
| 289 | &json!({"query": "read"}), |
| 290 | &catalog, |
| 291 | &mut active, |
| 292 | &mut cache, |
| 293 | ) |
| 294 | .expect("tool search should succeed"); |
| 295 | |
| 296 | let metadata = result.metadata.expect("search metadata"); |
| 297 | assert_eq!(metadata["tool_references"], json!([])); |
| 298 | assert_eq!(metadata["unavailable_tool_references"], json!([])); |
| 299 | assert!(active.contains("read")); |
| 300 | assert_eq!(cache.names().count(), 0); |
| 301 | } |
| 302 | |
| 303 | #[test] |
| 304 | fn allow_and_deny_rules_cover_visible_and_hidden_compat_aliases_symmetrically() { |
| 305 | for family in [ |
| 306 | &["read", "read_file"][..], |
| 307 | &["write", "write_file"][..], |
| 308 | &["edit", "edit_file"][..], |
| 309 | &["bash", "Bash", "exec_shell"][..], |
| 310 | ] { |
| 311 | for rule in family { |
| 312 | let rules = vec![(*rule).to_string()]; |
| 313 | for tool_name in family { |
| 314 | assert!( |
| 315 | tool_matches_any_rule(&rules, tool_name), |
| 316 | "rule {rule:?} should cover alias {tool_name:?}" |
| 317 | ); |
| 318 | } |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | assert!(tool_matches_any_rule(&["exec_shell*".to_string()], "bash")); |
| 323 | assert!(tool_matches_any_rule( |
| 324 | &["exec_shell*".to_string()], |
| 325 | "exec_shell_wait" |
| 326 | )); |
| 327 | for primitive in ["read", "write", "edit"] { |
| 328 | assert!(tool_matches_any_rule(&["File".to_string()], primitive)); |
| 329 | } |
| 330 | assert!(!tool_matches_any_rule(&["read".to_string()], "write")); |
| 331 | } |
| 332 | |
| 333 | #[test] |
| 334 | fn native_file_and_shell_allowlist_can_skip_mcp_startup() { |
| 335 | let native = ["bash", "read", "write", "edit"].map(str::to_string); |
| 336 | assert!(allowlist_is_native_file_and_shell_only(Some(&native))); |
| 337 | assert!(!allowlist_is_native_file_and_shell_only(None)); |
| 338 | } |
| 339 | |
| 340 | #[test] |
| 341 | fn unknown_and_wildcard_allowlists_keep_mcp_startup() { |
| 342 | for rule in ["mcp_github_list_prs", "mcp_*", "m*", "*", "other_tool"] { |
| 343 | assert!( |
| 344 | !allowlist_is_native_file_and_shell_only(Some(&[rule.to_string()])), |
| 345 | "{rule} may admit an MCP-backed tool" |
| 346 | ); |
| 347 | } |
| 348 | } |
| 349 | |
| 350 | #[test] |
| 351 | fn compact_surface_keeps_agent_and_workflow_eager() { |
| 352 | let catalog = build_model_tool_catalog_with_surface( |
| 353 | [ |
| 354 | "read", |
| 355 | "write", |
| 356 | "edit", |
| 357 | "bash", |
| 358 | "agent", |
| 359 | "workflow", |
| 360 | "todo_write", |
| 361 | ] |
| 362 | .into_iter() |
| 363 | .map(tool) |
| 364 | .collect(), |
| 365 | Vec::new(), |
| 366 | AppMode::Agent, |
| 367 | &HashSet::new(), |
| 368 | crate::model_profile::ToolSurfaceBudget::Compact, |
| 369 | ); |
| 370 | |
| 371 | for name in ["agent", "workflow"] { |
| 372 | assert_eq!( |
| 373 | catalog |
| 374 | .iter() |
| 375 | .find(|definition| definition.name == name) |
| 376 | .and_then(|definition| definition.defer_loading), |
| 377 | Some(false), |
| 378 | "{name}" |
| 379 | ); |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | /// The per-tool Registry paragraph is gone; the catalog builder must leave the |
| 384 | /// shell tool's description exactly as the registry produced it, so the KV |
| 385 | /// prefix stays byte-stable and there is one Registry authority (the prompt). |
| 386 | #[test] |
| 387 | fn catalog_build_does_not_append_registry_guidance_to_the_shell_tool() { |
| 388 | let described = tool("bash"); |
| 389 | let catalog = build_model_tool_catalog_with_surface( |
| 390 | vec![described.clone()], |
| 391 | Vec::new(), |
| 392 | AppMode::Agent, |
| 393 | &HashSet::new(), |
| 394 | crate::model_profile::ToolSurfaceBudget::Standard, |
| 395 | ); |
| 396 | |
| 397 | let shell = catalog |
| 398 | .iter() |
| 399 | .find(|definition| definition.name == "bash") |
| 400 | .expect("shell tool"); |
| 401 | assert_eq!(shell.description, described.description); |
| 402 | assert!(!shell.description.contains("registry_sync")); |
| 403 | } |
| 404 | |
| 405 | #[test] |
| 406 | fn requested_tool_mode_prefers_model_hint_then_flag_then_direct() { |
| 407 | use crate::features::{Feature, Features}; |
| 408 | |
| 409 | // #6562: code mode is the default; `[features] code_mode = false` is the |
| 410 | // escape hatch back to Direct. |
| 411 | let on = Features::with_defaults(); |
| 412 | assert_eq!(requested_tool_mode(None, &on), ToolMode::CodeMode); |
| 413 | |
| 414 | let mut off = Features::with_defaults(); |
| 415 | off.disable(Feature::CodeMode); |
| 416 | assert_eq!(requested_tool_mode(None, &off), ToolMode::Direct); |
| 417 | |
| 418 | // Model metadata wins over config, in both directions (Codex parity). |
| 419 | assert_eq!( |
| 420 | requested_tool_mode(Some(ToolMode::Direct), &on), |
| 421 | ToolMode::Direct |
| 422 | ); |
| 423 | assert_eq!( |
| 424 | requested_tool_mode(Some(ToolMode::CodeMode), &off), |
| 425 | ToolMode::CodeMode |
| 426 | ); |
| 427 | } |
| 428 | |
| 429 | #[test] |
| 430 | fn execute_tools_is_eager_in_code_mode_and_deferred_in_direct() { |
| 431 | for (mode, expected_defer) in [(ToolMode::Direct, true), (ToolMode::CodeMode, false)] { |
| 432 | let mut catalog = vec![tool("read")]; |
| 433 | ensure_advanced_tooling(&mut catalog, AppMode::Agent, &HashSet::new(), mode); |
| 434 | let injected = catalog |
| 435 | .iter() |
| 436 | .find(|definition| definition.name == "execute_tools") |
| 437 | .expect("execute_tools is injected outside Plan"); |
| 438 | assert_eq!(injected.defer_loading, Some(expected_defer), "{mode:?}"); |
| 439 | } |
| 440 | |
| 441 | // Plan hides the surface under every tool mode. |
| 442 | let mut catalog = vec![tool("read")]; |
| 443 | ensure_advanced_tooling( |
| 444 | &mut catalog, |
| 445 | AppMode::Plan, |
| 446 | &HashSet::new(), |
| 447 | ToolMode::CodeMode, |
| 448 | ); |
| 449 | assert!( |
| 450 | catalog |
| 451 | .iter() |
| 452 | .all(|definition| definition.name != "execute_tools") |
| 453 | ); |
| 454 | } |
| 455 | |
| 456 | /// A timed-out or cancelled `code_execution` kills the interpreter and what the |
| 457 | /// script started; a bare `output()` left both running. |
| 458 | #[cfg(unix)] |
| 459 | #[tokio::test] |
| 460 | async fn dropped_code_execution_kills_the_interpreter_tree() { |
| 461 | if crate::dependencies::resolve_python_interpreter().is_none() { |
| 462 | return; |
| 463 | } |
| 464 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 465 | let pid_file = tmp.path().join("grandchild.pid"); |
| 466 | let code = format!( |
| 467 | "import subprocess\np = subprocess.Popen(['sleep', '300'])\nopen({:?}, 'w').write(str(p.pid))\np.wait()\n", |
| 468 | pid_file.display().to_string() |
| 469 | ); |
| 470 | let input = json!({ "code": code }); |
| 471 | let context = crate::tools::spec::ToolContext::new(tmp.path()); |
| 472 | let run = super::execute_code_execution_tool(&input, tmp.path(), &context); |
| 473 | let grandchild = crate::process_tree::drop_once_pid_written(run, &pid_file).await; |
| 474 | assert!( |
| 475 | crate::process_tree::wait_for_pid_exit(grandchild, std::time::Duration::from_secs(5)), |
| 476 | "a process the script started outlived the dropped code_execution call" |
| 477 | ); |
| 478 | } |
| 479 |