返回 CodeWhale
core_call_tests.rs
根目录 / crates / tui / src / extension_host / core_call_tests.rs
1 //! `core/call`: tickets, refusals, approval rules and the real host.
2 //!
3 //! The Node-backed tests drive a real host through a real extension tool
4 //! (`tests/fixtures/extension_host/core-call`) with a stand-in for the turn
5 //! loop's gate: it serves `NestedCallRequest`s exactly as the engine does and
6 //! can hold a request the way an approval card does. What the engine itself
7 //! decides (planning, the card, forced prompts, withdrawal) is tested in
8 //! `core::engine::approval::tests`.
9
10 use std::path::Path;
11 use std::sync::Arc;
12 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
13 use std::time::{Duration, Instant};
14
15 use async_trait::async_trait;
16 use codewhale_workflow_js::ToolCallResponse;
17 use serde_json::{Value, json};
18 use tokio::sync::{Notify, mpsc};
19
20 use super::core_call::{
21 EXT_AUTO_ELIGIBLE, MAX_CALLS_PER_INVOCATION, OriginApproval, origin_approval, refusal,
22 wire_from_response,
23 };
24 use super::protocol::{ContentBlockWire, CoreCallParams, OwnerRef, RpcErrorWire, error_code};
25 use super::supervisor::HostRequestContext;
26 use super::tests::{FixturePlugins, fake_authority, node_for_tests};
27 use super::tier::HostTier;
28 use super::{ExtensionHostManager, ExtensionHostOptions, HostAttachment, SupervisionOptions};
29 use crate::plugins::activation::TestPolicyGuard;
30 use crate::tools::codemode::{
31 ExtensionCaller, NestedCallGate, NestedCallRequest, NestedCallVerdict, NestedDecision,
32 };
33 use crate::tools::spec::{
34 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
35 };
36
37 // ---------------------------------------------------------------------------
38 // A stand-in for the turn loop's gate
39 // ---------------------------------------------------------------------------
40
41 /// What the stand-in gate server saw. It serves the way the engine does: a
42 /// stale request is dropped unplanned, a held one waits for `release` or its
43 /// request's withdrawal (an approval card left open), anything else is run
44 /// exactly as asked.
45 #[derive(Default)]
46 struct Server {
47 asked: AtomicUsize,
48 stale: AtomicUsize,
49 withdrawn: AtomicUsize,
50 hold: AtomicBool,
51 release: Notify,
52 }
53
54 fn serve(mut requests: mpsc::Receiver<NestedCallRequest>, server: Arc<Server>) {
55 tokio::spawn(async move {
56 while let Some(request) = requests.recv().await {
57 if request.is_stale() {
58 server.stale.fetch_add(1, Ordering::SeqCst);
59 continue;
60 }
61 server.asked.fetch_add(1, Ordering::SeqCst);
62 let held = server.hold.load(Ordering::SeqCst);
63 if held {
64 match &request.withdraw {
65 Some(withdraw) => tokio::select! {
66 () = server.release.notified() => {}
67 () = withdraw.cancelled() => {
68 server.withdrawn.fetch_add(1, Ordering::SeqCst);
69 continue;
70 }
71 },
72 None => server.release.notified().await,
73 }
74 }
75 let _ = request.reply.send(NestedCallVerdict::Run {
76 name: request.name.clone(),
77 input: request.input.clone(),
78 supports_parallel: true,
79 decision: if held {
80 NestedDecision::Approved
81 } else {
82 NestedDecision::Auto
83 },
84 hook_context: None,
85 });
86 }
87 });
88 }
89
90 /// A tool that takes a while and records how many run at once.
91 struct SlowFixture {
92 running: AtomicUsize,
93 peak: AtomicUsize,
94 }
95
96 #[async_trait]
97 impl ToolSpec for SlowFixture {
98 fn name(&self) -> &str {
99 "slow_fixture"
100 }
101 fn description(&self) -> &str {
102 "slow, read-only"
103 }
104 fn input_schema(&self) -> Value {
105 json!({"type": "object", "properties": {}})
106 }
107 fn capabilities(&self) -> Vec<ToolCapability> {
108 vec![ToolCapability::ReadOnly]
109 }
110 async fn execute(
111 &self,
112 _input: Value,
113 _context: &ToolContext,
114 ) -> Result<ToolResult, ToolError> {
115 let now = self.running.fetch_add(1, Ordering::SeqCst) + 1;
116 self.peak.fetch_max(now, Ordering::SeqCst);
117 tokio::time::sleep(Duration::from_millis(150)).await;
118 self.running.fetch_sub(1, Ordering::SeqCst);
119 Ok(ToolResult::success("slow done"))
120 }
121 }
122
123 /// An extension tool the core knows about, for the no-recursion rule.
124 struct FakeExtensionTool;
125
126 #[async_trait]
127 impl ToolSpec for FakeExtensionTool {
128 fn name(&self) -> &str {
129 "fake_ext"
130 }
131 fn description(&self) -> &str {
132 "an extension tool"
133 }
134 fn input_schema(&self) -> Value {
135 json!({"type": "object"})
136 }
137 fn capabilities(&self) -> Vec<ToolCapability> {
138 vec![ToolCapability::ExecutesCode]
139 }
140 fn extension_caller(&self) -> Option<ExtensionCaller> {
141 Some(ExtensionCaller {
142 origin: "extension:fake".to_string(),
143 tool: "fake_ext".to_string(),
144 scope: "ext:fake@h".to_string(),
145 })
146 }
147 async fn execute(
148 &self,
149 _input: Value,
150 _context: &ToolContext,
151 ) -> Result<ToolResult, ToolError> {
152 Ok(ToolResult::success("never"))
153 }
154 }
155
156 /// One extension tool of the `core-call` fixture, running under the stand-in
157 /// gate with the file tools (and a slow one) as the tool snapshot its core
158 /// calls run against.
159 struct Rig {
160 _manager: super::TestManagerGuard,
161 tool: Arc<dyn ToolSpec>,
162 context: ToolContext,
163 server: Arc<Server>,
164 slow: Arc<SlowFixture>,
165 }
166
167 impl Rig {
168 fn new(engine: &HostAttachment, workspace: &Path, tool: &str) -> Self {
169 let manager = super::TestManagerGuard::install(Arc::clone(&engine.manager));
170 let slow = Arc::new(SlowFixture {
171 running: AtomicUsize::new(0),
172 peak: AtomicUsize::new(0),
173 });
174 let mut registry = crate::tools::registry::ToolRegistryBuilder::new()
175 .with_file_tools()
176 .build(ToolContext::new(workspace).with_plugin_registry(engine.plugin_view()));
177 registry.register(slow.clone());
178 engine.install_tools(&mut registry);
179 let tool = registry.get(tool).expect("the fixture tool is installed");
180 let caller = tool.extension_caller().expect("an extension tool");
181 let (tx_event, mut rx_event) = mpsc::channel(64);
182 tokio::spawn(async move { while rx_event.recv().await.is_some() {} });
183 let (gate, requests) = NestedCallGate::new(None, tx_event, Duration::from_secs(60));
184 let server = Arc::new(Server::default());
185 serve(requests, Arc::clone(&server));
186 let mut context = ToolContext::new(workspace).with_plugin_registry(engine.plugin_view());
187 context.execution.nested_call_gate = Some(gate.for_extension(caller, registry.all()));
188 Self {
189 _manager: manager,
190 tool,
191 context,
192 server,
193 slow,
194 }
195 }
196
197 async fn run(&self, input: Value) -> Result<ToolResult, ToolError> {
198 self.tool.execute(input, &self.context).await
199 }
200
201 async fn json(&self, input: Value) -> Value {
202 let result = self.run(input).await.unwrap_or_else(|e| panic!("{e:?}"));
203 serde_json::from_str(&result.content).expect("the tool answers JSON")
204 }
205 }
206
207 async fn started(
208 node: std::path::PathBuf,
209 deadline: Option<Duration>,
210 ) -> (FixturePlugins, Arc<ExtensionHostManager>, HostAttachment) {
211 let fixture = FixturePlugins::new(&["core-call"]).await;
212 let manager = Arc::new(ExtensionHostManager::new(ExtensionHostOptions {
213 runtime: crate::config::ExtensionHostRuntime::Node,
214 node_override: Some(node),
215 root: Some(fixture.root.clone()),
216 supervision: SupervisionOptions {
217 tool_call_deadline: deadline.unwrap_or(super::tool::TOOL_CALL_DEADLINE),
218 ..Default::default()
219 },
220 ..Default::default()
221 }));
222 let engine = manager.attach(fixture.registry());
223 engine.sync().await.unwrap();
224 (fixture, manager, engine)
225 }
226
227 async fn until(what: &str, mut done: impl FnMut() -> bool) {
228 for _ in 0..300 {
229 if done() {
230 return;
231 }
232 tokio::time::sleep(Duration::from_millis(10)).await;
233 }
234 panic!("timed out waiting for {what}");
235 }
236
237 // ---------------------------------------------------------------------------
238 // Through the real host
239 // ---------------------------------------------------------------------------
240
241 #[tokio::test]
242 async fn an_extension_tool_runs_a_core_tool_through_the_gate_and_the_result_records_what_ran() {
243 let Some(node) = node_for_tests("an_extension_tool_runs_a_core_tool") else {
244 return;
245 };
246 let _policy = TestPolicyGuard::extension_host(true);
247 let (fixture, manager, engine) = started(node, None).await;
248 std::fs::write(
249 fixture.workspace().join("note.txt"),
250 "hello from the workspace",
251 )
252 .unwrap();
253 let rig = Rig::new(&engine, fixture.workspace(), "cc_call");
254
255 let result = rig
256 .run(json!({"name": "read_file", "input": {"path": "note.txt"}}))
257 .await
258 .unwrap();
259 let answer: Value = serde_json::from_str(&result.content).unwrap();
260 assert_eq!(answer["ok"]["isError"], false, "{answer}");
261 assert!(
262 answer["ok"]["content"]
263 .as_str()
264 .unwrap()
265 .contains("hello from the workspace"),
266 "{answer}"
267 );
268 assert_eq!(rig.server.asked.load(Ordering::SeqCst), 1);
269 // The persisted record shows what the tool asked the core to run.
270 let core_calls = &result.metadata.as_ref().unwrap()["core_calls"];
271 assert_eq!(core_calls["total"], 1);
272 assert_eq!(core_calls["calls"][0]["tool"], "read_file");
273 assert_eq!(core_calls["calls"][0]["status"], "ok");
274 assert_eq!(core_calls["calls"][0]["decision"], "auto");
275 // A tool that made none leaves no `core_calls` key.
276 let probe = Rig::new(&engine, fixture.workspace(), "cc_probe");
277 let none = probe.run(json!({})).await.unwrap();
278 assert!(none.metadata.as_ref().unwrap().get("core_calls").is_none());
279 // The ticket does not outlive the call.
280 assert_eq!(manager.shared.core_calls.live_tickets(), 0);
281 manager.shutdown().await;
282 }
283
284 #[tokio::test]
285 async fn every_refusal_is_refused_through_the_real_host_before_the_gate_is_asked() {
286 let Some(node) = node_for_tests("every_refusal_is_refused") else {
287 return;
288 };
289 let _policy = TestPolicyGuard::extension_host(true);
290 let (fixture, manager, engine) = started(node, None).await;
291 let rig = Rig::new(&engine, fixture.workspace(), "cc_call");
292 let names = [
293 // Code mode's own, in the spellings the model could try.
294 "execute_tools",
295 "EXECUTE_TOOLS",
296 "code_execution",
297 "js_execution",
298 "agent",
299 "Agent",
300 "workflow",
301 "rlm",
302 "request_user_input",
303 "multi_tool_use.parallel",
304 // MCP, Computer Use included.
305 "mcp_demo_tool",
306 "MCP_Demo_Tool",
307 "mcp_computer_computer_register",
308 "list_mcp_resources",
309 "read_mcp_resource",
310 // Discovery and retrieval.
311 "tool_search",
312 "Tool_Search_Tool_BM25",
313 "tool_search_tool_regex",
314 "retrieve_tool_result",
315 // The memory writer and what changes the session's permissions.
316 "remember",
317 "REMEMBER",
318 "request_plugin_install",
319 "create_goal",
320 "update_goal",
321 "automation",
322 "automation_create",
323 "send_later",
324 "start_mcp_server",
325 "start_registry_mcp_server",
326 // Extension tools, this one's own included.
327 "cc_call",
328 "CC_CALL",
329 "cc_many",
330 ];
331 for name in names {
332 let answer = rig.json(json!({"name": name, "input": {}})).await;
333 assert_eq!(
334 answer["failed"]["code"], "refused",
335 "{name} must be refused: {answer}"
336 );
337 }
338 // An interactive shell and a sandbox escalation, whatever the tool.
339 for (name, input) in [
340 ("bash", json!({"command": "ls", "interactive": true})),
341 (
342 "read_file",
343 json!({"path": "x", "sandbox_permissions": "danger"}),
344 ),
345 ] {
346 let answer = rig.json(json!({"name": name, "input": input})).await;
347 assert_eq!(answer["failed"]["code"], "refused", "{name}: {answer}");
348 }
349 assert_eq!(
350 rig.server.asked.load(Ordering::SeqCst),
351 0,
352 "no refused call reached planning"
353 );
354 manager.shutdown().await;
355 }
356
357 #[tokio::test]
358 async fn without_the_turn_loops_gate_for_this_tool_there_is_no_ticket_and_no_core() {
359 let Some(node) = node_for_tests("without_the_turn_loops_gate") else {
360 return;
361 };
362 let _policy = TestPolicyGuard::extension_host(true);
363 let (fixture, manager, engine) = started(node, None).await;
364 let rig = Rig::new(&engine, fixture.workspace(), "cc_call");
365 let input = json!({"name": "read_file", "input": {"path": "x"}});
366
367 // No gate at all: a sub-agent, a test, a call nested in `execute_tools`.
368 let bare = ToolContext::new(fixture.workspace()).with_plugin_registry(engine.plugin_view());
369 let answer: Value = serde_json::from_str(
370 &rig.tool
371 .execute(input.clone(), &bare)
372 .await
373 .unwrap()
374 .content,
375 )
376 .unwrap();
377 assert_eq!(answer, json!({"noCore": true}));
378
379 // A gate that is not an extension's (an `execute_tools` program's).
380 let mut program =
381 ToolContext::new(fixture.workspace()).with_plugin_registry(engine.plugin_view());
382 program.execution.nested_call_gate = Some(NestedCallGate::admitting_for_test());
383 let answer: Value = serde_json::from_str(
384 &rig.tool
385 .execute(input.clone(), &program)
386 .await
387 .unwrap()
388 .content,
389 )
390 .unwrap();
391 assert_eq!(answer, json!({"noCore": true}));
392
393 // An extension gate served for another tool's call.
394 let (tx_event, mut rx_event) = mpsc::channel(8);
395 tokio::spawn(async move { while rx_event.recv().await.is_some() {} });
396 let (gate, requests) = NestedCallGate::new(None, tx_event, Duration::from_secs(60));
397 let server = Arc::new(Server::default());
398 serve(requests, Arc::clone(&server));
399 let mut other =
400 ToolContext::new(fixture.workspace()).with_plugin_registry(engine.plugin_view());
401 other.execution.nested_call_gate = Some(gate.for_extension(
402 ExtensionCaller {
403 origin: "extension:other".to_string(),
404 tool: "other_tool".to_string(),
405 scope: "ext:other@h".to_string(),
406 },
407 Vec::new(),
408 ));
409 let answer: Value =
410 serde_json::from_str(&rig.tool.execute(input, &other).await.unwrap().content).unwrap();
411 assert_eq!(answer, json!({"noCore": true}));
412 assert_eq!(server.asked.load(Ordering::SeqCst), 0);
413
414 // A command invocation never has `core`.
415 let reference = manager
416 .commands_for_plugins(&engine.plugin_view())
417 .into_iter()
418 .find(|entry| entry.registration.name == "cc-probe")
419 .expect("the command is live")
420 .reference();
421 match super::command::run(&manager.shared, &reference, "", None).await {
422 Ok(super::command::CommandOutcome::Show { text }) => {
423 let probe: Value = serde_json::from_str(&text).unwrap();
424 assert_eq!(probe["hasCore"], false, "{text}");
425 }
426 other => panic!("{other:?}"),
427 }
428 manager.shutdown().await;
429 }
430
431 #[tokio::test]
432 async fn a_core_call_waiting_on_a_person_pauses_the_tool_call_deadline() {
433 let Some(node) = node_for_tests("a_core_call_waiting_on_a_person") else {
434 return;
435 };
436 let _policy = TestPolicyGuard::extension_host(true);
437 let (fixture, manager, engine) = started(node, Some(Duration::from_secs(1))).await;
438 std::fs::write(fixture.workspace().join("note.txt"), "slow approval").unwrap();
439 let rig = Rig::new(&engine, fixture.workspace(), "cc_call");
440 rig.server.hold.store(true, Ordering::SeqCst);
441
442 // The approval takes 2.5 s: longer than the tool's whole 1 s deadline.
443 let started_at = Instant::now();
444 let release = async {
445 tokio::time::sleep(Duration::from_millis(2500)).await;
446 rig.server.release.notify_one();
447 };
448 let (result, ()) = tokio::join!(
449 rig.run(json!({"name": "read_file", "input": {"path": "note.txt"}})),
450 release
451 );
452 let answer: Value =
453 serde_json::from_str(&result.as_ref().expect("the call is not timed out").content).unwrap();
454 assert_eq!(answer["ok"]["isError"], false, "{answer}");
455 assert!(started_at.elapsed() >= Duration::from_millis(2400));
456 assert_eq!(
457 result.as_ref().unwrap().metadata.as_ref().unwrap()["core_calls"]["calls"][0]["decision"],
458 "approved",
459 "the receipt records that a person allowed it"
460 );
461
462 // The deadline still holds when nothing is paused.
463 rig.server.hold.store(false, Ordering::SeqCst);
464 let slow = Rig::new(&engine, fixture.workspace(), "cc_then_sleep");
465 let error = slow
466 .run(json!({"name": "read_file", "input": {"path": "note.txt"}, "ms": 5000}))
467 .await
468 .unwrap_err();
469 assert!(matches!(error, ToolError::Timeout { .. }), "{error:?}");
470 manager.shutdown().await;
471 }
472
473 #[tokio::test]
474 async fn cancel_revoke_and_host_exit_withdraw_a_pending_core_call() {
475 let Some(node) = node_for_tests("cancel_revoke_and_host_exit") else {
476 return;
477 };
478 let _policy = TestPolicyGuard::extension_host(true);
479 let (fixture, manager, engine) = started(node, None).await;
480 let call = json!({"name": "read_file", "input": {"path": "x"}});
481
482 // The turn is cancelled (the tool call's future is dropped) while a card
483 // is open: the server's wait for the person is withdrawn.
484 let rig = Rig::new(&engine, fixture.workspace(), "cc_call");
485 rig.server.hold.store(true, Ordering::SeqCst);
486 let wait = tokio::time::timeout(Duration::from_millis(500), rig.run(call.clone())).await;
487 assert!(wait.is_err(), "the call is still waiting on the card");
488 assert_eq!(rig.server.asked.load(Ordering::SeqCst), 1);
489 until("the withdrawal", || {
490 rig.server.withdrawn.load(Ordering::SeqCst) == 1
491 })
492 .await;
493 assert_eq!(manager.shared.core_calls.live_tickets(), 0);
494
495 // The plugin is disabled while a card is open: the owner's revocation
496 // withdraws it, and the call fails cancelled.
497 let rig = Rig::new(&engine, fixture.workspace(), "cc_call");
498 rig.server.hold.store(true, Ordering::SeqCst);
499 let disable = async {
500 until("the card to open", || {
501 rig.server.asked.load(Ordering::SeqCst) == 1
502 })
503 .await;
504 engine.set_plugins(fixture.disable("core-call"));
505 engine.sync().await.unwrap();
506 };
507 let (result, ()) = tokio::join!(rig.run(call.clone()), disable);
508 assert!(result.is_err(), "a revoked tool's call fails: {result:?}");
509 until("the withdrawal", || {
510 rig.server.withdrawn.load(Ordering::SeqCst) == 1
511 })
512 .await;
513 assert_eq!(manager.shared.core_calls.live_tickets(), 0);
514
515 // The host exits while a card is open (the plugin is enabled again first).
516 crate::plugins::discovery::discover_with_config(&fixture.config)
517 .enable("core-call")
518 .unwrap();
519 engine.set_plugins(fixture.registry());
520 engine.sync().await.unwrap();
521 let rig = Rig::new(&engine, fixture.workspace(), "cc_call");
522 rig.server.hold.store(true, Ordering::SeqCst);
523 let pid = manager.host_pid().expect("a running host");
524 let kill = async {
525 until("the card to open", || {
526 rig.server.asked.load(Ordering::SeqCst) == 1
527 })
528 .await;
529 #[cfg(unix)]
530 let status = std::process::Command::new("kill")
531 .args(["-9", &pid.to_string()])
532 .status()
533 .unwrap();
534 #[cfg(windows)]
535 let status = std::process::Command::new("taskkill")
536 .args(["/F", "/PID", &pid.to_string()])
537 .status()
538 .unwrap();
539 assert!(status.success());
540 };
541 let (result, ()) = tokio::join!(rig.run(call), kill);
542 assert!(result.is_err(), "a killed host fails the call: {result:?}");
543 until("the withdrawal", || {
544 rig.server.withdrawn.load(Ordering::SeqCst) == 1
545 })
546 .await;
547 assert_eq!(manager.shared.core_calls.live_tickets(), 0);
548 manager.shutdown().await;
549 }
550
551 #[tokio::test]
552 async fn an_invocation_has_at_most_fifty_core_calls_and_four_at_once() {
553 let Some(node) = node_for_tests("an_invocation_has_at_most_fifty") else {
554 return;
555 };
556 let _policy = TestPolicyGuard::extension_host(true);
557 let (fixture, manager, engine) = started(node, None).await;
558 std::fs::write(fixture.workspace().join("note.txt"), "n").unwrap();
559
560 // 52 calls in a row: the first 50 run, the last two are refused.
561 let rig = Rig::new(&engine, fixture.workspace(), "cc_many");
562 let answer = rig
563 .json(json!({"name": "read_file", "input": {"path": "note.txt"}, "count": 52}))
564 .await;
565 let outcomes = answer["outcomes"].as_array().unwrap();
566 assert_eq!(outcomes.len(), 52);
567 assert!(
568 outcomes[..MAX_CALLS_PER_INVOCATION as usize]
569 .iter()
570 .all(|outcome| outcome["ok"]["isError"] == false)
571 );
572 for outcome in &outcomes[MAX_CALLS_PER_INVOCATION as usize..] {
573 assert_eq!(outcome["failed"]["code"], "refused", "{outcome}");
574 assert!(
575 outcome["failed"]["message"]
576 .as_str()
577 .unwrap()
578 .contains("limit"),
579 "{outcome}"
580 );
581 }
582 assert_eq!(rig.server.asked.load(Ordering::SeqCst), 50);
583
584 // Eight at once of a tool that takes a while: all run, never more than four together.
585 let rig = Rig::new(&engine, fixture.workspace(), "cc_many");
586 let answer = rig
587 .json(json!({"name": "slow_fixture", "input": {}, "count": 8, "parallel": true}))
588 .await;
589 assert_eq!(answer["outcomes"].as_array().unwrap().len(), 8);
590 assert!(
591 answer["outcomes"]
592 .as_array()
593 .unwrap()
594 .iter()
595 .all(|outcome| outcome["ok"]["isError"] == false)
596 );
597 let peak = rig.slow.peak.load(Ordering::SeqCst);
598 assert!((2..=4).contains(&peak), "peak concurrency {peak}");
599 manager.shutdown().await;
600 }
601
602 // ---------------------------------------------------------------------------
603 // Tickets presented to the core (no host process)
604 // ---------------------------------------------------------------------------
605
606 struct Fixture {
607 manager: ExtensionHostManager,
608 owner: OwnerRef,
609 other: OwnerRef,
610 context: ToolContext,
611 gate: NestedCallGate,
612 }
613
614 fn fixture(uses_server: bool) -> Fixture {
615 let manager = ExtensionHostManager::new(ExtensionHostOptions::default());
616 let mut registry = manager.shared.registry.lock().unwrap();
617 let begin = |registry: &mut super::registry::OwnerRegistry, id: &str| {
618 let owner = registry
619 .begin_owner(HostTier::Plugin, id, id, Some(fake_authority(id)), "hash")
620 .unwrap();
621 registry.mark_active(&owner);
622 owner
623 };
624 let owner = begin(&mut registry, "a");
625 let other = begin(&mut registry, "b");
626 drop(registry);
627 let dir = tempfile::tempdir().unwrap();
628 let workspace = dir.path().to_path_buf();
629 std::mem::forget(dir);
630 std::fs::write(workspace.join("x"), "x").unwrap();
631 let specs = crate::tools::registry::ToolRegistryBuilder::new()
632 .with_file_tools()
633 .build(ToolContext::new(&workspace))
634 .all();
635 let (tx_event, mut rx_event) = mpsc::channel(8);
636 tokio::spawn(async move { while rx_event.recv().await.is_some() {} });
637 let (gate, requests) = NestedCallGate::new(None, tx_event, Duration::from_secs(60));
638 if uses_server {
639 serve(requests, Arc::new(Server::default()));
640 }
641 Fixture {
642 manager,
643 owner,
644 other,
645 context: ToolContext::new(&workspace),
646 gate: gate.for_extension(
647 ExtensionCaller {
648 origin: "extension:a".to_string(),
649 tool: "a_tool".to_string(),
650 scope: "ext:a@hash".to_string(),
651 },
652 specs,
653 ),
654 }
655 }
656
657 impl Fixture {
658 fn caller(&self) -> ExtensionCaller {
659 self.gate.extension().unwrap().0.clone()
660 }
661
662 fn begin(&self, generation: u64, owner: &OwnerRef) -> super::core_call::InvocationGuard {
663 self.manager
664 .shared
665 .core_calls
666 .begin(
667 HostTier::Plugin,
668 generation,
669 owner,
670 "call-1",
671 &self.caller(),
672 &self.context,
673 &self.gate,
674 )
675 .expect("an invocation")
676 }
677
678 async fn serve(
679 &self,
680 tier: HostTier,
681 generation: u64,
682 owner: &OwnerRef,
683 ticket: &str,
684 ) -> (Result<Value, RpcErrorWire>, Vec<String>) {
685 let (cx, mut violations, _cancel) = HostRequestContext::for_test(1);
686 let result = self
687 .manager
688 .shared
689 .core_calls
690 .serve(
691 &self.manager.shared,
692 tier,
693 generation,
694 CoreCallParams {
695 owner: owner.clone(),
696 ticket: ticket.to_string(),
697 name: "read_file".to_string(),
698 input: json!({"path": "x"}),
699 },
700 cx,
701 )
702 .await;
703 let mut seen = Vec::new();
704 while let Ok(violation) = violations.try_recv() {
705 seen.push(violation);
706 }
707 (result, seen)
708 }
709 }
710
711 fn refused(result: &Result<Value, RpcErrorWire>) -> bool {
712 matches!(result, Err(error) if error.code == error_code::REFUSED)
713 }
714
715 #[tokio::test]
716 async fn a_spoofed_ticket_is_refused_whoever_presents_it_and_a_burst_ends_the_host() {
717 let f = fixture(true);
718 let guard = f.begin(7, &f.owner);
719 let ticket = guard.ticket().to_string();
720
721 // The honest presentation works.
722 let (ok, violations) = f.serve(HostTier::Plugin, 7, &f.owner, &ticket).await;
723 assert!(ok.is_ok(), "{ok:?}");
724 assert!(violations.is_empty());
725
726 // Another plugin in the same host presents it: wrong owner.
727 let (result, _) = f.serve(HostTier::Plugin, 7, &f.other, &ticket).await;
728 assert!(refused(&result), "{result:?}");
729 // The right plugin with another activation's token.
730 let stale_owner = OwnerRef {
731 owner_token: "0".repeat(32),
732 ..f.owner.clone()
733 };
734 let (result, _) = f.serve(HostTier::Plugin, 7, &stale_owner, &ticket).await;
735 assert!(refused(&result), "{result:?}");
736 // From the other tier's host.
737 let (result, _) = f.serve(HostTier::Builtin, 7, &f.owner, &ticket).await;
738 assert!(refused(&result), "{result:?}");
739 // After a host generation bump (a restarted host).
740 let (result, _) = f.serve(HostTier::Plugin, 8, &f.owner, &ticket).await;
741 assert!(refused(&result), "{result:?}");
742 // A made-up ticket.
743 let (result, _) = f.serve(HostTier::Plugin, 7, &f.owner, "cwt.guess").await;
744 assert!(refused(&result), "{result:?}");
745 // The refusal says nothing about which field mismatched, and never the ticket.
746 let message = &result.as_ref().unwrap_err().message;
747 assert!(!message.contains("cwt."), "{message}");
748
749 // The ticket is still good for its owner afterwards.
750 let (ok, _) = f.serve(HostTier::Plugin, 7, &f.owner, &ticket).await;
751 assert!(ok.is_ok(), "{ok:?}");
752
753 // Eight invalid presentations are a burst: the host is ended, once it is.
754 let (_, violations) = f.serve(HostTier::Plugin, 7, &f.other, &ticket).await;
755 assert!(violations.is_empty(), "{violations:?}");
756 let mut ended = Vec::new();
757 for _ in 0..8 {
758 let (_, violations) = f.serve(HostTier::Plugin, 7, &f.other, "cwt.guess").await;
759 ended.extend(violations);
760 }
761 assert!(
762 ended
763 .iter()
764 .any(|reason| reason
765 .starts_with("protocol violation: too many invalid core/call tickets")),
766 "{ended:?}"
767 );
768
769 // The ticket dies with its host, its owner and its invocation.
770 f.manager.shared.core_calls.revoke_host(HostTier::Plugin, 7);
771 let (result, _) = f.serve(HostTier::Plugin, 7, &f.owner, &ticket).await;
772 assert!(refused(&result), "after the host exited: {result:?}");
773 let guard = f.begin(7, &f.owner);
774 let second = guard.ticket().to_string();
775 f.manager.shared.core_calls.revoke_owner("a");
776 let (result, _) = f.serve(HostTier::Plugin, 7, &f.owner, &second).await;
777 assert!(refused(&result), "after the owner was revoked: {result:?}");
778 let guard = f.begin(7, &f.owner);
779 let third = guard.ticket().to_string();
780 drop(guard);
781 let (result, _) = f.serve(HostTier::Plugin, 7, &f.owner, &third).await;
782 assert!(refused(&result), "after the invocation ended: {result:?}");
783 assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
784 }
785
786 #[tokio::test]
787 async fn a_ticket_for_a_revoked_owner_is_refused_even_if_it_was_never_revoked() {
788 let f = fixture(true);
789 let guard = f.begin(7, &f.owner);
790 // The owner is replaced by a new activation: its token changes, so the
791 // old one the host still holds no longer names the current owner.
792 let ticket = guard.ticket().to_string();
793 f.manager.shared.registry.lock().unwrap().revoke_owner("a");
794 let (result, _) = f.serve(HostTier::Plugin, 7, &f.owner, &ticket).await;
795 assert!(refused(&result), "{result:?}");
796 }
797
798 #[test]
799 fn the_wire_result_is_the_tools_text_and_json_and_says_when_it_was_cut() {
800 let text = |wire: &super::protocol::ToolResultWire| match &wire.content[..] {
801 [ContentBlockWire::Text { text }] => text.clone(),
802 other => panic!("{other:?}"),
803 };
804 let ok = wire_from_response(ToolCallResponse {
805 ok: true,
806 result: json!({"content": "plain", "metadata": null, "truncated": null}),
807 });
808 assert_eq!(
809 (text(&ok).as_str(), ok.is_error, &ok.structured),
810 ("plain", false, &None)
811 );
812 let json_content = wire_from_response(ToolCallResponse {
813 ok: true,
814 result: json!({"content": {"a": 1}, "metadata": null, "truncated": null}),
815 });
816 assert_eq!(text(&json_content), r#"{"a":1}"#);
817 assert_eq!(json_content.structured, Some(json!({"a": 1})));
818 let cut = wire_from_response(ToolCallResponse {
819 ok: true,
820 result: json!({"content": "head", "metadata": null,
821 "truncated": {"original_bytes": 9000, "kept_bytes": 4, "spill_path": "/secret/path"}}),
822 });
823 let cut_text = text(&cut);
824 assert!(
825 cut_text.contains("9000 bytes") && cut_text.contains("first 4"),
826 "{cut_text}"
827 );
828 assert!(
829 !cut_text.contains("/secret/path"),
830 "no local path reaches the host"
831 );
832 let failed = wire_from_response(ToolCallResponse {
833 ok: false,
834 result: json!("tool failed"),
835 });
836 assert_eq!(
837 (text(&failed).as_str(), failed.is_error),
838 ("tool failed", true)
839 );
840 }
841
842 // ---------------------------------------------------------------------------
843 // Policy
844 // ---------------------------------------------------------------------------
845
846 #[test]
847 fn the_refusal_list_is_case_insensitive_covers_aliases_and_spares_ordinary_tools() {
848 let specs: Vec<Arc<dyn ToolSpec>> = vec![Arc::new(FakeExtensionTool)];
849 let empty = json!({});
850 for name in [
851 "execute_tools",
852 "Execute_Tools",
853 "EXECUTE_TOOLS",
854 "agent",
855 "AGENT",
856 "mcp_a_b",
857 "MCP_A_B",
858 "tool_search",
859 "TOOL_SEARCH_TOOL_BM25",
860 "retrieve_tool_result",
861 "Remember",
862 "create_goal",
863 "automation_create",
864 "AUTOMATION",
865 "request_plugin_install",
866 "fake_ext",
867 "FAKE_EXT",
868 ] {
869 assert!(refusal(&specs, name, &empty).is_some(), "{name}");
870 }
871 assert!(refusal(&specs, "bash", &json!({"interactive": true})).is_some());
872 assert!(refusal(&specs, "read_file", &json!({"sandbox_permissions": "x"})).is_some());
873 // Ordinary tools are not refused here: they are planned, and prompt.
874 for name in [
875 "read_file",
876 "read",
877 "write_file",
878 "bash",
879 "web_search",
880 "fetch_url",
881 "list_dir",
882 ] {
883 assert_eq!(refusal(&specs, name, &empty), None, "{name}");
884 }
885 }
886
887 #[test]
888 fn the_auto_table_is_registered_read_only_tools_and_shell_and_network_force_a_prompt() {
889 // Every row is a real, read-only, auto-approved tool.
890 let registry = crate::tools::registry::ToolRegistryBuilder::new()
891 .with_file_tools()
892 .with_search_tools()
893 .build(ToolContext::new(Path::new("/w")));
894 for name in EXT_AUTO_ELIGIBLE {
895 let spec = registry
896 .get(name)
897 .unwrap_or_else(|| panic!("{name} is not a registered tool"));
898 assert!(spec.is_read_only(), "{name}");
899 assert_eq!(
900 spec.approval_requirement(),
901 ApprovalRequirement::Auto,
902 "{name}"
903 );
904 }
905
906 let input = json!({});
907 for name in EXT_AUTO_ELIGIBLE {
908 // Planning found nothing that asks: unchanged. If it did ask, still asks.
909 assert_eq!(
910 origin_approval(name, &input, false, None),
911 OriginApproval::Unchanged,
912 "{name}"
913 );
914 assert_eq!(
915 origin_approval(name, &input, true, None),
916 OriginApproval::Prompt,
917 "{name}"
918 );
919 }
920 // Anything else needs approval even where the model's call would not.
921 for name in [
922 "write_file",
923 "edit_file",
924 "apply_patch",
925 "todo_write",
926 "notify",
927 "tui_help",
928 ] {
929 assert_eq!(
930 origin_approval(name, &input, false, None),
931 OriginApproval::Prompt,
932 "{name}"
933 );
934 }
935 // Shell and network force a prompt, in every spelling.
936 for name in [
937 "bash",
938 "Bash",
939 "BASH",
940 "exec_shell",
941 "task_shell_start",
942 "web_search",
943 "fetch_url",
944 "web.run",
945 "WEB.RUN",
946 "git_fetch",
947 "run_tests",
948 "verify",
949 "finance",
950 ] {
951 assert_eq!(
952 origin_approval(name, &input, false, None),
953 OriginApproval::ForcePrompt,
954 "{name}"
955 );
956 assert_eq!(
957 origin_approval(name, &input, true, None),
958 OriginApproval::ForcePrompt,
959 "{name}"
960 );
961 }
962 }
963
964 #[test]
965 fn action_families_and_registered_process_or_network_tools_always_force_a_prompt() {
966 let registry = crate::tools::registry::ToolRegistryBuilder::new()
967 .with_file_tools()
968 .with_git_tools()
969 .with_test_runner_tool()
970 .with_runtime_task_tools()
971 .with_web_tools()
972 .build(ToolContext::new(Path::new("/w")));
973 for (name, action) in [
974 ("Git", "fetch"),
975 ("Run", "tests"),
976 ("Web", "search"),
977 ("Web", "fetch"),
978 ("tasks", "gate_run"),
979 ("github", "issue_context"),
980 ] {
981 let input = json!({"action": action});
982 let spec = registry.get(name).expect("registered family");
983 for spelling in [
984 name.to_string(),
985 name.to_ascii_lowercase(),
986 name.to_ascii_uppercase(),
987 ] {
988 assert_eq!(
989 origin_approval(&spelling, &input, false, Some(spec.as_ref())),
990 OriginApproval::ForcePrompt,
991 "{spelling}/{action} must prompt even under Full Access or a grant"
992 );
993 }
994 }
995 // Resolve family semantics even for core meta-tools without a registry spec.
996 for (name, action) in [("gIt", "fetch"), ("rUn", "tests"), ("wEb", "search")] {
997 assert_eq!(
998 origin_approval(name, &json!({"action": action}), false, None),
999 OriginApproval::ForcePrompt,
1000 "{name}/{action}"
1001 );
1002 }
1003 // The same family preserves safe workspace reads.
1004 let file = registry.get("File").expect("registered File family");
1005 assert_eq!(
1006 origin_approval(
1007 "File",
1008 &json!({"action": "read", "path": "note.txt"}),
1009 false,
1010 Some(file.as_ref())
1011 ),
1012 OriginApproval::Unchanged
1013 );
1014 }
1015
1016 /// How each existing approval posture resolves what an extension's call needs
1017 /// (the table in `docs/EXTENSIONS.md`): forced extension calls ask in both
1018 /// Ask and Full Access; Auto-Review and Never refuse them. Full Access
1019 /// auto-approves ordinary extension calls as it does for the model.
1020 #[test]
1021 fn extension_calls_resolve_against_every_posture_as_documented() {
1022 use crate::core::authority::{
1023 ApprovalRequestDisposition as D, TurnAuthority, resolve_approval_request_disposition,
1024 };
1025 use codewhale_config::AppMode;
1026 use codewhale_execpolicy::ApprovalMode;
1027
1028 let authority = |auto_approve: bool, mode: ApprovalMode| {
1029 TurnAuthority::from_effective_fields(AppMode::Agent, true, false, auto_approve, mode)
1030 };
1031 let input = json!({});
1032 // (posture, session grant held for exactly this extension's call)
1033 let ask = authority(false, ApprovalMode::Suggest);
1034 let full = authority(true, ApprovalMode::Bypass);
1035 let auto_review = authority(false, ApprovalMode::Auto);
1036 let never = authority(false, ApprovalMode::Never);
1037
1038 let resolve = |authority: &TurnAuthority, name: &str, granted: bool| {
1039 let approval = origin_approval(name, &input, false, None);
1040 match approval {
1041 OriginApproval::Unchanged => None,
1042 OriginApproval::Prompt => Some(resolve_approval_request_disposition(
1043 authority, granted, false, false, true,
1044 )),
1045 OriginApproval::ForcePrompt => Some(resolve_approval_request_disposition(
1046 authority, granted, false, true, true,
1047 )),
1048 }
1049 };
1050
1051 // A read-only workspace tool: no request at all, in any posture.
1052 for authority in [&ask, &full, &auto_review, &never] {
1053 assert_eq!(resolve(authority, "read_file", false), None);
1054 }
1055 // An ordinary tool that needs approval ("write_file").
1056 assert_eq!(resolve(&ask, "write_file", false), Some(D::Prompt));
1057 assert_eq!(
1058 resolve(&ask, "write_file", true),
1059 Some(D::AutoApprove),
1060 "an extension-scoped grant"
1061 );
1062 assert_eq!(resolve(&full, "write_file", false), Some(D::AutoApprove));
1063 assert_eq!(
1064 resolve(&auto_review, "write_file", false),
1065 Some(D::AutoDenyAutoReview)
1066 );
1067 assert_eq!(
1068 resolve(&never, "write_file", true),
1069 Some(D::AutoDenyNeverPosture)
1070 );
1071 // Shell and network ask in Ask and Full Access, whatever grant exists.
1072 // Explicit no-prompt postures still refuse.
1073 for name in ["bash", "web_search"] {
1074 assert_eq!(resolve(&ask, name, false), Some(D::Prompt), "{name}");
1075 assert_eq!(
1076 resolve(&ask, name, true),
1077 Some(D::Prompt),
1078 "{name}: a grant never satisfies it"
1079 );
1080 assert_eq!(resolve(&full, name, false), Some(D::Prompt), "{name}");
1081 assert_eq!(resolve(&full, name, true), Some(D::Prompt), "{name}");
1082 assert_eq!(
1083 resolve(&auto_review, name, false),
1084 Some(D::AutoDenyAutoReview),
1085 "{name}"
1086 );
1087 assert_eq!(
1088 resolve(&never, name, true),
1089 Some(D::AutoDenyNeverPosture),
1090 "{name}"
1091 );
1092 }
1093 }
1094
1095 #[test]
1096 fn approval_keys_are_scoped_to_the_extension_so_grants_never_cross_origins() {
1097 use crate::tools::approval_cache::{approval_keys_for_call, extension_origin_approval_keys};
1098 let input = json!({"command": "ls -la"});
1099 let model = approval_keys_for_call(None, "bash", &input);
1100 let ext = extension_origin_approval_keys("ext:a@h1", None, "bash", &input);
1101 let other_build = extension_origin_approval_keys("ext:a@h2", None, "bash", &input);
1102 let other_plugin = extension_origin_approval_keys("ext:b@h1", None, "bash", &input);
1103 for (one, two) in [(&model, &ext), (&ext, &other_build), (&ext, &other_plugin)] {
1104 assert_ne!(one.0, two.0, "exact keys must differ");
1105 assert_ne!(one.1, two.1, "grouping keys must differ");
1106 }
1107 assert!(ext.0.0.starts_with("extcall:ext:a@h1:") && ext.1.0.starts_with("extcall:ext:a@h1:"));
1108 // Both directions: a grant held for one origin is looked up by the other's key.
1109 let held_by_model: std::collections::HashSet<String> = [model.1.0.clone()].into();
1110 assert!(
1111 !held_by_model.contains(&ext.1.0),
1112 "a grant given to the model never covers an extension call"
1113 );
1114 let held_by_extension: std::collections::HashSet<String> = [ext.1.0.clone()].into();
1115 assert!(
1116 !held_by_extension.contains(&model.1.0),
1117 "a grant given to the extension never covers the model's call"
1118 );
1119 }
1120
1120 lines RUST