Skip to main content

bamboo_engine/ticket_worker_plan/
files.rs

1//! File execution uses the native permission gate and Ticket authority.
2//! Remote Workers use HostBridge; local tasks call the same Host endpoint in an
3//! owned blocking task. Neither route invokes ambient filesystem tools.
4use 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
19/// Read-only preflight for an explicitly prepared Git worktree. This never
20/// creates branches or workspaces, and runs outside the Ticket transaction lock.
21pub 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    // The permission gate has already run in the caller's phase scope. The
221    // registry owns this task and its actual blocking JoinHandle until the
222    // authority-backed operation returns, even if its awaiting caller drops.
223    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
387/// Same-process Ticket file executor. The immutable snapshot is supplied by
388/// the Host's exact canonical Child activation, never by tool arguments.
389/// Parsing, policy checks, schemas and idempotent raw-call identity are shared
390/// with the remote route; only its authority transport differs.
391pub 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}