1use super::*;
5use bamboo_agent_core::tools::{
6 ToolCall, ToolError, ToolExecutionContext, ToolExecutor, ToolOutcome, ToolResult, ToolSchema,
7};
8use bamboo_subagent::executor::HostBridge;
9use bamboo_tickets::{FileOperation, FileReply};
10use serde::Deserialize;
11use serde_json::{json, Value};
12use std::{
13 collections::{BTreeMap, BTreeSet},
14 sync::Mutex,
15};
16
17pub const TICKET_FILE_ACTION: &str = "_ticket_workspace_file_v1";
18
19pub async fn verify_workspace(workspace: bamboo_tickets::ExecutionWorkspace) -> Result<()> {
22 tokio::task::spawn_blocking(move || {
23 use std::{path::Path, process::Command};
24 fn git(path: &str, args: &[&str]) -> Result<String> {
25 let output = Command::new("git")
26 .env("GIT_OPTIONAL_LOCKS", "0")
27 .args(["-C", path])
28 .args(args)
29 .output()?;
30 if !output.status.success() {
31 return Err(Error::ScopeDenied(
32 "Git workspace identity cannot be verified".into(),
33 ));
34 }
35 String::from_utf8(output.stdout)
36 .map(|s| s.trim().to_owned())
37 .map_err(|_| Error::ScopeDenied("Git workspace identity is not UTF-8".into()))
38 }
39 let root = Path::new(&workspace.worktree).canonicalize()?;
40 let marker = std::fs::symlink_metadata(root.join(".git"))?;
41 if !marker.is_file()
42 || marker.file_type().is_symlink()
43 || Path::new(&git(
44 &workspace.worktree,
45 &["rev-parse", "--show-toplevel"],
46 )?)
47 .canonicalize()?
48 != root
49 || git(&workspace.worktree, &["rev-parse", "HEAD"])? != workspace.base_commit
50 || git(&workspace.worktree, &["symbolic-ref", "--short", "HEAD"])? != workspace.branch
51 || Path::new(&git(
52 &workspace.worktree,
53 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
54 )?)
55 .canonicalize()?
56 != Path::new(&git(
57 &workspace.repo,
58 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
59 )?)
60 .canonicalize()?
61 {
62 return Err(Error::ScopeDenied(
63 "Assignment must use its exact isolated repo/base/branch/worktree".into(),
64 ));
65 }
66 Ok(())
67 })
68 .await
69 .map_err(|_| Error::AuthorityUnavailable("workspace preflight stopped".into()))?
70}
71
72#[derive(Clone, Deserialize)]
73#[serde(deny_unknown_fields)]
74struct FileRequest {
75 tool: String,
76 arguments: Value,
77 expected_sha256: Option<String>,
78}
79#[derive(Deserialize)]
80#[serde(deny_unknown_fields)]
81struct ReadArgs {
82 file_path: String,
83}
84#[derive(Deserialize)]
85#[serde(deny_unknown_fields)]
86struct WriteArgs {
87 file_path: String,
88 content: String,
89}
90
91fn operation(request: &FileRequest) -> Result<FileOperation> {
92 match request.tool.as_str() {
93 "Read" if request.expected_sha256.is_none() => {
94 let args: ReadArgs = serde_json::from_value(request.arguments.clone())?;
95 Ok(FileOperation::Read {
96 file_path: args.file_path,
97 })
98 }
99 "Write" => {
100 let args: WriteArgs = serde_json::from_value(request.arguments.clone())?;
101 Ok(FileOperation::Write {
102 file_path: args.file_path,
103 content: args.content,
104 expected_sha256: request.expected_sha256.clone(),
105 })
106 }
107 _ => Err(Error::ScopeDenied(
108 "unsupported Ticket file operation".into(),
109 )),
110 }
111}
112
113pub fn apply_host_file_request(
114 service: &TicketService,
115 caller: &Session,
116 run_id: &str,
117 args: &Value,
118 call_id: &str,
119 ceiling: &[String],
120) -> Result<FileReply> {
121 let body = args
122 .as_object()
123 .filter(|o| o.len() == 1)
124 .and_then(|o| o.get(TICKET_FILE_ACTION))
125 .ok_or_else(|| Error::ScopeDenied("invalid file callback".into()))?;
126 let request: FileRequest = serde_json::from_value(body.clone())?;
127 if !ceiling.contains(&request.tool) {
128 return Err(Error::ScopeDenied(
129 "file tool outside immutable native ceiling".into(),
130 ));
131 }
132 let assignment = caller
133 .metadata
134 .get(TICKET_LOCAL_PLAN_KEY)
135 .ok_or_else(|| Error::ScopeDenied("Child has no Ticket capability".into()))?;
136 let plan = TicketWorkerPlan::from_receipt_identity(Arc::new(service.clone()), assignment)?;
137 if caller.kind != SessionKind::Child
138 || caller.id != plan.session_id
139 || run_id != plan.run_id
140 || caller.parent_session_id.as_deref() != Some(plan.supervisor_session_id.as_str())
141 {
142 return Err(Error::ScopeDenied("file callback Run changed".into()));
143 }
144 service.workspace_file(&plan.authority, call_id, &operation(&request)?)
145}
146
147enum FileEndpoint {
148 Remote(HostBridge),
149 Local {
150 service: Arc<TicketService>,
151 child: Box<Session>,
152 run_id: String,
153 ceiling: Vec<String>,
154 },
155}
156
157impl FileEndpoint {
158 fn local_resources(
159 &self,
160 ) -> std::result::Result<Option<bamboo_tools::LocalTaskResources>, ToolError> {
161 let Self::Local { child, run_id, .. } = self else {
162 return Ok(None);
163 };
164 let resources = bamboo_tools::LocalTaskResources::current().ok_or_else(|| {
165 ToolError::Execution("local Ticket files require an owned execution scope".into())
166 })?;
167 let identity = resources.identity();
168 if identity.session_id != child.id
169 || identity.run_id != *run_id
170 || identity.child_birth != child.created_at
171 {
172 return Err(ToolError::Execution(
173 "local Ticket resource identity changed".into(),
174 ));
175 }
176 Ok(Some(resources))
177 }
178
179 async fn execute(
180 &self,
181 arguments: Value,
182 call_id: &str,
183 ) -> std::result::Result<FileReply, ToolError> {
184 match self {
185 Self::Remote(host) => {
186 let result = host
187 .subagent_call(arguments, call_id)
188 .await
189 .map_err(ToolError::Execution)?;
190 serde_json::from_value(result)
191 .map_err(|error| ToolError::Execution(error.to_string()))
192 }
193 Self::Local {
194 service,
195 child,
196 run_id,
197 ceiling,
198 } => {
199 let resources = self.local_resources()?.expect("checked local endpoint");
200 let service = service.clone();
201 let child = child.as_ref().clone();
202 let run_id = run_id.clone();
203 let ceiling = ceiling.clone();
204 let call_id = call_id.to_string();
205 run_owned_file_request(resources, move || {
206 apply_host_file_request(
207 &service, &child, &run_id, &arguments, &call_id, &ceiling,
208 )
209 })
210 .await
211 }
212 }
213 }
214}
215
216async fn run_owned_file_request(
217 resources: bamboo_tools::LocalTaskResources,
218 operation: impl FnOnce() -> Result<FileReply> + Send + 'static,
219) -> std::result::Result<FileReply, ToolError> {
220 let receiver = resources
224 .spawn_owned(async move {
225 tokio::task::spawn_blocking(operation)
226 .await
227 .map_err(|error| ToolError::Execution(format!("Ticket file task failed: {error}")))?
228 .map_err(|error| ToolError::Execution(error.to_string()))
229 })
230 .map_err(|error| ToolError::Execution(error.to_string()))?;
231 receiver
232 .await
233 .map_err(|error| ToolError::Execution(error.to_string()))?
234}
235
236pub struct RemoteFileExecutor {
237 inner: Arc<dyn ToolExecutor>,
238 endpoint: FileEndpoint,
239 session_id: String,
240 tools: BTreeSet<String>,
241 reads: Mutex<BTreeMap<String, String>>,
242 calls: Mutex<BTreeMap<String, FileRequest>>,
243}
244impl RemoteFileExecutor {
245 pub fn new(
246 inner: Arc<dyn ToolExecutor>,
247 host: HostBridge,
248 session_id: String,
249 tools: &[String],
250 ) -> Result<Self> {
251 bamboo_tickets::validate_native_tool_ceiling(tools.iter().map(String::as_str))?;
252 Ok(Self {
253 inner,
254 endpoint: FileEndpoint::Remote(host),
255 session_id,
256 tools: tools.iter().cloned().collect(),
257 reads: Mutex::new(BTreeMap::new()),
258 calls: Mutex::new(BTreeMap::new()),
259 })
260 }
261 fn permitted(&self, call: &ToolCall) -> std::result::Result<(), ToolError> {
262 if self.tools.contains(&call.function.name) {
263 Ok(())
264 } else {
265 Err(ToolError::NotFound(call.function.name.clone()))
266 }
267 }
268}
269#[async_trait::async_trait]
270impl ToolExecutor for RemoteFileExecutor {
271 async fn execute(&self, _: &ToolCall) -> std::result::Result<ToolResult, ToolError> {
272 Err(ToolError::Execution(
273 "Ticket file execution needs a bound Run context".into(),
274 ))
275 }
276 async fn execute_with_context_outcome(
277 &self,
278 call: &ToolCall,
279 ctx: ToolExecutionContext<'_>,
280 ) -> std::result::Result<ToolOutcome, ToolError> {
281 self.permitted(call)?;
282 if ctx.session_id != Some(self.session_id.as_str()) || ctx.tool_call_id != call.id {
283 return Err(ToolError::Execution("Ticket file context changed".into()));
284 }
285 self.endpoint.local_resources()?;
286 if call.function.name == "Task" {
287 return self.inner.execute_with_context_outcome(call, ctx).await;
288 }
289 let args: Value = serde_json::from_str(&call.function.arguments)
290 .map_err(|e| ToolError::InvalidArguments(e.to_string()))?;
291 if let Some(outcome) = self
292 .inner
293 .check_permissions_for_resolved(call, &call.function.name, &args, &ctx)
294 .await?
295 {
296 return Ok(outcome);
297 }
298 let path = args
299 .get("file_path")
300 .and_then(Value::as_str)
301 .ok_or_else(|| ToolError::InvalidArguments("file_path missing".into()))?;
302 let hash = if call.function.name == "Write" {
303 self.reads.lock().expect("read tracker").get(path).cloned()
304 } else {
305 None
306 };
307 let mut request = FileRequest {
308 tool: call.function.name.clone(),
309 arguments: args.clone(),
310 expected_sha256: hash.clone(),
311 };
312 operation(&request).map_err(|e| ToolError::InvalidArguments(e.to_string()))?;
313 {
314 let mut calls = self.calls.lock().expect("file call identities");
315 if let Some(original) = calls.get(&call.id) {
316 if original.tool != request.tool || original.arguments != request.arguments {
317 return Err(ToolError::Execution("idempotency_conflict".into()));
318 }
319 request = original.clone();
320 } else {
321 if calls.len() >= 32 {
322 return Err(ToolError::Execution("file call budget exceeded".into()));
323 }
324 calls.insert(call.id.clone(), request.clone());
325 }
326 }
327 let reply = self
328 .endpoint
329 .execute(
330 json!({(TICKET_FILE_ACTION): {
331 "tool": request.tool,
332 "arguments": request.arguments,
333 "expected_sha256": request.expected_sha256,
334 }}),
335 &call.id,
336 )
337 .await?;
338 self.reads
339 .lock()
340 .expect("read tracker")
341 .insert(path.into(), reply.sha256.clone());
342 Ok(ToolOutcome::Completed(ToolResult {
343 success: true,
344 result: serde_json::to_string(&reply)
345 .map_err(|e| ToolError::Execution(e.to_string()))?,
346 display_preference: None,
347 images: Vec::new(),
348 }))
349 }
350 async fn execute_with_context(
351 &self,
352 call: &ToolCall,
353 ctx: ToolExecutionContext<'_>,
354 ) -> std::result::Result<ToolResult, ToolError> {
355 self.execute_with_context_outcome(call, ctx)
356 .await
357 .map(ToolOutcome::into_tool_result)
358 }
359 async fn check_permissions_for(
360 &self,
361 call: &ToolCall,
362 ctx: &ToolExecutionContext<'_>,
363 ) -> std::result::Result<Option<ToolOutcome>, ToolError> {
364 self.permitted(call)?;
365 self.inner.check_permissions_for(call, ctx).await
366 }
367 fn list_tools(&self) -> Vec<ToolSchema> {
368 let mut schemas = self.inner.list_tools();
369 schemas.retain(|s| self.tools.contains(&s.function.name));
370 for schema in &mut schemas {
371 match schema.function.name.as_str() {
372 "Read" => {
373 schema.function.description="Read one complete UTF-8 file inside this Assignment's write roots. Both raw content and the encoded result must fit 16 KiB. No symlinks, hardlinks, .git, .bamboo control directories, traversal or directory listing.".into();
374 schema.function.parameters = json!({"type":"object","additionalProperties":false,"required":["file_path"],"properties":{"file_path":{"type":"string"}}});
375 }
376 "Write" => {
377 schema.function.description="Create one absent UTF-8 file, 1–16384 bytes, in an existing directory inside this Assignment's write roots, excluding .git and .bamboo control directories. Existing-file replacement is unsupported even after Read; use a new path. Installation cannot overwrite an externally created file. Unknown effects cannot be automatically retried.".into();
378 schema.function.parameters = json!({"type":"object","additionalProperties":false,"required":["file_path","content"],"properties":{"file_path":{"type":"string"},"content":{"type":"string","minLength":1,"maxLength":16384}}});
379 }
380 _ => {}
381 }
382 }
383 schemas
384 }
385}
386
387pub struct LocalFileExecutor {
392 executor: RemoteFileExecutor,
393}
394
395impl LocalFileExecutor {
396 pub fn new(
397 inner: Arc<dyn ToolExecutor>,
398 service: Arc<TicketService>,
399 canonical_child_snapshot: Session,
400 run_id: String,
401 ceiling: &[String],
402 ) -> Result<Self> {
403 bamboo_tickets::validate_native_tool_ceiling(ceiling.iter().map(String::as_str))?;
404 if canonical_child_snapshot.kind != SessionKind::Child
405 || canonical_child_snapshot
406 .agent_runtime_state
407 .as_ref()
408 .is_none_or(|runtime| runtime.run_id != run_id)
409 || !canonical_child_snapshot
410 .metadata
411 .contains_key(TICKET_LOCAL_PLAN_KEY)
412 {
413 return Err(Error::ScopeDenied(
414 "local file executor requires its exact Child Run".into(),
415 ));
416 }
417 Ok(Self {
418 executor: RemoteFileExecutor {
419 session_id: canonical_child_snapshot.id.clone(),
420 inner,
421 endpoint: FileEndpoint::Local {
422 service,
423 child: Box::new(canonical_child_snapshot),
424 run_id,
425 ceiling: ceiling.to_vec(),
426 },
427 tools: ceiling.iter().cloned().collect(),
428 reads: Mutex::new(BTreeMap::new()),
429 calls: Mutex::new(BTreeMap::new()),
430 },
431 })
432 }
433}
434
435#[async_trait::async_trait]
436impl ToolExecutor for LocalFileExecutor {
437 async fn execute(&self, call: &ToolCall) -> std::result::Result<ToolResult, ToolError> {
438 self.executor.execute(call).await
439 }
440 async fn execute_with_context_outcome(
441 &self,
442 call: &ToolCall,
443 ctx: ToolExecutionContext<'_>,
444 ) -> std::result::Result<ToolOutcome, ToolError> {
445 self.executor.execute_with_context_outcome(call, ctx).await
446 }
447 async fn execute_with_context(
448 &self,
449 call: &ToolCall,
450 ctx: ToolExecutionContext<'_>,
451 ) -> std::result::Result<ToolResult, ToolError> {
452 self.executor.execute_with_context(call, ctx).await
453 }
454 async fn check_permissions_for(
455 &self,
456 call: &ToolCall,
457 ctx: &ToolExecutionContext<'_>,
458 ) -> std::result::Result<Option<ToolOutcome>, ToolError> {
459 self.executor.check_permissions_for(call, ctx).await
460 }
461 fn list_tools(&self) -> Vec<ToolSchema> {
462 self.executor.list_tools()
463 }
464 fn owns_exact_tool(&self, name: &str) -> bool {
465 self.executor.tools.contains(name) && self.executor.inner.owns_exact_tool(name)
466 }
467 fn exact_tool_owner(&self, name: &str) -> Option<&dyn ToolExecutor> {
468 if !self.owns_exact_tool(name) {
469 None
470 } else if matches!(name, "Read" | "Write") {
471 Some(self)
472 } else {
473 self.executor.inner.exact_tool_owner(name)
474 }
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481 use bamboo_agent_core::tools::FunctionCall;
482 struct Gate(std::sync::atomic::AtomicBool);
483 #[async_trait::async_trait]
484 impl ToolExecutor for Gate {
485 async fn execute(&self, _: &ToolCall) -> std::result::Result<ToolResult, ToolError> {
486 panic!("ambient file execution must never occur")
487 }
488 async fn check_permissions_for(
489 &self,
490 _: &ToolCall,
491 _: &ToolExecutionContext<'_>,
492 ) -> std::result::Result<Option<ToolOutcome>, ToolError> {
493 if self.0.load(std::sync::atomic::Ordering::SeqCst) {
494 Err(ToolError::Execution("policy denied".into()))
495 } else {
496 Ok(None)
497 }
498 }
499 fn list_tools(&self) -> Vec<ToolSchema> {
500 ["Task", "Read", "Write", "Bash"]
501 .into_iter()
502 .map(|name| {
503 serde_json::from_value(json!({
504 "type": "function",
505 "function": {"name": name, "description": "fixture", "parameters": {}}
506 }))
507 .unwrap()
508 })
509 .collect()
510 }
511 }
512 #[tokio::test]
513 async fn permission_gate_and_frozen_raw_call_precede_host_file_execution() {
514 let (bridge, mut requests) = HostBridge::channel();
515 let gate = Arc::new(Gate(std::sync::atomic::AtomicBool::new(true)));
516 let executor = RemoteFileExecutor::new(
517 gate.clone(),
518 bridge,
519 "child".into(),
520 &["Task".into(), "Read".into(), "Write".into()],
521 )
522 .unwrap();
523 let call = ToolCall {
524 id: "write".into(),
525 tool_type: "function".into(),
526 function: FunctionCall {
527 name: "Write".into(),
528 arguments: json!({"file_path":"/fixture/code","content":"new"}).to_string(),
529 },
530 };
531 let mut ctx = ToolExecutionContext::none(&call.id);
532 ctx.session_id = Some("child");
533 assert!(executor
534 .execute_with_context_outcome(&call, ctx)
535 .await
536 .is_err());
537 assert!(requests.try_recv().is_err());
538 let mut shell = call.clone();
539 shell.function.name = "Bash".into();
540 assert!(executor
541 .execute_with_context_outcome(&shell, ctx)
542 .await
543 .is_err());
544 assert!(requests.try_recv().is_err());
545 gate.0.store(false, std::sync::atomic::Ordering::SeqCst);
546 let pump = tokio::spawn(async move {
547 let mut original = None;
548 for _ in 0..2 {
549 let request = requests.recv().await.unwrap();
550 let body = request.body["args"][TICKET_FILE_ACTION].clone();
551 if let Some(prior) = &original {
552 assert_eq!(
553 &body, prior,
554 "same raw Write must retain its original hash after success"
555 );
556 } else {
557 assert!(body["expected_sha256"].is_null());
558 original = Some(body);
559 }
560 request
561 .reply
562 .send(
563 json!({"result":{"content":null,"sha256":"a".repeat(64),"artifact":null}}),
564 )
565 .unwrap();
566 }
567 });
568 assert!(
569 executor
570 .execute_with_context_outcome(&call, ctx)
571 .await
572 .unwrap()
573 .into_tool_result()
574 .success
575 );
576 assert!(
577 executor
578 .execute_with_context_outcome(&call, ctx)
579 .await
580 .unwrap()
581 .into_tool_result()
582 .success
583 );
584 let mut changed = call.clone();
585 changed.function.arguments =
586 json!({"file_path":"/fixture/code","content":"different"}).to_string();
587 assert!(executor
588 .execute_with_context_outcome(&changed, ctx)
589 .await
590 .is_err());
591 pump.await.unwrap();
592 }
593
594 fn local_file_fixture() -> (
595 tempfile::TempDir,
596 Arc<TicketService>,
597 Session,
598 std::path::PathBuf,
599 ) {
600 use bamboo_tickets::{
601 Contract, ExecutionWorkspace, RuntimeReceipt, ScopeBinding, TicketKind,
602 };
603 let dir = tempfile::tempdir().unwrap();
604 let workspace = dir.path().join("worker");
605 std::fs::create_dir(&workspace).unwrap();
606 let workspace = workspace.canonicalize().unwrap();
607 let path = workspace.to_string_lossy().into_owned();
608 let binding = ScopeBinding {
609 scope_id: "local-files".into(),
610 supervisor_session_id: "root".into(),
611 binding_revision: 1,
612 };
613 let service =
614 Arc::new(TicketService::open(dir.path().join("authority"), binding.clone()).unwrap());
615 let host = Authority::from_verified_host(
616 binding.clone(),
617 Principal::Supervisor {
618 session_id: "root".into(),
619 },
620 );
621 let command = service
622 .prepare_command(
623 &host,
624 "start",
625 vec![
626 Operation::Create {
627 temp_id: "w".into(),
628 kind: TicketKind::Work,
629 parent: None,
630 depends_on: BTreeSet::new(),
631 contract: Contract {
632 title: "bounded local files".into(),
633 objective: "create one artifact".into(),
634 constraints: vec![],
635 acceptance: vec!["artifact".into()],
636 user_acceptance_required: true,
637 allowed_tools: BTreeSet::from([
638 "Task".into(),
639 "Read".into(),
640 "Write".into(),
641 ]),
642 },
643 },
644 Operation::Ready {
645 work_id: "w".into(),
646 },
647 Operation::Start {
648 work_id: "w".into(),
649 temp_id: "a".into(),
650 workspace: Some(ExecutionWorkspace {
651 repo: "fixture-repo".into(),
652 base_commit: "a".repeat(40),
653 branch: "worker".into(),
654 worktree: path.clone(),
655 write_roots: vec![path.clone()],
656 claims: BTreeSet::from([format!("worktree:{path}")]),
657 }),
658 },
659 ],
660 )
661 .unwrap();
662 let receipt = service.execute(&host, &command).unwrap();
663 let assignment_id = receipt.ids["a"].clone();
664 let (_, snapshot) = service.published().unwrap();
665 let assignment = &snapshot.assignments[&assignment_id];
666 let receipt = RuntimeReceipt {
667 dispatch_key: assignment.dispatch_key.clone(),
668 spec_hash: snapshot.intents[&assignment.dispatch_key].spec_hash.clone(),
669 run_id: "run".into(),
670 session_id: "child".into(),
671 };
672 let runtime = Authority::from_verified_host(binding, Principal::Runtime);
673 let command = service
674 .prepare_command(
675 &runtime,
676 "admit",
677 vec![
678 Operation::Admitted {
679 assignment_id: assignment_id.clone(),
680 receipt,
681 },
682 Operation::Running {
683 assignment_id: assignment_id.clone(),
684 },
685 ],
686 )
687 .unwrap();
688 service.execute(&runtime, &command).unwrap();
689 let mut child = Session::new_child("child", "root", "fixture-model", "fixture");
690 child.agent_runtime_state = Some(bamboo_domain::AgentRuntimeState::new("run"));
691 TicketWorkerPlan::from_runtime_receipt(service.clone(), &assignment_id)
692 .unwrap()
693 .bind_session(&mut child)
694 .unwrap();
695 (dir, service, child, workspace)
696 }
697
698 fn file_call(id: &str, tool: &str, args: Value) -> ToolCall {
699 ToolCall {
700 id: id.into(),
701 tool_type: "function".into(),
702 function: FunctionCall {
703 name: tool.into(),
704 arguments: args.to_string(),
705 },
706 }
707 }
708
709 #[cfg(unix)]
710 #[tokio::test]
711 async fn local_files_use_ticket_authority_and_preserve_raw_call_idempotency() {
712 let (_dir, service, child, workspace) = local_file_fixture();
713 let gate = Arc::new(Gate(std::sync::atomic::AtomicBool::new(true)));
714 let executor = LocalFileExecutor::new(
715 gate.clone(),
716 service,
717 child.clone(),
718 "run".into(),
719 &["Task".into(), "Read".into(), "Write".into()],
720 )
721 .unwrap();
722 let resources = bamboo_tools::LocalTaskResources::new(&child.id, "run", child.created_at);
723 let path = workspace.join("artifact.txt");
724 let call = file_call(
725 "write",
726 "Write",
727 json!({"file_path":path,"content":"artifact"}),
728 );
729 let mut ctx = ToolExecutionContext::none(&call.id);
730 ctx.session_id = Some(&child.id);
731 assert!(resources
732 .scope(executor.execute_with_context_outcome(&call, ctx))
733 .await
734 .unwrap_err()
735 .to_string()
736 .contains("policy denied"));
737 assert!(!path.exists());
738 gate.0.store(false, std::sync::atomic::Ordering::SeqCst);
739 let first = resources
740 .scope(executor.execute_with_context_outcome(&call, ctx))
741 .await
742 .unwrap();
743 let first: FileReply = serde_json::from_str(&first.into_tool_result().result).unwrap();
744 let second = resources
745 .scope(executor.execute_with_context_outcome(&call, ctx))
746 .await
747 .unwrap();
748 let second: FileReply = serde_json::from_str(&second.into_tool_result().result).unwrap();
749 assert_eq!(first, second);
750 assert_eq!(std::fs::read_to_string(&path).unwrap(), "artifact");
751 let changed = file_call(
752 "write",
753 "Write",
754 json!({"file_path":path,"content":"changed"}),
755 );
756 assert!(resources
757 .scope(executor.execute_with_context_outcome(&changed, ctx))
758 .await
759 .unwrap_err()
760 .to_string()
761 .contains("idempotency_conflict"));
762 let read = file_call("read", "Read", json!({"file_path":path}));
763 let mut read_ctx = ToolExecutionContext::none(&read.id);
764 read_ctx.session_id = Some(&child.id);
765 let result = resources
766 .scope(executor.execute_with_context_outcome(&read, read_ctx))
767 .await
768 .unwrap();
769 let reply: FileReply = serde_json::from_str(&result.into_tool_result().result).unwrap();
770 assert_eq!(reply.content.as_deref(), Some("artifact"));
771 assert_eq!(reply.sha256, first.sha256);
772 let outside = workspace.parent().unwrap().join("outside.txt");
773 let denied = file_call(
774 "outside",
775 "Write",
776 json!({"file_path":outside,"content":"escape"}),
777 );
778 let mut denied_ctx = ToolExecutionContext::none(&denied.id);
779 denied_ctx.session_id = Some(&child.id);
780 assert!(resources
781 .scope(executor.execute_with_context_outcome(&denied, denied_ctx))
782 .await
783 .is_err());
784 assert!(!outside.exists());
785 resources.close_and_drain().await.unwrap();
786 }
787
788 #[tokio::test]
789 async fn local_files_require_exact_resource_birth_run_and_ceiling() {
790 let (_dir, service, child, workspace) = local_file_fixture();
791 let executor = LocalFileExecutor::new(
792 Arc::new(Gate(std::sync::atomic::AtomicBool::new(false))),
793 service,
794 child.clone(),
795 "run".into(),
796 &["Task".into(), "Read".into()],
797 )
798 .unwrap();
799 let call = file_call(
800 "write",
801 "Write",
802 json!({"file_path":workspace.join("denied"),"content":"denied"}),
803 );
804 let mut ctx = ToolExecutionContext::none(&call.id);
805 ctx.session_id = Some(&child.id);
806 let resources = bamboo_tools::LocalTaskResources::new(&child.id, "run", child.created_at);
807 assert!(matches!(
808 resources
809 .scope(executor.execute_with_context_outcome(&call, ctx))
810 .await,
811 Err(ToolError::NotFound(_))
812 ));
813 let read = file_call(
814 "read",
815 "Read",
816 json!({"file_path":workspace.join("denied")}),
817 );
818 let mut ctx = ToolExecutionContext::none(&read.id);
819 ctx.session_id = Some(&child.id);
820 assert!(executor
821 .execute_with_context_outcome(&read, ctx)
822 .await
823 .unwrap_err()
824 .to_string()
825 .contains("owned execution scope"));
826 for wrong in [
827 bamboo_tools::LocalTaskResources::new(&child.id, "wrong-run", child.created_at),
828 bamboo_tools::LocalTaskResources::new(
829 &child.id,
830 "run",
831 child.created_at + chrono::Duration::seconds(1),
832 ),
833 bamboo_tools::LocalTaskResources::new("another-child", "run", child.created_at),
834 ] {
835 assert!(wrong
836 .scope(executor.execute_with_context_outcome(&read, ctx))
837 .await
838 .unwrap_err()
839 .to_string()
840 .contains("resource identity changed"));
841 wrong.close_and_drain().await.unwrap();
842 }
843 resources.close_and_drain().await.unwrap();
844 assert!(!workspace.join("denied").exists());
845 }
846
847 #[tokio::test]
848 async fn dropped_local_file_awaiter_cannot_detach_blocking_mutation() {
849 let resources = bamboo_tools::LocalTaskResources::new("child", "run", chrono::Utc::now());
850 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
851 let (finish_tx, finish_rx) = std::sync::mpsc::channel();
852 let completed = Arc::new(std::sync::atomic::AtomicBool::new(false));
853 let completion = completed.clone();
854 let owned = resources.clone();
855 let waiter = tokio::spawn(async move {
856 run_owned_file_request(owned, move || {
857 started_tx.send(()).unwrap();
858 finish_rx.recv().unwrap();
859 completion.store(true, std::sync::atomic::Ordering::SeqCst);
860 Ok(FileReply {
861 content: None,
862 sha256: "a".repeat(64),
863 artifact: None,
864 })
865 })
866 .await
867 });
868 started_rx.await.unwrap();
869 waiter.abort();
870 let _ = waiter.await;
871 let draining = resources.clone();
872 let drain = tokio::spawn(async move { draining.close_and_drain().await });
873 tokio::task::yield_now().await;
874 assert!(!drain.is_finished());
875 assert!(!completed.load(std::sync::atomic::Ordering::SeqCst));
876 finish_tx.send(()).unwrap();
877 drain.await.unwrap().unwrap();
878 assert!(completed.load(std::sync::atomic::Ordering::SeqCst));
879 }
880
881 #[test]
882 fn local_and_remote_file_schemas_are_identical_and_ceiling_bounded() {
883 let (_dir, service, child, _workspace) = local_file_fixture();
884 let tools = ["Task".into(), "Read".into(), "Write".into()];
885 let inner: Arc<dyn ToolExecutor> =
886 Arc::new(Gate(std::sync::atomic::AtomicBool::new(false)));
887 let local =
888 LocalFileExecutor::new(inner.clone(), service, child.clone(), "run".into(), &tools)
889 .unwrap();
890 let (bridge, _requests) = HostBridge::channel();
891 let remote = RemoteFileExecutor::new(inner, bridge, child.id, &tools).unwrap();
892 assert_eq!(
893 serde_json::to_value(local.list_tools()).unwrap(),
894 serde_json::to_value(remote.list_tools()).unwrap()
895 );
896 assert_eq!(local.list_tools().len(), 3);
897 assert!(!local.owns_exact_tool("Bash"));
898 assert!(local.exact_tool_owner("Read").is_some());
899 assert!(local.exact_tool_owner("Write").is_some());
900 let read = local
901 .list_tools()
902 .into_iter()
903 .find(|schema| schema.function.name == "Read")
904 .unwrap();
905 assert_eq!(read.function.parameters["additionalProperties"], false);
906 assert_eq!(read.function.parameters["required"], json!(["file_path"]));
907 }
908}