1use std::collections::{HashMap, VecDeque};
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::time::Duration;
17
18use async_trait::async_trait;
19use bamboo_agent_core::{AgentError, AgentEvent, Role, Session};
20use bamboo_domain::poison::PoisonRecover;
21use bamboo_domain::SessionInboxClaim;
22use tokio::sync::mpsc;
23use tokio_util::sync::CancellationToken;
24
25use bamboo_subagent::fleet::{spawn_worker_on_bus, SpawnedChild};
26use bamboo_subagent::proto::{
27 AgentRecord, ChildFrame, LogicalSessionIdentity, ParentFrame, PermissionPolicyContext, RunSpec,
28 SessionMessageDelivery, TerminalStatus,
29};
30use bamboo_subagent::provision::{
31 ChildIdentity, ExecutorSpec, ModelRefSpec, Placement, ProvisionSpec, ScopedCredential,
32};
33use bamboo_subagent::transport::{client_config_trusting_cert, ChildClient};
34
35use crate::runtime::execution::{ExternalChildRunner, SessionInboxRuntimeBinding, SpawnJob};
36
37pub const DEFAULT_MAX_CONCURRENT_ACTORS: usize = 8;
39
40pub const MAX_SPAWN_DEPTH: u32 = 4;
46
47const DEFAULT_MAX_IDLE_PER_KEY: usize = 4;
49
50const POOLED_IDLE_TIMEOUT_SECS: u64 = 300;
53
54const WORKER_FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(60);
61
62fn active_scoped_session_deny_count(
63 config: &bamboo_tools::permission::PermissionConfig,
64 session_id: &str,
65) -> usize {
66 config
67 .temporary_grants()
68 .into_iter()
69 .filter(|grant| {
70 grant.scope == bamboo_tools::permission::TemporaryPermissionGrantScope::Session
71 && grant.effect == bamboo_tools::permission::TemporaryPermissionGrantEffect::Deny
72 && grant.session_id.as_deref() == Some(session_id)
73 })
74 .count()
75}
76
77fn ensure_no_active_scoped_session_denies(
78 config: &bamboo_tools::permission::PermissionConfig,
79 session_id: &str,
80) -> Result<(), AgentError> {
81 let count = active_scoped_session_deny_count(config, session_id);
82 if count == 0 {
83 Ok(())
84 } else {
85 Err(AgentError::LLM(format!(
86 "external executor activation blocked by {count} active session-scoped explicit deny rule(s)"
87 )))
88 }
89}
90
91pub struct IssuedCodexRunToken {
94 pub token_id: String,
95 pub token: String,
96}
97
98pub trait CodexRunTokenAuthority: Send + Sync + 'static {
101 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String>;
102 fn revoke(&self, token_id: &str);
103}
104
105struct CodexRunTokenGuard {
106 authority: Arc<dyn CodexRunTokenAuthority>,
107 token_id: String,
108}
109
110impl Drop for CodexRunTokenGuard {
111 fn drop(&mut self) {
112 self.authority.revoke(&self.token_id);
113 }
114}
115
116fn executor_uses_bamboo_codex(executor: &ExecutorSpec) -> bool {
117 matches!(
118 executor,
119 ExecutorSpec::Codex {
120 auth_mode: Some(mode),
121 ..
122 } if mode == "bamboo"
123 ) || matches!(
124 executor,
125 ExecutorSpec::Codex {
126 auth_mode: None,
127 inherit_user_config,
128 ..
129 } if !inherit_user_config.unwrap_or(false)
130 )
131}
132
133fn executor_has_read_only_permission_profile(executor: &ExecutorSpec) -> bool {
134 match executor {
135 ExecutorSpec::ClaudeCode {
136 permission_mode, ..
137 } => permission_mode
138 .as_deref()
139 .is_some_and(|mode| mode.eq_ignore_ascii_case("plan")),
140 ExecutorSpec::Codex {
141 permission_profile,
142 sandbox,
143 ..
144 } => {
145 permission_profile
146 .as_deref()
147 .is_some_and(|profile| profile.eq_ignore_ascii_case("read-only"))
148 || sandbox
149 .as_deref()
150 .is_some_and(|value| value.eq_ignore_ascii_case("read-only"))
151 }
152 _ => false,
153 }
154}
155
156fn expected_permission_executor_mapping(
164 executor: &ExecutorSpec,
165 resolution: bamboo_domain::PermissionModeResolution,
166 has_explicit_deny: bool,
167) -> Result<Option<String>, AgentError> {
168 let mapping = match executor {
169 ExecutorSpec::Echo | ExecutorSpec::CliAdapter { .. } => return Ok(None),
170 ExecutorSpec::BambooRuntime => {
171 format!("bamboo_runtime:{}", resolution.effective.as_str())
172 }
173 ExecutorSpec::ClaudeCode {
174 permission_mode, ..
175 } => {
176 if has_explicit_deny {
177 "claude_code:blocked_explicit_deny".to_string()
178 } else {
179 let mode = match resolution.effective {
180 bamboo_domain::PermissionMode::Plan => "plan",
181 bamboo_domain::PermissionMode::Auto => "bypassPermissions",
182 bamboo_domain::PermissionMode::AcceptEdits => "acceptEdits",
183 bamboo_domain::PermissionMode::DontAsk => "dontAsk",
184 bamboo_domain::PermissionMode::Default
185 | bamboo_domain::PermissionMode::BypassPermissions => {
186 permission_mode.as_deref().unwrap_or("default")
187 }
188 };
189 format!("claude_code:permission_mode={mode}")
190 }
191 }
192 ExecutorSpec::Codex {
193 mode,
194 sandbox,
195 approval_policy,
196 allow_danger_bypass,
197 ..
198 } => match mode.as_deref().unwrap_or("exec") {
199 "exec" => {
200 let approval_policy = expected_codex_exec_approval_policy(
201 sandbox.as_deref(),
202 approval_policy.as_deref(),
203 allow_danger_bypass.unwrap_or(false),
204 resolution,
205 )?;
206 if has_explicit_deny {
207 "codex_exec:blocked_explicit_deny".to_string()
208 } else {
209 format!("codex_exec:approval_policy={approval_policy}")
210 }
211 }
212 "app_server" => {
213 if !matches!(approval_policy.as_deref(), None | Some("on-request")) {
214 return Err(AgentError::LLM(
215 "invalid Codex app-server permission posture configuration".to_string(),
216 ));
217 }
218 if has_explicit_deny {
219 "codex_app_server:blocked_explicit_deny".to_string()
220 } else {
221 let approval_policy = if resolution.suppress_approval_prompts()
222 || resolution.effective == bamboo_domain::PermissionMode::Plan
223 {
224 "never"
225 } else {
226 "on-request"
227 };
228 format!("codex_app_server:approvalPolicy={approval_policy}")
229 }
230 }
231 _ => {
232 return Err(AgentError::LLM(
233 "unsupported Codex executor mode for permission posture contract".to_string(),
234 ));
235 }
236 },
237 };
238 Ok(Some(mapping))
239}
240
241fn expected_codex_exec_approval_policy(
242 sandbox: Option<&str>,
243 approval_policy: Option<&str>,
244 allow_danger_bypass: bool,
245 resolution: bamboo_domain::PermissionModeResolution,
246) -> Result<&'static str, AgentError> {
247 let configured = match approval_policy {
248 None | Some("never") => "never",
249 Some("on-failure") => "on-failure",
250 Some(_) => {
251 return Err(AgentError::LLM(
252 "invalid Codex exec permission posture configuration".to_string(),
253 ));
254 }
255 };
256 if resolution.suppress_approval_prompts()
257 || resolution.effective == bamboo_domain::PermissionMode::Plan
258 {
259 return Ok("never");
260 }
261 match sandbox {
262 Some("danger-full-access") => Ok("never"),
263 Some("read-only") | Some("workspace-write") => Ok(configured),
264 None if allow_danger_bypass || resolution.bypass_permissions() => Ok("never"),
265 None => Ok(configured),
266 Some(_) => Err(AgentError::LLM(
267 "invalid Codex exec permission posture configuration".to_string(),
268 )),
269 }
270}
271
272fn workspace_is_bamboo_owned(raw: &str) -> bool {
273 let workspace = std::fs::canonicalize(raw).unwrap_or_else(|_| PathBuf::from(raw));
274 let configured_root = bamboo_config::paths::resolve_workspace_root();
275 let configured_root = std::fs::canonicalize(&configured_root).unwrap_or(configured_root);
276 if workspace.starts_with(&configured_root) {
277 return true;
278 }
279
280 workspace.ancestors().any(|candidate| {
285 let Some(name) = candidate.file_name().and_then(|name| name.to_str()) else {
286 return false;
287 };
288 let Some(worktree_root) = candidate.parent() else {
289 return false;
290 };
291 if worktree_root.file_name() != Some(std::ffi::OsStr::new("worktree"))
292 || worktree_root.parent().and_then(Path::file_name)
293 != Some(std::ffi::OsStr::new(".bamboo"))
294 {
295 return false;
296 }
297 let marker = worktree_root.join(".bamboo-owned").join(name);
298 std::fs::read_to_string(marker).is_ok_and(|branch| branch == format!("bamboo/{name}"))
299 })
300}
301
302fn build_codex_run_secrets(
303 executor: &ExecutorSpec,
304 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
305 child_session_id: &str,
306) -> Result<
307 (
308 bamboo_subagent::proto::RunSecrets,
309 Option<CodexRunTokenGuard>,
310 ),
311 AgentError,
312> {
313 if !executor_uses_bamboo_codex(executor) {
314 return Ok((bamboo_subagent::proto::RunSecrets::default(), None));
315 }
316
317 let authority = authority.ok_or_else(|| {
318 AgentError::LLM(
319 "Codex auth mode 'bamboo' requires the server per-run token authority".to_string(),
320 )
321 })?;
322 let issued = authority
323 .issue(child_session_id)
324 .map_err(|error| AgentError::LLM(format!("mint Codex per-run provider token: {error}")))?;
325 let guard = CodexRunTokenGuard {
326 authority,
327 token_id: issued.token_id,
328 };
329 Ok((
330 bamboo_subagent::proto::RunSecrets {
331 codex_provider_token: Some(bamboo_subagent::proto::SecretValue::new(issued.token)),
332 },
333 Some(guard),
334 ))
335}
336
337struct PooledWorker {
343 worker: SpawnedChild,
344 mailbox_id: String,
346}
347
348#[derive(Debug, Clone)]
354pub struct ResolvedRemotePlacement {
355 pub endpoint: String,
356 pub token: Option<String>,
357 pub ca_cert_file: Option<PathBuf>,
358 pub host_label: Option<String>,
362}
363
364#[derive(Debug, Clone)]
371pub struct ResolvedSchedulablePlacement {
372 pub pool: String,
373 pub host_label: Option<String>,
377}
378
379enum PlacementKind {
385 Local,
386 Remote,
387 Schedulable,
388}
389
390pub struct ActorChildRunner {
392 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
393 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
394 agent_id: String,
395 worker_bin: PathBuf,
396 worker_args: Vec<String>,
397 fabric_dir: PathBuf,
398 executor: ExecutorSpec,
399 credentials: Vec<ScopedCredential>,
402 default_provider: String,
404 bus: Option<bamboo_subagent::BusEndpoint>,
407 concurrency: std::sync::Arc<tokio::sync::Semaphore>,
411 pool: Arc<tokio::sync::Mutex<HashMap<String, Vec<PooledWorker>>>>,
417 max_idle_per_key: usize,
418 approval_decider: Option<Arc<dyn ChildApprovalDecider>>,
422 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
426 escalation_bridge: Arc<std::sync::Mutex<Option<bamboo_subagent::executor::HostBridge>>>,
437 remote_placements: HashMap<String, ResolvedRemotePlacement>,
443 schedulable_placements: HashMap<String, ResolvedSchedulablePlacement>,
451 schedule_cursor: Arc<std::sync::Mutex<HashMap<String, usize>>>,
456 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
458 session_inbox_runtime: Arc<std::sync::Mutex<Option<SessionInboxRuntimeBinding>>>,
461}
462
463#[async_trait]
473pub trait ChildApprovalDecider: Send + Sync {
474 async fn decide(&self, child_session_id: &str, request: &serde_json::Value) -> bool;
477}
478
479async fn decide_child_approval(
482 decider: Option<&Arc<dyn ChildApprovalDecider>>,
483 child_session_id: &str,
484 request: &serde_json::Value,
485) -> bool {
486 match decider {
487 Some(decider) => decider.decide(child_session_id, request).await,
488 None => false,
489 }
490}
491
492const CHILD_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
496
497#[async_trait]
506pub trait ChildApprovalReviewer: Send + Sync {
507 async fn review(
510 &self,
511 parent_session_id: &str,
512 child_session_id: &str,
513 request: &serde_json::Value,
514 ) -> bool;
515}
516
517fn child_approval_reviewer_slot() -> &'static std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> {
518 static SLOT: std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> = std::sync::OnceLock::new();
519 &SLOT
520}
521
522pub fn set_child_approval_reviewer(reviewer: Arc<dyn ChildApprovalReviewer>) {
524 let _ = child_approval_reviewer_slot().set(reviewer);
525}
526
527pub fn child_approval_reviewer() -> Option<Arc<dyn ChildApprovalReviewer>> {
529 child_approval_reviewer_slot().get().cloned()
530}
531
532impl ActorChildRunner {
533 #[allow(clippy::too_many_arguments)]
534 pub fn new(
535 agent_id: String,
536 worker_bin: PathBuf,
537 worker_args: Vec<String>,
538 fabric_dir: PathBuf,
539 executor: ExecutorSpec,
540 credentials: Vec<ScopedCredential>,
541 default_provider: String,
542 max_concurrent: usize,
543 ) -> Self {
544 Self {
545 approval_registry: None,
546 permission_config: None,
547 agent_id,
548 worker_bin,
549 worker_args,
550 fabric_dir,
551 executor,
552 credentials,
553 default_provider,
554 bus: None,
555 concurrency: std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1))),
556 pool: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
557 max_idle_per_key: DEFAULT_MAX_IDLE_PER_KEY,
558 approval_decider: None,
559 approval_reviewer: None,
560 escalation_bridge: Arc::new(std::sync::Mutex::new(None)),
561 remote_placements: HashMap::new(),
562 schedulable_placements: HashMap::new(),
563 schedule_cursor: Arc::new(std::sync::Mutex::new(HashMap::new())),
564 codex_run_tokens: None,
565 session_inbox_runtime: Arc::new(std::sync::Mutex::new(None)),
566 }
567 }
568
569 pub fn with_approval_registry(
570 mut self,
571 registry: super::approval_registry::SharedApprovalRegistry,
572 ) -> Self {
573 self.approval_registry = Some(registry);
574 self
575 }
576
577 pub fn with_permission_config(
578 mut self,
579 config: Arc<bamboo_tools::permission::PermissionConfig>,
580 ) -> Self {
581 self.permission_config = Some(config);
582 self
583 }
584
585 pub fn with_bus(mut self, bus: Option<bamboo_subagent::BusEndpoint>) -> Self {
590 self.bus = bus.filter(|b| !b.endpoint.trim().is_empty());
591 self
592 }
593
594 pub fn with_approval_decider(mut self, decider: Arc<dyn ChildApprovalDecider>) -> Self {
597 self.approval_decider = Some(decider);
598 self
599 }
600
601 pub fn with_approval_reviewer(mut self, reviewer: Arc<dyn ChildApprovalReviewer>) -> Self {
602 self.approval_reviewer = Some(reviewer);
603 self
604 }
605
606 pub fn with_codex_run_tokens(
607 mut self,
608 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
609 ) -> Self {
610 self.codex_run_tokens = authority;
611 self
612 }
613
614 pub fn with_remote_placements(
620 mut self,
621 placements: HashMap<String, ResolvedRemotePlacement>,
622 ) -> Self {
623 self.remote_placements = placements;
624 self
625 }
626
627 pub fn with_schedulable_placements(
633 mut self,
634 placements: HashMap<String, ResolvedSchedulablePlacement>,
635 ) -> Self {
636 self.schedulable_placements = placements;
637 self
638 }
639
640 fn fingerprint(spec: &ProvisionSpec) -> String {
658 let role = spec.identity.role.as_str();
659 let (provider, model) = spec
660 .model
661 .as_ref()
662 .map(|m| (m.provider.as_str(), m.model.as_str()))
663 .unwrap_or(("", ""));
664 let workspace = spec.workspace.as_deref().unwrap_or("");
665 let mut tools = spec.disabled_tools.clone().unwrap_or_default();
666 tools.sort();
667 let caps = &spec.capabilities;
668 let executor = serde_json::to_string(&spec.executor).unwrap_or_default();
671 format!(
672 "{role}\u{1}{provider}\u{1}{model}\u{1}{workspace}\u{1}{}\u{1}d={}\u{1}ns={}\u{1}pr={}\u{1}pe={}\u{1}by={}\u{1}auto={}\u{1}ep={}\u{1}md={}\u{1}nha={}\u{1}gro={}\u{1}executor={executor}",
673 tools.join(","),
674 spec.identity.depth,
675 caps.nested_spawn,
676 caps.permission_requested_mode,
677 caps.permission_effective_mode,
678 caps.bypass,
679 caps.auto_approve_permissions,
680 caps.enforce_permissions,
681 caps.max_spawn_depth.unwrap_or(0),
682 caps.no_human_approver,
689 caps.guardian_read_only,
693 )
694 }
695
696 async fn acquire_bus_worker(
702 &self,
703 key: &str,
704 spec: &ProvisionSpec,
705 ) -> crate::runtime::runner::Result<PooledWorker> {
706 loop {
709 let candidate = {
710 let mut pool = self.pool.lock().await;
711 pool.get_mut(key).and_then(|bucket| bucket.pop())
712 };
713 let Some(mut candidate) = candidate else {
714 break;
715 };
716 if candidate.worker.is_alive() {
717 return Ok(candidate);
718 }
719 candidate.worker.kill().await;
720 }
721
722 let spawned = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, spec)
723 .await
724 .map_err(|e| AgentError::LLM(format!("actor spawn (bus) failed: {e}")))?;
725 let mailbox_id = spawned.record.agent_id.clone();
726 Ok(PooledWorker {
727 worker: spawned,
728 mailbox_id,
729 })
730 }
731
732 async fn release_bus_worker(&self, key: &str, mut worker: PooledWorker) {
736 if !worker.worker.is_alive() {
737 worker.worker.kill().await;
738 return;
739 }
740 let mut pool = self.pool.lock().await;
741 let bucket = pool.entry(key.to_string()).or_default();
742 if bucket.len() >= self.max_idle_per_key {
743 drop(pool);
744 worker.worker.kill().await;
745 return;
746 }
747 bucket.push(worker);
748 }
749
750 fn build_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
752 let mut spec = ProvisionSpec::new(
753 ChildIdentity {
754 child_id: job.child_session_id.clone(),
755 parent_id: Some(job.parent_session_id.clone()),
756 project_key: None,
757 role: session
758 .metadata
759 .get("subagent_type")
760 .cloned()
761 .unwrap_or_else(|| "worker".to_string()),
762 depth: session.spawn_depth,
767 },
768 self.executor.clone(),
769 self.fabric_dir.to_string_lossy().into_owned(),
770 );
771 spec.workspace = session.workspace.clone();
772 if let ExecutorSpec::Codex {
773 workspace_owned, ..
774 } = &mut spec.executor
775 {
776 *workspace_owned = Some(
777 spec.workspace
778 .as_deref()
779 .is_some_and(workspace_is_bamboo_owned),
780 );
781 }
782 spec.bus = self.bus.clone();
785 spec.model = session
788 .model_ref
789 .as_ref()
790 .map(|r| ModelRefSpec {
791 provider: r.provider.clone(),
792 model: r.model.clone(),
793 })
794 .or_else(|| {
795 let m = job.model.trim();
796 (!m.is_empty()).then(|| ModelRefSpec {
797 provider: self.default_provider.clone(),
798 model: m.to_string(),
799 })
800 });
801 spec.disabled_tools = job.disabled_tools.clone();
802 match &spec.executor {
803 ExecutorSpec::Codex {
809 auth_mode,
810 provider_key_ref,
811 ..
812 } => {
813 if auth_mode.as_deref() == Some("custom") {
814 if let Some(reference) = provider_key_ref {
815 if let Some(credential) = self.credentials.iter().find(|credential| {
816 credential.credential_ref.as_deref() == Some(reference)
817 }) {
818 spec.secrets.provider_credentials.push(credential.clone());
819 } else {
820 tracing::warn!(
821 "actor child {}: custom Codex credential reference '{}' did not resolve",
822 job.child_session_id,
823 reference
824 );
825 }
826 } else {
827 tracing::warn!(
828 "actor child {}: custom Codex executor has no credential reference",
829 job.child_session_id
830 );
831 }
832 }
833 }
834 _ => {
837 let provider = spec
838 .model
839 .as_ref()
840 .map(|model| model.provider.as_str())
841 .filter(|provider| !provider.trim().is_empty())
842 .unwrap_or(&self.default_provider);
843 if let Some(credential) = self
844 .credentials
845 .iter()
846 .find(|credential| credential.provider == provider)
847 {
848 spec.secrets.provider_credentials.push(credential.clone());
849 } else {
850 tracing::warn!(
851 "actor child {}: no credential found for provider '{}'",
852 job.child_session_id,
853 provider
854 );
855 }
856 }
857 }
858 spec.capabilities.nested_spawn = session.spawn_depth < MAX_SPAWN_DEPTH;
865 spec.capabilities.max_spawn_depth = Some(MAX_SPAWN_DEPTH);
866 spec.capabilities.enforce_permissions = true;
872 let requested_permission_mode = session
877 .agent_runtime_state
878 .as_ref()
879 .map(|state| state.effective_permission_mode())
880 .unwrap_or_default();
881 let configured_permission_mode = self
882 .permission_config
883 .as_ref()
884 .map(|config| config.mode())
885 .unwrap_or_default();
886 spec.capabilities.no_human_approver = session
891 .agent_runtime_state
892 .as_ref()
893 .is_some_and(|s| s.no_human_approver);
894 spec.capabilities.guardian_read_only =
904 session.metadata.get("subagent_type").map(String::as_str) == Some("guardian");
905 if spec.capabilities.guardian_read_only {
906 if let ExecutorSpec::Codex {
907 permission_profile, ..
908 } = &mut spec.executor
909 {
910 *permission_profile = Some("read-only".to_string());
911 }
912 }
913 let read_only_overlay = session
914 .agent_runtime_state
915 .as_ref()
916 .is_some_and(|state| state.plan_mode.is_some())
917 || spec.capabilities.guardian_read_only
918 || executor_has_read_only_permission_profile(&spec.executor);
919 let permission_resolution = bamboo_domain::resolve_permission_mode_with_read_only(
920 requested_permission_mode,
921 configured_permission_mode,
922 read_only_overlay,
923 );
924 spec.capabilities.bypass = permission_resolution.bypass_permissions();
925 spec.capabilities.auto_approve_permissions =
926 permission_resolution.suppress_approval_prompts();
927 spec.capabilities.permission_requested_mode =
928 permission_resolution.requested.as_str().to_string();
929 spec.capabilities.permission_effective_mode =
930 permission_resolution.effective.as_str().to_string();
931 if let Some(placement) = self.remote_placements.get(spec.identity.role.as_str()) {
938 spec.placement = Placement::Remote {
939 endpoint: placement.endpoint.clone(),
940 };
941 spec.secrets.worker_auth_token = placement.token.clone();
942 } else if let Some(placement) = self.schedulable_placements.get(spec.identity.role.as_str())
943 {
944 spec.placement = Placement::Schedulable {
952 pool: placement.pool.clone(),
953 };
954 }
955 spec
956 }
957
958 fn placement_stamp_for(&self, spec: &ProvisionSpec) -> Option<String> {
964 let host_label = match &spec.placement {
965 Placement::Remote { .. } => self
966 .remote_placements
967 .get(spec.identity.role.as_str())
968 .and_then(|p| p.host_label.as_deref()),
969 Placement::Schedulable { .. } => self
970 .schedulable_placements
971 .get(spec.identity.role.as_str())
972 .and_then(|p| p.host_label.as_deref()),
973 Placement::Local => None,
974 };
975 placement_metadata(&spec.placement, host_label)
976 }
977
978 async fn resolve_schedulable_worker(
985 &self,
986 role: &str,
987 ) -> std::result::Result<String, AgentError> {
988 let pool = self
989 .schedulable_placements
990 .get(role)
991 .ok_or_else(|| {
992 AgentError::LLM(format!(
993 "schedulable placement for role '{role}' vanished before scheduling"
994 ))
995 })?
996 .pool
997 .clone();
998 let bus = self.bus.as_ref().ok_or_else(|| {
999 AgentError::LLM(format!(
1000 "schedulable role '{role}': no mailbox bus configured (subagents.broker)"
1001 ))
1002 })?;
1003
1004 let mut q = bamboo_broker::BrokerClient::connect(
1007 &bus.endpoint,
1008 bamboo_subagent::AgentRef {
1009 session_id: format!("sched-q-{role}"),
1010 role: None,
1011 },
1012 &bus.token,
1013 )
1014 .await
1015 .map_err(|e| {
1016 AgentError::LLM(format!(
1017 "schedulable role '{role}': bus connect failed: {e}"
1018 ))
1019 })?;
1020 let candidates = q.list_connected(&pool).await.map_err(|e| {
1021 AgentError::LLM(format!(
1022 "schedulable role '{role}': bus presence query failed: {e}"
1023 ))
1024 })?;
1025
1026 if candidates.is_empty() {
1027 return Err(AgentError::LLM(format!(
1028 "schedulable role '{role}': no live worker in pool '{pool}' on the bus \
1029 (NOT spawning a local subprocess — a schedulable role has no local fallback)"
1030 )));
1031 }
1032
1033 let idx = {
1038 let mut cursors = self.schedule_cursor.lock().recover_poison();
1039 let cursor = cursors.entry(pool.clone()).or_insert(0);
1040 let i = *cursor % candidates.len();
1041 *cursor = cursor.wrapping_add(1);
1042 i
1043 };
1044 Ok(candidates[idx].clone())
1045 }
1046}
1047
1048#[async_trait]
1049impl ExternalChildRunner for ActorChildRunner {
1050 async fn should_handle(&self, session: &Session) -> bool {
1051 session.metadata.get("runtime.kind") == Some(&"external".to_string())
1052 && session.metadata.get("external.protocol") == Some(&"actor".to_string())
1053 && session.metadata.get("external.agent_id") == Some(&self.agent_id)
1054 }
1055
1056 fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
1057 *self.escalation_bridge.lock().recover_poison() = bridge;
1058 }
1059
1060 fn set_session_inbox_runtime(&self, binding: Option<SessionInboxRuntimeBinding>) {
1061 *self.session_inbox_runtime.lock().recover_poison() = binding;
1062 }
1063
1064 async fn execute_external_child(
1065 &self,
1066 session: &mut Session,
1067 job: &SpawnJob,
1068 event_tx: mpsc::Sender<AgentEvent>,
1069 cancel_token: CancellationToken,
1070 ) -> crate::runtime::runner::Result<()> {
1071 let escalation = self.escalation_bridge.lock().recover_poison().clone();
1080 let session_inbox_runtime = self.session_inbox_runtime.lock().recover_poison().clone();
1081 let assignment = extract_assignment(session);
1082 let mut spec = self.build_spec(session, job);
1083 spec.reusable = true;
1086 if spec.limits.idle_timeout_secs.is_none() {
1087 spec.limits.idle_timeout_secs = Some(POOLED_IDLE_TIMEOUT_SECS);
1088 }
1089 let pool_key = Self::fingerprint(&spec);
1090
1091 if executor_uses_bamboo_codex(&spec.executor) && !matches!(spec.placement, Placement::Local)
1096 {
1097 return Err(AgentError::LLM(
1098 "Codex auth mode 'bamboo' requires local actor placement; use custom mode with a reachable URL for remote workers"
1099 .to_string(),
1100 ));
1101 }
1102 let project_id = project_id_for_actor_run(session)?;
1103 let requested_permission_mode = session
1104 .agent_runtime_state
1105 .as_ref()
1106 .map(|state| state.effective_permission_mode())
1107 .unwrap_or_default();
1108 let permission_policy = if let Some(config) = self.permission_config.as_ref() {
1113 ensure_no_active_scoped_session_denies(config, &session.id)?;
1114 let read_only_overlay = session
1115 .agent_runtime_state
1116 .as_ref()
1117 .is_some_and(|state| state.plan_mode.is_some())
1118 || spec.capabilities.guardian_read_only
1119 || executor_has_read_only_permission_profile(&spec.executor);
1120 let resolution = bamboo_domain::resolve_permission_mode_with_read_only(
1121 requested_permission_mode,
1122 config.mode(),
1123 read_only_overlay,
1124 );
1125 let policy = serde_json::to_value(config.to_serializable()).map_err(|error| {
1126 AgentError::LLM(format!(
1127 "failed to serialize permission policy for external executor: {error}"
1128 ))
1129 })?;
1130 Some(PermissionPolicyContext {
1131 revision: config.policy_revision(),
1132 requested_mode: resolution.requested.as_str().to_string(),
1133 effective_mode: resolution.effective.as_str().to_string(),
1134 bypass_permissions: resolution.bypass_permissions(),
1135 auto_approve_permissions: resolution.suppress_approval_prompts(),
1136 session_id: session.id.clone(),
1137 workspace_path: session.workspace.clone(),
1138 inherit_session_grants: false,
1139 policy,
1140 })
1141 } else {
1142 None
1143 };
1144 let provisioned_permission =
1145 spec.capabilities.permission_resolution().map_err(|error| {
1146 AgentError::LLM(format!("invalid provisioned permission posture: {error}"))
1147 })?;
1148 let policy_resolution = permission_policy
1149 .as_ref()
1150 .map(|context| {
1151 context.resolved_modes().map(|(requested, effective)| {
1152 bamboo_domain::PermissionModeResolution {
1153 requested,
1154 effective,
1155 }
1156 })
1157 })
1158 .transpose()
1159 .map_err(|error| {
1160 AgentError::LLM(format!("invalid host permission posture: {error}"))
1161 })?;
1162 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata);
1163 let has_explicit_deny = self.permission_config.as_ref().is_some_and(|config| {
1164 bamboo_tools::permission::explicit_deny_policy_reason(&config.to_serializable())
1165 .is_some()
1166 });
1167 let expected_executor_mapping = expected_permission_executor_mapping(
1168 &spec.executor,
1169 policy_resolution.unwrap_or(provisioned_permission),
1170 has_explicit_deny,
1171 )?;
1172 let expected_permission_posture =
1173 expected_executor_mapping.map(|executor_mapping| ExpectedPermissionPosture {
1174 policy_revision: permission_policy
1175 .as_ref()
1176 .map(|context| context.revision)
1177 .or_else(|| host_audit.as_ref().map(|audit| audit.policy_revision))
1178 .unwrap_or_default(),
1179 resolution: policy_resolution.unwrap_or(provisioned_permission),
1180 expected_audit_revision: host_audit.as_ref().map(|audit| audit.audit_revision),
1181 executor_mapping,
1182 });
1183
1184 let _slot = self
1189 .concurrency
1190 .acquire()
1191 .await
1192 .map_err(|_| AgentError::LLM("actor concurrency limiter closed".to_string()))?;
1193
1194 let (run_secrets, _codex_token_guard) = build_codex_run_secrets(
1201 &spec.executor,
1202 self.codex_run_tokens.clone(),
1203 &job.child_session_id,
1204 )?;
1205
1206 let kind = match spec.placement {
1214 Placement::Remote { .. } => PlacementKind::Remote,
1215 Placement::Schedulable { .. } => PlacementKind::Schedulable,
1216 Placement::Local => PlacementKind::Local,
1217 };
1218 let remote = !matches!(kind, PlacementKind::Local);
1219
1220 if let Some(placement_meta) = self.placement_stamp_for(&spec) {
1226 session
1227 .metadata
1228 .insert("placement".to_string(), placement_meta);
1229 }
1230
1231 let mut attempt = 0u8;
1238 let (result, actor) = loop {
1239 let (actor, mut client) = match kind {
1240 PlacementKind::Remote => {
1241 let placement = self
1245 .remote_placements
1246 .get(spec.identity.role.as_str())
1247 .ok_or_else(|| {
1248 AgentError::LLM(format!(
1249 "remote placement for role '{}' vanished before connect",
1250 spec.identity.role
1251 ))
1252 })?;
1253 let endpoint = placement.endpoint.clone();
1254 let trust_cfg = match placement.ca_cert_file.as_deref() {
1257 Some(path) => Some(client_config_trusting_cert(path).map_err(|e| {
1258 AgentError::LLM(format!(
1259 "remote worker CA cert '{}': {e}",
1260 path.display()
1261 ))
1262 })?),
1263 None => None,
1264 };
1265 let client = ChildClient::connect_with_auth_tls(
1266 &endpoint,
1267 placement.token.as_deref(),
1268 trust_cfg,
1269 )
1270 .await
1271 .map_err(|e| {
1272 AgentError::LLM(format!("remote actor connect to '{endpoint}' failed: {e}"))
1273 })?;
1274 let record = AgentRecord {
1277 agent_id: job.child_session_id.clone(),
1278 role: spec.identity.role.clone(),
1279 labels: Vec::new(),
1280 endpoint: endpoint.clone(),
1281 pid: 0,
1282 version: String::new(),
1283 started_at: chrono::Utc::now(),
1284 lease_expires_at: chrono::Utc::now(),
1285 };
1286 let _ = endpoint;
1287 let actor = PooledWorker {
1288 worker: SpawnedChild::remote(record),
1289 mailbox_id: job.child_session_id.clone(),
1290 };
1291 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(client);
1292 (actor, client)
1293 }
1294 PlacementKind::Schedulable => {
1295 let bus = self.bus.as_ref().ok_or_else(|| {
1302 AgentError::LLM(
1303 "schedulable sub-agents require a mailbox bus (subagents.broker)"
1304 .to_string(),
1305 )
1306 })?;
1307 let mailbox_id = self
1308 .resolve_schedulable_worker(spec.identity.role.as_str())
1309 .await?;
1310 let parent = bamboo_subagent::AgentRef {
1311 session_id: format!("p-{}", job.child_session_id),
1312 role: None,
1313 };
1314 let link = bamboo_broker::BrokerChildLink::connect(
1315 &bus.endpoint,
1316 parent,
1317 &bus.token,
1318 mailbox_id.clone(),
1319 )
1320 .await
1321 .map_err(|e| {
1322 AgentError::LLM(format!(
1323 "schedulable link connect to '{mailbox_id}' failed: {e}"
1324 ))
1325 })?;
1326 let actor = PooledWorker {
1329 worker: SpawnedChild::remote(AgentRecord {
1330 agent_id: mailbox_id.clone(),
1331 role: spec.identity.role.clone(),
1332 labels: Vec::new(),
1333 endpoint: bus.endpoint.clone(),
1334 pid: 0,
1335 version: String::new(),
1336 started_at: chrono::Utc::now(),
1337 lease_expires_at: chrono::Utc::now(),
1338 }),
1339 mailbox_id,
1340 };
1341 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
1342 (actor, client)
1343 }
1344 PlacementKind::Local => {
1345 let bus = self.bus.as_ref().ok_or_else(|| {
1352 AgentError::LLM(
1353 "local sub-agents require a mailbox bus (subagents.broker); none is \
1354 configured and the bus could not be embedded"
1355 .to_string(),
1356 )
1357 })?;
1358 let actor = self.acquire_bus_worker(&pool_key, &spec).await?;
1359 let parent = bamboo_subagent::AgentRef {
1360 session_id: format!("p-{}", job.child_session_id),
1361 role: None,
1362 };
1363 let link = bamboo_broker::BrokerChildLink::connect(
1364 &bus.endpoint,
1365 parent,
1366 &bus.token,
1367 actor.mailbox_id.clone(),
1368 )
1369 .await
1370 .map_err(|e| {
1371 AgentError::LLM(format!("broker child link connect failed: {e}"))
1372 })?;
1373 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
1374 (actor, client)
1375 }
1376 };
1377
1378 let (delivery_tx, mut delivery_rx) = mpsc::unbounded_channel::<u64>();
1383 let bound_activation_run_id = match session_inbox_runtime.as_ref() {
1384 Some(binding) => {
1385 let run_id = binding
1386 .router
1387 .attach_delivery_sink(&job.child_session_id, delivery_tx.clone())
1388 .await;
1389 if run_id.is_none() {
1390 tracing::debug!(
1391 session_id = %job.child_session_id,
1392 "actor driver had no current SessionInbox activation owner to bind"
1393 );
1394 }
1395 run_id
1396 }
1397 None => None,
1398 };
1399 drop(delivery_tx);
1400 let initial_pairs = match (
1401 session_inbox_runtime.as_ref(),
1402 bound_activation_run_id.as_deref(),
1403 ) {
1404 (Some(binding), Some(run_id)) => {
1405 match claim_canonical_deliveries(binding, session, run_id, usize::MAX).await {
1406 Ok(deliveries) => deliveries,
1407 Err(error) => {
1408 binding
1409 .router
1410 .detach_delivery_sink(&job.child_session_id, run_id)
1411 .await;
1412 if !remote {
1413 actor.worker.kill().await;
1414 }
1415 return Err(error);
1416 }
1417 }
1418 }
1419 _ => Vec::new(),
1420 };
1421 let initial_session_messages = initial_pairs
1422 .iter()
1423 .map(|(_, delivery)| delivery.clone())
1424 .collect::<Vec<_>>();
1425 let initial_inflight_claims = initial_pairs
1426 .into_iter()
1427 .map(|(claim, _)| claim)
1428 .collect::<VecDeque<_>>();
1429 let messages = session
1432 .messages
1433 .iter()
1434 .filter_map(|message| serde_json::to_value(message).ok())
1435 .collect();
1436
1437 if let Err(e) = client
1438 .send(ParentFrame::Run(RunSpec {
1439 assignment: assignment.clone(),
1441 logical_session: Some(logical_identity_for_actor_run(session, job)),
1442 project_id: project_id.clone(),
1443 reasoning_effort: None,
1444 permission_policy: permission_policy.clone(),
1445 messages,
1446 activation_run_id: bound_activation_run_id.clone(),
1447 initial_session_messages,
1448 secrets: run_secrets.clone(),
1449 }))
1450 .await
1451 {
1452 if let (Some(binding), Some(run_id)) = (
1453 session_inbox_runtime.as_ref(),
1454 bound_activation_run_id.as_deref(),
1455 ) {
1456 binding
1457 .router
1458 .detach_delivery_sink(&job.child_session_id, run_id)
1459 .await;
1460 }
1461 if !remote {
1462 actor.worker.kill().await;
1463 }
1464 return Err(AgentError::LLM(format!("actor run dispatch failed: {e}")));
1465 }
1466
1467 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
1471 let live_guard = super::live::register(
1472 &job.child_session_id,
1473 live_tx,
1474 attempt as u32,
1475 self.approval_registry.clone(),
1476 );
1477
1478 let result = drive(ActorDriveContext {
1479 client: &mut *client,
1480 parent_session_id: &job.parent_session_id,
1481 child_session_id: &job.child_session_id,
1482 child_attempt: attempt as u32,
1483 approval_registry: self.approval_registry.as_ref(),
1484 approval_decider: self.approval_decider.as_ref(),
1485 approval_reviewer: self.approval_reviewer.as_ref(),
1486 escalation_bridge: escalation.clone(),
1487 event_tx: &event_tx,
1488 cancel_token: &cancel_token,
1489 live_rx: &mut live_rx,
1490 delivery_rx: &mut delivery_rx,
1491 logical_session: session,
1492 expected_permission_posture: expected_permission_posture.clone(),
1493 session_inbox_runtime: session_inbox_runtime.as_ref(),
1494 activation_run_id: bound_activation_run_id.as_deref(),
1495 initial_inflight_claims,
1496 first_frame_timeout: Some(WORKER_FIRST_FRAME_TIMEOUT),
1503 })
1504 .await;
1505 if let (Some(binding), Some(run_id)) = (
1506 session_inbox_runtime.as_ref(),
1507 bound_activation_run_id.as_deref(),
1508 ) {
1509 binding
1510 .router
1511 .detach_delivery_sink(&job.child_session_id, run_id)
1512 .await;
1513 }
1514 drop(live_guard);
1520 drop(client);
1523
1524 if attempt == 0 && matches!(result, Err(AgentError::WorkerUnresponsive(_))) {
1531 match kind {
1532 PlacementKind::Local => {
1533 tracing::warn!(
1534 "actor child {} got no first frame; reaping the worker and respawning once",
1535 job.child_session_id
1536 );
1537 actor.worker.kill().await;
1538 attempt += 1;
1539 continue;
1540 }
1541 PlacementKind::Schedulable => {
1542 tracing::warn!(
1543 "scheduled actor child {} got no first frame; re-selecting a pool worker",
1544 job.child_session_id
1545 );
1546 drop(actor);
1547 attempt += 1;
1548 continue;
1549 }
1550 PlacementKind::Remote => {}
1551 }
1552 }
1553 break (result, actor);
1554 };
1555
1556 if remote {
1560 drop(actor);
1561 } else {
1562 match &result {
1563 Ok(_) => self.release_bus_worker(&pool_key, actor).await,
1564 Err(_) => actor.worker.kill().await,
1565 }
1566 }
1567
1568 match result {
1572 Ok(Some(text)) => {
1573 if !text.is_empty() {
1574 session.add_message(bamboo_agent_core::Message::assistant(text, None));
1575 }
1576 Ok(())
1577 }
1578 Ok(None) => Ok(()),
1579 Err(e) => Err(e),
1580 }
1581 }
1582}
1583
1584fn placement_metadata(placement: &Placement, host_label: Option<&str>) -> Option<String> {
1590 let value = match placement {
1593 Placement::Local => return None,
1594 Placement::Remote { endpoint } => serde_json::json!({
1595 "kind": "remote",
1596 "host": host_label.map(str::to_string).unwrap_or_else(|| host_of_endpoint(endpoint)),
1597 }),
1598 Placement::Schedulable { pool } => serde_json::json!({
1599 "kind": "remote",
1600 "host": host_label.unwrap_or(pool),
1601 }),
1602 };
1603 serde_json::to_string(&value).ok()
1604}
1605
1606fn host_of_endpoint(endpoint: &str) -> String {
1608 endpoint
1609 .trim()
1610 .trim_start_matches("wss://")
1611 .trim_start_matches("ws://")
1612 .split(['/', ':'])
1613 .next()
1614 .unwrap_or(endpoint)
1615 .to_string()
1616}
1617
1618async fn reconcile_already_admitted_claim(
1619 binding: &SessionInboxRuntimeBinding,
1620 session: &mut Session,
1621 claim: &SessionInboxClaim,
1622) -> crate::runtime::runner::Result<()> {
1623 let latest = binding
1624 .storage
1625 .load_session(&session.id)
1626 .await
1627 .map_err(|error| {
1628 AgentError::LLM(format!(
1629 "load canonical SessionInbox checkpoint for {}: {error}",
1630 session.id
1631 ))
1632 })?
1633 .ok_or_else(|| {
1634 AgentError::LLM(format!(
1635 "canonical SessionInbox target disappeared: {}",
1636 session.id
1637 ))
1638 })?;
1639 let Some(message) = latest
1640 .messages
1641 .iter()
1642 .find(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
1643 .cloned()
1644 else {
1645 return Err(AgentError::LLM(format!(
1646 "canonical admitted receipt for {} exists without transcript message {}",
1647 session.id, claim.envelope.id
1648 )));
1649 };
1650 if let Some(existing) = session
1651 .messages
1652 .iter_mut()
1653 .find(|existing| existing.id == message.id)
1654 {
1655 *existing = message;
1656 } else {
1657 session.add_message(message);
1658 }
1659 bamboo_domain::merge_session_inbox_admission(session, &latest);
1660 binding
1661 .inbox
1662 .ack(&session.id, claim)
1663 .await
1664 .map_err(|error| {
1665 AgentError::LLM(format!(
1666 "ack recovered canonical SessionInbox claim {}: {error}",
1667 claim.envelope.id
1668 ))
1669 })
1670}
1671
1672async fn checkpoint_and_ack_canonical_claim(
1676 binding: &SessionInboxRuntimeBinding,
1677 session: &mut Session,
1678 claim: &SessionInboxClaim,
1679) -> crate::runtime::runner::Result<()> {
1680 if claim.envelope.target_session_id != session.id {
1681 return Err(AgentError::LLM(format!(
1682 "canonical SessionInbox claim target {} does not match active logical session {}",
1683 claim.envelope.target_session_id, session.id
1684 )));
1685 }
1686 if binding
1687 .inbox
1688 .was_admitted(&session.id, &claim.envelope.id)
1689 .await
1690 .map_err(|error| {
1691 AgentError::LLM(format!(
1692 "inspect canonical admitted receipt {}: {error}",
1693 claim.envelope.id
1694 ))
1695 })?
1696 {
1697 return reconcile_already_admitted_claim(binding, session, claim).await;
1698 }
1699
1700 let transcript_has_id = session
1701 .messages
1702 .iter()
1703 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
1704 if session
1705 .messages
1706 .iter()
1707 .any(|message| message.id == claim.envelope.id.as_str())
1708 && !transcript_has_id
1709 {
1710 return Err(AgentError::LLM(format!(
1711 "canonical SessionInbox id {} collides with a non-matching transcript message",
1712 claim.envelope.id
1713 )));
1714 }
1715 let cursor_has_id = session
1716 .session_inbox_admission()
1717 .is_some_and(|state| state.contains(&claim.envelope.id));
1718 if cursor_has_id && !transcript_has_id {
1719 return Err(AgentError::LLM(format!(
1720 "canonical SessionInbox cursor exists without transcript message {}",
1721 claim.envelope.id
1722 )));
1723 }
1724 let before = session.clone();
1725 if !transcript_has_id {
1726 let message = claim.envelope.to_provider_message().map_err(|error| {
1727 AgentError::LLM(format!(
1728 "translate canonical SessionInbox envelope {}: {error}",
1729 claim.envelope.id
1730 ))
1731 })?;
1732 session.add_message(message);
1733 }
1734 session
1735 .session_inbox_admission_mut()
1736 .record(claim.envelope.id.clone(), claim.generation);
1737 session.updated_at = chrono::Utc::now();
1738
1739 if let Err(error) = binding
1740 .persistence
1741 .checkpoint_runtime_session(session)
1742 .await
1743 {
1744 *session = before;
1745 return Err(AgentError::LLM(format!(
1746 "checkpoint canonical SessionInbox claim {}: {error}",
1747 claim.envelope.id
1748 )));
1749 }
1750 if !session
1751 .messages
1752 .iter()
1753 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
1754 {
1755 *session = before;
1756 return Err(AgentError::LLM(format!(
1757 "canonical SessionInbox checkpoint lost typed transcript proof for {}",
1758 claim.envelope.id
1759 )));
1760 }
1761 binding
1762 .inbox
1763 .ack(&session.id, claim)
1764 .await
1765 .map_err(|error| {
1766 AgentError::LLM(format!(
1767 "ack canonical SessionInbox claim {} after checkpoint: {error}",
1768 claim.envelope.id
1769 ))
1770 })
1771}
1772
1773async fn checkpoint_claim_context_before_dispatch(
1784 binding: &SessionInboxRuntimeBinding,
1785 session: &mut Session,
1786 claims: &[SessionInboxClaim],
1787) -> crate::runtime::runner::Result<()> {
1788 if claims.is_empty() {
1789 return Ok(());
1790 }
1791 let before = session.clone();
1792 for claim in claims {
1793 if claim.envelope.target_session_id != session.id {
1794 return Err(AgentError::LLM(format!(
1795 "canonical SessionInbox claim target {} does not match actor session {}",
1796 claim.envelope.target_session_id, session.id
1797 )));
1798 }
1799 let matching = session
1800 .messages
1801 .iter()
1802 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
1803 if session
1804 .messages
1805 .iter()
1806 .any(|message| message.id == claim.envelope.id.as_str())
1807 && !matching
1808 {
1809 return Err(AgentError::LLM(format!(
1810 "canonical SessionInbox id {} collides before actor dispatch",
1811 claim.envelope.id
1812 )));
1813 }
1814 if session
1815 .session_inbox_admission()
1816 .is_some_and(|state| state.contains(&claim.envelope.id))
1817 && !matching
1818 {
1819 return Err(AgentError::LLM(format!(
1820 "canonical SessionInbox cursor exists without transcript proof for {}",
1821 claim.envelope.id
1822 )));
1823 }
1824 if !matching {
1825 let message = claim.envelope.to_provider_message().map_err(|error| {
1826 AgentError::LLM(format!(
1827 "translate canonical SessionInbox envelope {} before actor dispatch: {error}",
1828 claim.envelope.id
1829 ))
1830 })?;
1831 session.add_message(message);
1832 }
1833 }
1834 session.updated_at = chrono::Utc::now();
1835 if let Err(error) = binding
1836 .persistence
1837 .checkpoint_runtime_session(session)
1838 .await
1839 {
1840 *session = before;
1841 return Err(AgentError::LLM(format!(
1842 "checkpoint canonical SessionInbox actor context: {error}"
1843 )));
1844 }
1845 for claim in claims {
1846 if !session
1847 .messages
1848 .iter()
1849 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
1850 {
1851 *session = before;
1852 return Err(AgentError::LLM(format!(
1853 "actor context checkpoint lost typed transcript proof for {}",
1854 claim.envelope.id
1855 )));
1856 }
1857 }
1858 Ok(())
1859}
1860
1861async fn claim_canonical_deliveries(
1862 binding: &SessionInboxRuntimeBinding,
1863 session: &mut Session,
1864 activation_run_id: &str,
1865 limit: usize,
1866) -> crate::runtime::runner::Result<Vec<(SessionInboxClaim, SessionMessageDelivery)>> {
1867 let claims = binding
1868 .inbox
1869 .claim(&session.id, limit)
1870 .await
1871 .map_err(|error| {
1872 AgentError::LLM(format!(
1873 "claim canonical SessionInbox for active actor {}: {error}",
1874 session.id
1875 ))
1876 })?;
1877 if claims.is_empty() {
1878 return Ok(Vec::new());
1879 }
1880 let interrupt_generation = binding
1881 .inbox
1882 .inspect(&session.id)
1883 .await
1884 .map_err(|error| {
1885 AgentError::LLM(format!(
1886 "inspect canonical SessionInbox activation policy for {}: {error}",
1887 session.id
1888 ))
1889 })?
1890 .interrupt_generation;
1891 let mut unconfirmed = Vec::with_capacity(claims.len());
1892 for claim in claims {
1893 if binding
1894 .inbox
1895 .was_admitted(&session.id, &claim.envelope.id)
1896 .await
1897 .map_err(|error| {
1898 AgentError::LLM(format!(
1899 "inspect canonical SessionInbox claim {}: {error}",
1900 claim.envelope.id
1901 ))
1902 })?
1903 {
1904 reconcile_already_admitted_claim(binding, session, &claim).await?;
1905 continue;
1906 }
1907 unconfirmed.push(claim);
1908 }
1909 checkpoint_claim_context_before_dispatch(binding, session, &unconfirmed).await?;
1910
1911 let mut deliveries = Vec::with_capacity(unconfirmed.len());
1912 for claim in unconfirmed {
1913 if session
1918 .session_inbox_admission()
1919 .is_some_and(|state| state.contains(&claim.envelope.id))
1920 {
1921 binding
1922 .inbox
1923 .ack(&session.id, &claim)
1924 .await
1925 .map_err(|error| {
1926 AgentError::LLM(format!(
1927 "finish confirmed canonical SessionInbox ack {}: {error}",
1928 claim.envelope.id
1929 ))
1930 })?;
1931 continue;
1932 }
1933 let activation_policy = if claim.generation <= interrupt_generation {
1934 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait
1935 } else {
1936 bamboo_domain::SessionActivationPolicy::RespectSpecificWait
1937 };
1938 let delivery = SessionMessageDelivery {
1939 target_session_id: session.id.clone(),
1940 envelope: claim.envelope.clone(),
1941 canonical_claim_generation: claim.generation,
1942 activation_run_id: activation_run_id.to_string(),
1943 activation_policy,
1944 };
1945 deliveries.push((claim, delivery));
1946 }
1947 Ok(deliveries)
1948}
1949
1950async fn forward_next_canonical_claim(
1951 client: &mut dyn bamboo_subagent::ChildLink,
1952 binding: &SessionInboxRuntimeBinding,
1953 session: &mut Session,
1954 activation_run_id: &str,
1955 inflight: &mut VecDeque<SessionInboxClaim>,
1956) -> crate::runtime::runner::Result<()> {
1957 if !inflight.is_empty() {
1958 return Ok(());
1959 }
1960 let Some((claim, delivery)) =
1961 claim_canonical_deliveries(binding, session, activation_run_id, 1)
1962 .await?
1963 .pop()
1964 else {
1965 return Ok(());
1966 };
1967 client
1968 .send(ParentFrame::SessionMessage { delivery })
1969 .await
1970 .map_err(|error| {
1971 AgentError::LLM(format!(
1972 "forward canonical SessionInbox claim {} to active actor: {error}",
1973 claim.envelope.id
1974 ))
1975 })?;
1976 inflight.push_back(claim);
1977 Ok(())
1978}
1979
1980struct ActorDriveContext<'a> {
1982 client: &'a mut dyn bamboo_subagent::ChildLink,
1983 parent_session_id: &'a str,
1984 child_session_id: &'a str,
1985 child_attempt: u32,
1986 approval_registry: Option<&'a super::approval_registry::SharedApprovalRegistry>,
1987 approval_decider: Option<&'a Arc<dyn ChildApprovalDecider>>,
1988 approval_reviewer: Option<&'a Arc<dyn ChildApprovalReviewer>>,
1989 escalation_bridge: Option<bamboo_subagent::executor::HostBridge>,
1990 event_tx: &'a mpsc::Sender<AgentEvent>,
1991 cancel_token: &'a CancellationToken,
1992 live_rx: &'a mut mpsc::UnboundedReceiver<ParentFrame>,
1993 delivery_rx: &'a mut mpsc::UnboundedReceiver<u64>,
1994 logical_session: &'a mut Session,
1995 expected_permission_posture: Option<ExpectedPermissionPosture>,
1996 session_inbox_runtime: Option<&'a SessionInboxRuntimeBinding>,
1997 activation_run_id: Option<&'a str>,
1998 initial_inflight_claims: VecDeque<SessionInboxClaim>,
1999 first_frame_timeout: Option<Duration>,
2000}
2001
2002#[derive(Debug, Clone, PartialEq, Eq)]
2003struct ExpectedPermissionPosture {
2004 policy_revision: u64,
2005 resolution: bamboo_domain::PermissionModeResolution,
2006 expected_audit_revision: Option<u64>,
2007 executor_mapping: String,
2008}
2009
2010#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2011enum PermissionPostureHandshake {
2012 NotRequired,
2014 Awaiting,
2017 Confirmed,
2019}
2020
2021impl PermissionPostureHandshake {
2022 fn new(expected: Option<&ExpectedPermissionPosture>) -> Self {
2023 if expected.is_some() {
2024 Self::Awaiting
2025 } else {
2026 Self::NotRequired
2027 }
2028 }
2029
2030 fn is_awaiting(self) -> bool {
2031 self == Self::Awaiting
2032 }
2033
2034 fn posture_was_confirmed(self) -> bool {
2035 self == Self::Confirmed
2036 }
2037}
2038
2039fn permission_posture_seed_from_event(
2040 session: &Session,
2041 event: &AgentEvent,
2042) -> Result<Option<bamboo_domain::PermissionAuditSeed>, String> {
2043 let AgentEvent::PermissionPostureActivated {
2044 session_id,
2045 policy_revision,
2046 requested_mode,
2047 effective_mode,
2048 executor_mapping,
2049 } = event
2050 else {
2051 return Ok(None);
2052 };
2053 if session_id != &session.id {
2054 return Err("permission posture event targets a different logical session".to_string());
2055 }
2056 let requested = bamboo_domain::SessionPermissionMode::from_audit_str(requested_mode)
2057 .ok_or_else(|| "permission posture event has an invalid requested mode".to_string())?;
2058 let effective = bamboo_domain::PermissionMode::from_audit_str(effective_mode)
2059 .ok_or_else(|| "permission posture event has an invalid effective mode".to_string())?;
2060 let resolution = bamboo_domain::PermissionModeResolution {
2061 requested,
2062 effective,
2063 };
2064 if !resolution.is_consistent() {
2065 return Err("permission posture event has an inconsistent mode pair".to_string());
2066 }
2067 let current_requested = session
2068 .agent_runtime_state
2069 .as_ref()
2070 .map(|state| state.effective_permission_mode())
2071 .unwrap_or_default();
2072 if current_requested != requested {
2073 return Err("permission posture event is stale for the host typed mode".to_string());
2074 }
2075 let mapping_chars = executor_mapping.chars().count();
2076 if mapping_chars == 0 || mapping_chars > bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS {
2077 return Err("permission posture event has an invalid executor mapping".to_string());
2078 }
2079 Ok(Some(bamboo_domain::PermissionAuditSeed::new(
2080 *policy_revision,
2081 resolution,
2082 executor_mapping,
2083 )))
2084}
2085
2086async fn drive(context: ActorDriveContext<'_>) -> crate::runtime::runner::Result<Option<String>> {
2097 let ActorDriveContext {
2098 client,
2099 parent_session_id,
2100 child_session_id,
2101 child_attempt,
2102 approval_registry,
2103 approval_decider,
2104 approval_reviewer,
2105 escalation_bridge,
2106 event_tx,
2107 cancel_token,
2108 live_rx,
2109 delivery_rx,
2110 logical_session,
2111 expected_permission_posture,
2112 session_inbox_runtime,
2113 activation_run_id,
2114 initial_inflight_claims,
2115 first_frame_timeout,
2116 } = context;
2117
2118 let mut got_first_frame = false;
2125 let mut first_frame_watch = first_frame_timeout.map(|d| Box::pin(tokio::time::sleep(d)));
2126 let mut inflight_claims = initial_inflight_claims;
2127 let strict_permission_events = expected_permission_posture.is_some();
2128 let mut permission_handshake =
2129 PermissionPostureHandshake::new(expected_permission_posture.as_ref());
2130 loop {
2131 tokio::select! {
2132 _ = cancel_token.cancelled() => {
2133 break;
2135 }
2136 _ = async {
2137 match first_frame_watch.as_mut() {
2138 Some(s) => s.as_mut().await,
2139 None => std::future::pending::<()>().await,
2140 }
2141 }, if !got_first_frame => {
2142 return Err(AgentError::WorkerUnresponsive(format!(
2143 "child {child_session_id} produced no frame within {:?}",
2144 first_frame_timeout.unwrap_or_default()
2145 )));
2146 }
2147 Some(_generation) = delivery_rx.recv(),
2148 if session_inbox_runtime.is_some() && activation_run_id.is_some() =>
2149 {
2150 forward_next_canonical_claim(
2151 client,
2152 session_inbox_runtime.expect("guarded"),
2153 logical_session,
2154 activation_run_id.expect("guarded"),
2155 &mut inflight_claims,
2156 )
2157 .await?;
2158 }
2159 Some(frame) = live_rx.recv() => {
2160 if client.send(frame).await.is_err() {
2162 tracing::warn!("live steering frame could not be sent; connection failing");
2163 }
2164 }
2165 frame = client.next_frame() => {
2166 got_first_frame = true;
2169 first_frame_watch = None;
2170 match frame {
2171 Ok(Some(ChildFrame::Event { event })) => {
2172 let ev = match serde_json::from_value::<AgentEvent>(event) {
2174 Ok(ev) => ev,
2175 Err(error) if strict_permission_events => {
2176 return Err(AgentError::LLM(format!(
2177 "actor emitted malformed AgentEvent under a typed permission posture contract: {error}"
2178 )));
2179 }
2180 Err(_) => continue,
2181 };
2182 if matches!(&ev, AgentEvent::PermissionPostureActivated { .. }) {
2183 if permission_handshake.posture_was_confirmed() {
2184 return Err(AgentError::LLM(
2185 "actor emitted a duplicate permission posture activation"
2186 .to_string(),
2187 ));
2188 }
2189 let seed = permission_posture_seed_from_event(logical_session, &ev)
2190 .map_err(AgentError::LLM)?
2191 .ok_or_else(|| {
2192 AgentError::LLM(
2193 "actor permission posture event did not decode as a posture"
2194 .to_string(),
2195 )
2196 })?;
2197 if let Some(expected) = expected_permission_posture.as_ref() {
2198 if seed.policy_revision != expected.policy_revision
2199 || seed.resolution != expected.resolution
2200 {
2201 return Err(AgentError::LLM(
2202 "permission posture event does not match the host-dispatched policy"
2203 .to_string(),
2204 ));
2205 }
2206 if seed.executor_mapping() != expected.executor_mapping {
2207 return Err(AgentError::LLM(
2208 "permission posture event does not match the host-dispatched executor mapping"
2209 .to_string(),
2210 ));
2211 }
2212 }
2213 if let Some(binding) = session_inbox_runtime {
2214 let saved = binding
2215 .persistence
2216 .record_permission_posture_activation(
2217 &logical_session.id,
2218 expected_permission_posture
2219 .as_ref()
2220 .and_then(|expected| expected.expected_audit_revision),
2221 &seed,
2222 )
2223 .await
2224 .map_err(|error| {
2225 AgentError::LLM(format!(
2226 "persist child permission posture bootstrap: {error}"
2227 ))
2228 })?
2229 .ok_or_else(|| {
2230 AgentError::LLM(
2231 "persist child permission posture bootstrap: session not found"
2232 .to_string(),
2233 )
2234 })?;
2235 let snapshot = bamboo_domain::PermissionAuditSnapshot::from_metadata(
2236 &saved.metadata,
2237 )
2238 .ok_or_else(|| {
2239 AgentError::LLM(
2240 "persisted child permission posture audit is incomplete"
2241 .to_string(),
2242 )
2243 })?;
2244 snapshot.write_to(&mut logical_session.metadata);
2245 } else {
2246 bamboo_domain::record_permission_audit(
2249 &mut logical_session.metadata,
2250 &seed,
2251 None,
2252 )
2253 .map_err(|error| {
2254 AgentError::LLM(format!(
2255 "record in-memory child permission posture: {error}"
2256 ))
2257 })?;
2258 }
2259 permission_handshake = PermissionPostureHandshake::Confirmed;
2262 } else if permission_handshake.is_awaiting() {
2263 return Err(AgentError::LLM(
2264 "actor emitted an execution event before permission posture confirmation"
2265 .to_string(),
2266 ));
2267 }
2268 let _ = event_tx.send(ev).await;
2269 }
2270 Ok(Some(ChildFrame::ApprovalRequest { id, body })) => {
2271 if permission_handshake.is_awaiting() {
2272 return Err(AgentError::LLM(
2273 "actor requested approval before permission posture confirmation"
2274 .to_string(),
2275 ));
2276 }
2277 if let Some(reviewer) = approval_reviewer
2283 .cloned()
2284 .or_else(child_approval_reviewer)
2285 {
2286 let child = child_session_id.to_string();
2294 let parent = parent_session_id.to_string();
2295 let req_id = id.clone();
2296 let body = body.clone();
2297 let registry = approval_registry.cloned();
2298 tokio::spawn(async move {
2299 let approved = tokio::time::timeout(
2300 CHILD_APPROVAL_TIMEOUT,
2301 reviewer.review(&parent, &child, &body),
2302 )
2303 .await
2304 .unwrap_or(false);
2305 super::live::deliver_approval_scoped(
2306 registry.as_ref(),
2307 &child,
2308 child_attempt,
2309 &req_id,
2310 approved,
2311 );
2312 });
2313 } else if approval_decider.is_some() {
2314 let approved =
2318 decide_child_approval(approval_decider, child_session_id, &body)
2319 .await;
2320 if client
2321 .send(ParentFrame::ApprovalReply { id, approved })
2322 .await
2323 .is_err()
2324 {
2325 tracing::warn!(
2326 "failed to answer approval_request; connection failing"
2327 );
2328 }
2329 } else if let Some(host) = escalation_bridge.clone() {
2330 let child = child_session_id.to_string();
2337 let req_id = id.clone();
2338 let body = body.clone();
2339 let registry = approval_registry.cloned();
2340 tokio::spawn(async move {
2341 let approved = match tokio::time::timeout(
2342 CHILD_APPROVAL_TIMEOUT,
2343 host.approval_call(body),
2344 )
2345 .await
2346 {
2347 Ok(Ok(reply)) => reply
2348 .get("approved")
2349 .and_then(|v| v.as_bool())
2350 .unwrap_or(false),
2351 _ => false,
2353 };
2354 super::live::deliver_approval_scoped(
2355 registry.as_ref(),
2356 &child,
2357 child_attempt,
2358 &req_id,
2359 approved,
2360 );
2361 });
2362 } else {
2363 tracing::warn!(
2367 parent_session_id,
2368 child_session_id,
2369 request_id = %id,
2370 "forced-ask request has no parent-agent reviewer; denying"
2371 );
2372 if client
2373 .send(ParentFrame::ApprovalReply {
2374 id,
2375 approved: false,
2376 })
2377 .await
2378 .is_err()
2379 {
2380 tracing::warn!(
2381 "failed to send fail-closed approval reply; connection failing"
2382 );
2383 }
2384 }
2385 }
2386 Ok(Some(ChildFrame::SessionMessageAdmitted { confirmation })) => {
2387 let Some(binding) = session_inbox_runtime else {
2388 tracing::warn!(
2389 child_session_id,
2390 "ignoring SessionInbox confirmation without a runtime binding"
2391 );
2392 continue;
2393 };
2394 let Some(bound_run_id) = activation_run_id else {
2395 tracing::warn!(
2396 child_session_id,
2397 "ignoring SessionInbox confirmation without an activation owner"
2398 );
2399 continue;
2400 };
2401 let Some(claim) = inflight_claims.front() else {
2402 tracing::warn!(
2403 child_session_id,
2404 envelope_id = %confirmation.envelope_id,
2405 "rejecting stale SessionInbox confirmation with no in-flight canonical claim"
2406 );
2407 continue;
2408 };
2409 let exact = confirmation.target_session_id == logical_session.id
2410 && confirmation.envelope_id == claim.envelope.id.as_str()
2411 && confirmation.canonical_claim_generation == claim.generation
2412 && confirmation.activation_run_id == bound_run_id;
2413 if !exact
2414 || !binding
2415 .router
2416 .owns_run(&logical_session.id, bound_run_id)
2417 .await
2418 {
2419 tracing::warn!(
2420 child_session_id,
2421 expected_target = %logical_session.id,
2422 received_target = %confirmation.target_session_id,
2423 expected_envelope_id = %claim.envelope.id,
2424 received_envelope_id = %confirmation.envelope_id,
2425 expected_generation = claim.generation,
2426 received_generation = confirmation.canonical_claim_generation,
2427 expected_run_id = bound_run_id,
2428 received_run_id = %confirmation.activation_run_id,
2429 "rejecting stale or mismatched SessionInbox admission confirmation"
2430 );
2431 continue;
2432 }
2433 let claim = inflight_claims
2434 .pop_front()
2435 .expect("validated in-flight canonical claim");
2436 checkpoint_and_ack_canonical_claim(binding, logical_session, &claim)
2439 .await?;
2440 if inflight_claims.is_empty() {
2444 forward_next_canonical_claim(
2445 client,
2446 binding,
2447 logical_session,
2448 bound_run_id,
2449 &mut inflight_claims,
2450 )
2451 .await?;
2452 }
2453 }
2454 Ok(Some(ChildFrame::Terminal { status, result, error, .. })) => {
2455 if permission_handshake.is_awaiting() {
2456 return Err(AgentError::LLM(
2457 "actor terminated before permission posture confirmation"
2458 .to_string(),
2459 ));
2460 }
2461 if let Some(claim) = inflight_claims.front() {
2462 return Err(AgentError::LLM(format!(
2463 "actor terminated before durably admitting SessionInbox message {}; canonical claim remains recoverable",
2464 claim.envelope.id
2465 )));
2466 }
2467 return match status {
2468 TerminalStatus::Completed => Ok(result),
2469 TerminalStatus::Cancelled => Err(AgentError::Cancelled),
2470 TerminalStatus::Error => Err(AgentError::LLM(
2471 error.unwrap_or_else(|| "actor child errored".to_string()),
2472 )),
2473 TerminalStatus::Suspended => Err(AgentError::LLM(
2478 "nested sub-agent suspend received but resume transport is not wired"
2479 .to_string(),
2480 )),
2481 };
2482 }
2483 Ok(None) => {
2484 return Err(AgentError::LLM(
2485 "actor child closed before terminal".to_string(),
2486 ));
2487 }
2488 Err(e) => {
2489 return Err(AgentError::LLM(format!("actor transport error: {e}")));
2490 }
2491 }
2492 }
2493 }
2494 }
2495
2496 let _ = client.send(ParentFrame::Cancel).await;
2498 Err(AgentError::Cancelled)
2499}
2500
2501fn project_id_for_actor_run(
2503 session: &Session,
2504) -> Result<Option<bamboo_domain::ProjectId>, AgentError> {
2505 match crate::project_context::ProjectContextResolver::session_project_identity(session) {
2506 crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
2507 Ok(Some(project_id))
2508 }
2509 crate::project_context::SessionProjectIdentity::Unassigned => Ok(None),
2510 crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
2511 Err(AgentError::LLM(format!(
2512 "child session carries an invalid Project identity '{raw}': {message}"
2513 )))
2514 }
2515 }
2516}
2517
2518fn logical_identity_for_actor_run(session: &Session, job: &SpawnJob) -> LogicalSessionIdentity {
2519 LogicalSessionIdentity {
2520 session_id: session.id.clone(),
2521 parent_session_id: session
2522 .parent_session_id
2523 .clone()
2524 .or_else(|| Some(job.parent_session_id.clone())),
2525 root_session_id: if session.root_session_id.trim().is_empty() {
2526 job.parent_session_id.clone()
2527 } else {
2528 session.root_session_id.clone()
2529 },
2530 }
2531}
2532
2533fn extract_assignment(session: &Session) -> String {
2534 session
2535 .messages
2536 .iter()
2537 .rev()
2538 .find(|m| matches!(m.role, Role::User))
2539 .map(|m| m.content.clone())
2540 .unwrap_or_else(|| {
2541 session
2542 .metadata
2543 .get("title")
2544 .cloned()
2545 .unwrap_or_else(|| "Execute task".to_string())
2546 })
2547}
2548
2549#[cfg(test)]
2550mod tests {
2551 use super::*;
2552 use crate::SessionActivationRouter;
2553 use bamboo_domain::{RuntimeSessionPersistence, SessionInboxPort, Storage};
2554
2555 #[test]
2556 fn actor_preflight_counts_only_current_session_scoped_denies() {
2557 let config = bamboo_tools::permission::PermissionConfig::new();
2558 let secret_matcher = "TOP_SECRET_ACTOR_DENY_MATCHER";
2559 config.deny_scoped_session_permission(
2560 "target-session",
2561 bamboo_tools::permission::PermissionType::ExecuteCommand,
2562 secret_matcher,
2563 );
2564 config.deny_scoped_session_permission(
2565 "other-session",
2566 bamboo_tools::permission::PermissionType::WriteFile,
2567 "/other/**",
2568 );
2569
2570 assert_eq!(
2571 active_scoped_session_deny_count(&config, "target-session"),
2572 1
2573 );
2574 assert_eq!(
2575 active_scoped_session_deny_count(&config, "clean-session"),
2576 0
2577 );
2578 let error = ensure_no_active_scoped_session_denies(&config, "target-session")
2579 .unwrap_err()
2580 .to_string();
2581 assert!(!error.contains(secret_matcher));
2582 ensure_no_active_scoped_session_denies(&config, "clean-session")
2583 .expect("another session's deny must not block this activation");
2584 }
2585
2586 #[test]
2587 fn permission_posture_event_rejects_oversized_executor_mapping() {
2588 let session = Session::new("mapping-bound", "model");
2589 let event = AgentEvent::PermissionPostureActivated {
2590 session_id: session.id.clone(),
2591 policy_revision: 1,
2592 requested_mode: "default".to_string(),
2593 effective_mode: "default".to_string(),
2594 executor_mapping: "x".repeat(bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS + 1),
2595 };
2596
2597 assert!(permission_posture_seed_from_event(&session, &event)
2598 .unwrap_err()
2599 .contains("executor mapping"));
2600 }
2601
2602 #[test]
2603 fn remote_audit_revision_and_timestamp_fields_cannot_poison_host_audit() {
2604 let mut session = Session::new("host-resigns-audit", "model");
2605 let hostile_timestamp = "9".repeat(1024);
2606 let event: AgentEvent = serde_json::from_value(serde_json::json!({
2607 "type": "permission_posture_activated",
2608 "session_id": session.id.clone(),
2609 "policy_revision": 31,
2610 "requested_mode": "default",
2611 "effective_mode": "default",
2612 "executor_mapping": "codex_exec:approval_policy=never",
2613 "audit_revision": u64::MAX,
2614 "transitioned_at": hostile_timestamp,
2615 }))
2616 .expect("unknown remote audit fields are ignored by the typed event");
2617 let seed = permission_posture_seed_from_event(&session, &event)
2618 .unwrap()
2619 .expect("permission event");
2620
2621 let host_revision =
2622 bamboo_domain::record_permission_audit(&mut session.metadata, &seed, None).unwrap();
2623 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
2624 .expect("host-generated complete audit");
2625 assert_eq!(host_audit.audit_revision, host_revision);
2626 assert!(host_audit.audit_revision < bamboo_domain::MAX_PERMISSION_AUDIT_REVISION);
2627 assert_ne!(host_audit.transitioned_at, hostile_timestamp);
2628 assert!(chrono::DateTime::parse_from_rfc3339(&host_audit.transitioned_at).is_ok());
2629 }
2630
2631 struct ActorFaultingPersistence {
2632 inner: Arc<bamboo_storage::LockedSessionStore>,
2633 fail_checkpoint_once: std::sync::atomic::AtomicBool,
2634 }
2635
2636 #[async_trait]
2637 impl RuntimeSessionPersistence for ActorFaultingPersistence {
2638 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
2639 self.inner.merge_save_runtime(session).await
2640 }
2641
2642 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
2643 if self
2644 .fail_checkpoint_once
2645 .swap(false, std::sync::atomic::Ordering::SeqCst)
2646 {
2647 return Err(std::io::Error::other("injected actor checkpoint failure"));
2648 }
2649 self.inner.checkpoint_runtime_session(session).await
2650 }
2651
2652 async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
2653 self.inner.storage().load_session(session_id).await
2654 }
2655 }
2656
2657 struct ActorFailBeforeAckInbox {
2658 inner: Arc<dyn SessionInboxPort>,
2659 fail_once: std::sync::atomic::AtomicBool,
2660 }
2661
2662 #[async_trait]
2663 impl SessionInboxPort for ActorFailBeforeAckInbox {
2664 async fn deliver(
2665 &self,
2666 envelope: &bamboo_domain::SessionMessageEnvelope,
2667 ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
2668 self.inner.deliver(envelope).await
2669 }
2670
2671 async fn mark_activation_eligible(
2672 &self,
2673 target_session_id: &str,
2674 generation: u64,
2675 policy: bamboo_domain::SessionActivationPolicy,
2676 ) -> Result<(), bamboo_domain::SessionInboxError> {
2677 self.inner
2678 .mark_activation_eligible(target_session_id, generation, policy)
2679 .await
2680 }
2681
2682 async fn claim(
2683 &self,
2684 target_session_id: &str,
2685 limit: usize,
2686 ) -> Result<Vec<SessionInboxClaim>, bamboo_domain::SessionInboxError> {
2687 self.inner.claim(target_session_id, limit).await
2688 }
2689
2690 async fn was_admitted(
2691 &self,
2692 target_session_id: &str,
2693 id: &bamboo_domain::SessionMessageId,
2694 ) -> Result<bool, bamboo_domain::SessionInboxError> {
2695 self.inner.was_admitted(target_session_id, id).await
2696 }
2697
2698 async fn ack(
2699 &self,
2700 target_session_id: &str,
2701 claim: &SessionInboxClaim,
2702 ) -> Result<(), bamboo_domain::SessionInboxError> {
2703 if self
2704 .fail_once
2705 .swap(false, std::sync::atomic::Ordering::SeqCst)
2706 {
2707 return Err(bamboo_domain::SessionInboxError::Storage(
2708 "injected actor pre-ack failure".to_string(),
2709 ));
2710 }
2711 self.inner.ack(target_session_id, claim).await
2712 }
2713
2714 async fn inspect(
2715 &self,
2716 target_session_id: &str,
2717 ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
2718 self.inner.inspect(target_session_id).await
2719 }
2720 }
2721
2722 async fn actor_inbox_fixture(
2723 session_id: &str,
2724 ) -> (
2725 tempfile::TempDir,
2726 Arc<bamboo_storage::SessionStoreV2>,
2727 Arc<bamboo_storage::LockedSessionStore>,
2728 Arc<dyn SessionInboxPort>,
2729 Session,
2730 SessionInboxClaim,
2731 ) {
2732 let temp = tempfile::tempdir().unwrap();
2733 let store = Arc::new(
2734 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
2735 .await
2736 .unwrap(),
2737 );
2738 let storage: Arc<dyn Storage> = store.clone();
2739 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
2740 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
2741 store.clone(),
2742 bamboo_domain::SessionInboxLimits::default(),
2743 ));
2744 let session = Session::new(session_id, "model");
2745 store.save_session(&session).await.unwrap();
2746 let mut envelope =
2747 bamboo_domain::SessionMessageEnvelope::user_input(session_id, "actor follow-up");
2748 envelope.id =
2749 bamboo_domain::SessionMessageId::parse(format!("{session_id}-message")).unwrap();
2750 let receipt = inbox.deliver(&envelope).await.unwrap();
2751 inbox
2752 .mark_activation_eligible(
2753 session_id,
2754 receipt.generation,
2755 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
2756 )
2757 .await
2758 .unwrap();
2759 let claim = inbox.claim(session_id, 1).await.unwrap().remove(0);
2760 (temp, store, locked, inbox, session, claim)
2761 }
2762
2763 fn actor_binding(
2764 store: Arc<bamboo_storage::SessionStoreV2>,
2765 inbox: Arc<dyn SessionInboxPort>,
2766 persistence: Arc<dyn RuntimeSessionPersistence>,
2767 ) -> SessionInboxRuntimeBinding {
2768 let storage: Arc<dyn Storage> = store;
2769 SessionInboxRuntimeBinding {
2770 router: SessionActivationRouter::new(),
2771 inbox,
2772 storage,
2773 persistence,
2774 }
2775 }
2776
2777 #[tokio::test]
2778 async fn actor_mismatched_typed_marker_id_collision_never_acks() {
2779 let (_temp, store, locked, inbox, mut session, claim) =
2780 actor_inbox_fixture("actor-live-id-collision").await;
2781 let mut forged = claim.envelope.to_provider_message().unwrap();
2782 forged.metadata = Some(serde_json::json!({
2783 "session_message": {
2784 "id": claim.envelope.id,
2785 "target_session_id": "different-session"
2786 }
2787 }));
2788 session.add_message(forged);
2789 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
2790 let binding = actor_binding(store, inbox.clone(), persistence);
2791
2792 assert!(
2793 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
2794 .await
2795 .is_err()
2796 );
2797 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
2798 assert!(!inbox
2799 .was_admitted(&session.id, &claim.envelope.id)
2800 .await
2801 .unwrap());
2802 }
2803
2804 #[tokio::test]
2805 async fn actor_concurrent_durable_id_collision_after_claim_never_acks() {
2806 let (_temp, store, locked, inbox, mut session, claim) =
2807 actor_inbox_fixture("actor-durable-id-collision").await;
2808 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
2809 let mut forged = bamboo_agent_core::Message::user("concurrent actor collision");
2810 forged.id = claim.envelope.id.to_string();
2811 concurrent.add_message(forged);
2812 store.save_session(&concurrent).await.unwrap();
2813 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
2814 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
2815
2816 assert!(
2817 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
2818 .await
2819 .is_err()
2820 );
2821 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
2822 assert!(!inbox
2823 .was_admitted(&session.id, &claim.envelope.id)
2824 .await
2825 .unwrap());
2826 let durable = store.load_session(&session.id).await.unwrap().unwrap();
2827 assert!(!durable.messages.iter().any(|message| {
2828 bamboo_domain::is_matching_session_message(message, &claim.envelope)
2829 }));
2830 }
2831
2832 #[tokio::test]
2833 async fn actor_concurrent_durable_typed_body_mismatch_never_acks() {
2834 let (_temp, store, locked, inbox, mut session, claim) =
2835 actor_inbox_fixture("actor-durable-typed-body-collision").await;
2836 let mut different = claim.envelope.clone();
2837 different.body = bamboo_domain::SessionMessageBody::Content(
2838 bamboo_domain::SessionMessageContent::text("forged actor body"),
2839 );
2840 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
2841 concurrent.add_message(different.to_provider_message().unwrap());
2842 store.save_session(&concurrent).await.unwrap();
2843 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
2844 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
2845
2846 assert!(
2847 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
2848 .await
2849 .is_err()
2850 );
2851 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
2852 assert!(!inbox
2853 .was_admitted(&session.id, &claim.envelope.id)
2854 .await
2855 .unwrap());
2856 let durable = store.load_session(&session.id).await.unwrap().unwrap();
2857 assert!(!durable
2858 .messages
2859 .iter()
2860 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope)));
2861 }
2862
2863 #[tokio::test]
2864 async fn actor_checkpoint_failure_rolls_back_and_restart_admits_once() {
2865 let (_temp, store, locked, inbox, mut session, claim) =
2866 actor_inbox_fixture("actor-checkpoint-failure").await;
2867 let envelope_id = claim.envelope.id.clone();
2868 let fault: Arc<dyn RuntimeSessionPersistence> = Arc::new(ActorFaultingPersistence {
2869 inner: locked.clone(),
2870 fail_checkpoint_once: std::sync::atomic::AtomicBool::new(true),
2871 });
2872 let binding = actor_binding(store.clone(), inbox.clone(), fault);
2873
2874 assert!(
2875 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
2876 .await
2877 .is_err()
2878 );
2879 assert!(!session
2880 .messages
2881 .iter()
2882 .any(|message| message.id == envelope_id.as_str()));
2883 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
2884 assert!(!inbox.was_admitted(&session.id, &envelope_id).await.unwrap());
2885
2886 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
2887 store.clone(),
2888 bamboo_domain::SessionInboxLimits::default(),
2889 ));
2890 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
2891 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
2892 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
2893 let mut restarted = store.load_session(&session.id).await.unwrap().unwrap();
2894 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered)
2895 .await
2896 .unwrap();
2897 assert_eq!(
2898 restarted
2899 .messages
2900 .iter()
2901 .filter(|message| message.id == envelope_id.as_str())
2902 .count(),
2903 1
2904 );
2905 assert!(reopened
2906 .was_admitted(&session.id, &envelope_id)
2907 .await
2908 .unwrap());
2909 let backlog = reopened.inspect(&session.id).await.unwrap();
2910 assert_eq!(backlog.pending + backlog.claimed, 0);
2911 }
2912
2913 #[tokio::test]
2914 async fn actor_checkpoint_success_pre_ack_failure_recovers_without_duplicate() {
2915 let (_temp, store, locked, real_inbox, mut session, claim) =
2916 actor_inbox_fixture("actor-pre-ack-failure").await;
2917 let envelope_id = claim.envelope.id.clone();
2918 let faulted: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
2919 inner: real_inbox.clone(),
2920 fail_once: std::sync::atomic::AtomicBool::new(true),
2921 });
2922 let persistence: Arc<dyn RuntimeSessionPersistence> = locked.clone();
2923 let binding = actor_binding(store.clone(), faulted, persistence);
2924
2925 assert!(
2926 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
2927 .await
2928 .is_err()
2929 );
2930 let durable = store.load_session(&session.id).await.unwrap().unwrap();
2931 assert_eq!(
2932 durable
2933 .messages
2934 .iter()
2935 .filter(|message| message.id == envelope_id.as_str())
2936 .count(),
2937 1
2938 );
2939 assert_eq!(real_inbox.inspect(&session.id).await.unwrap().claimed, 1);
2940 assert!(!real_inbox
2941 .was_admitted(&session.id, &envelope_id)
2942 .await
2943 .unwrap());
2944
2945 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
2946 store.clone(),
2947 bamboo_domain::SessionInboxLimits::default(),
2948 ));
2949 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
2950 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
2951 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
2952 let mut restarted = durable;
2953 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered)
2954 .await
2955 .unwrap();
2956 assert_eq!(
2957 restarted
2958 .messages
2959 .iter()
2960 .filter(|message| message.id == envelope_id.as_str())
2961 .count(),
2962 1
2963 );
2964 assert!(reopened
2965 .was_admitted(&session.id, &envelope_id)
2966 .await
2967 .unwrap());
2968 let backlog = reopened.inspect(&session.id).await.unwrap();
2969 assert_eq!(backlog.pending + backlog.claimed, 0);
2970 }
2971
2972 struct ConfirmationSequenceLink {
2973 frames: VecDeque<ChildFrame>,
2974 sent: Vec<ParentFrame>,
2975 }
2976
2977 #[async_trait]
2978 impl bamboo_subagent::ChildLink for ConfirmationSequenceLink {
2979 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
2980 self.sent.push(frame);
2981 Ok(())
2982 }
2983
2984 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
2985 match self.frames.pop_front() {
2986 Some(frame) => Ok(Some(frame)),
2987 None => std::future::pending().await,
2988 }
2989 }
2990 }
2991
2992 fn admission_confirmation(
2993 session_id: &str,
2994 claim: &SessionInboxClaim,
2995 run_id: &str,
2996 ) -> bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
2997 bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
2998 target_session_id: session_id.to_string(),
2999 envelope_id: claim.envelope.id.to_string(),
3000 canonical_claim_generation: claim.generation,
3001 activation_run_id: run_id.to_string(),
3002 }
3003 }
3004
3005 fn expected_default_permission_posture(policy_revision: u64) -> ExpectedPermissionPosture {
3006 ExpectedPermissionPosture {
3007 policy_revision,
3008 resolution: bamboo_domain::PermissionModeResolution {
3009 requested: bamboo_domain::SessionPermissionMode::Default,
3010 effective: bamboo_domain::PermissionMode::Default,
3011 },
3012 expected_audit_revision: None,
3013 executor_mapping: "test_actor:permission_mode=default".to_string(),
3014 }
3015 }
3016
3017 fn permission_posture_frame(session_id: &str, policy_revision: u64) -> ChildFrame {
3018 ChildFrame::Event {
3019 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
3020 session_id: session_id.to_string(),
3021 policy_revision,
3022 requested_mode: "default".to_string(),
3023 effective_mode: "default".to_string(),
3024 executor_mapping: "test_actor:permission_mode=default".to_string(),
3025 })
3026 .expect("serialize permission posture event"),
3027 }
3028 }
3029
3030 fn actor_event_frame(event: AgentEvent) -> ChildFrame {
3031 ChildFrame::Event {
3032 event: serde_json::to_value(event).expect("serialize actor event"),
3033 }
3034 }
3035
3036 fn completed_actor_frame() -> ChildFrame {
3037 ChildFrame::Terminal {
3038 status: TerminalStatus::Completed,
3039 result: Some("done".to_string()),
3040 error: None,
3041 transcript: Vec::new(),
3042 }
3043 }
3044
3045 async fn drive_permission_handshake_frames(
3046 session_id: &str,
3047 frames: impl IntoIterator<Item = ChildFrame>,
3048 expected: ExpectedPermissionPosture,
3049 ) -> (
3050 crate::runtime::runner::Result<Option<String>>,
3051 Session,
3052 Vec<AgentEvent>,
3053 Vec<ParentFrame>,
3054 ) {
3055 let mut link = ConfirmationSequenceLink {
3056 frames: frames.into_iter().collect(),
3057 sent: Vec::new(),
3058 };
3059 let (event_tx, mut event_rx) = mpsc::channel(16);
3060 let cancel = CancellationToken::new();
3061 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
3062 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
3063 let mut session = Session::new(session_id, "model");
3064 let result = drive(ActorDriveContext {
3065 client: &mut link,
3066 parent_session_id: "permission-parent",
3067 child_session_id: session_id,
3068 child_attempt: 0,
3069 approval_registry: None,
3070 approval_decider: None,
3071 approval_reviewer: None,
3072 escalation_bridge: None,
3073 event_tx: &event_tx,
3074 cancel_token: &cancel,
3075 live_rx: &mut live_rx,
3076 delivery_rx: &mut delivery_rx,
3077 logical_session: &mut session,
3078 expected_permission_posture: Some(expected),
3079 session_inbox_runtime: None,
3080 activation_run_id: None,
3081 initial_inflight_claims: VecDeque::new(),
3082 first_frame_timeout: Some(Duration::from_secs(1)),
3083 })
3084 .await;
3085 let events = std::iter::from_fn(|| event_rx.try_recv().ok()).collect();
3086 (result, session, events, link.sent)
3087 }
3088
3089 #[tokio::test]
3090 async fn actor_permission_handshake_rejects_terminal_without_posture() {
3091 let session_id = "permission-missing";
3092 let (result, session, events, _) = drive_permission_handshake_frames(
3093 session_id,
3094 [completed_actor_frame()],
3095 expected_default_permission_posture(7),
3096 )
3097 .await;
3098
3099 assert!(result
3100 .unwrap_err()
3101 .to_string()
3102 .contains("terminated before permission posture confirmation"));
3103 assert!(events.is_empty());
3104 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3105 }
3106
3107 #[tokio::test]
3108 async fn actor_permission_handshake_rejects_malformed_agent_event() {
3109 let session_id = "permission-malformed";
3110 let (result, session, events, _) = drive_permission_handshake_frames(
3111 session_id,
3112 [ChildFrame::Event {
3113 event: serde_json::json!({"type": "token", "content": 42}),
3114 }],
3115 expected_default_permission_posture(7),
3116 )
3117 .await;
3118
3119 assert!(result
3120 .unwrap_err()
3121 .to_string()
3122 .contains("malformed AgentEvent"));
3123 assert!(events.is_empty());
3124 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3125 }
3126
3127 #[tokio::test]
3128 async fn actor_permission_handshake_rejects_execution_event_before_posture() {
3129 let session_id = "permission-early-event";
3130 let early_events = [
3131 (
3132 "progress",
3133 AgentEvent::RunnerProgress {
3134 session_id: session_id.to_string(),
3135 round_count: 1,
3136 },
3137 ),
3138 (
3139 "token",
3140 AgentEvent::Token {
3141 content: "must-not-forward".to_string(),
3142 },
3143 ),
3144 (
3145 "tool",
3146 AgentEvent::ToolStart {
3147 tool_call_id: "early-tool".to_string(),
3148 tool_name: "Read".to_string(),
3149 arguments: serde_json::json!({"file_path": "README.md"}),
3150 },
3151 ),
3152 ];
3153 for (kind, event) in early_events {
3154 let (result, session, events, _) = drive_permission_handshake_frames(
3155 session_id,
3156 [
3157 actor_event_frame(event),
3158 permission_posture_frame(session_id, 7),
3159 completed_actor_frame(),
3160 ],
3161 expected_default_permission_posture(7),
3162 )
3163 .await;
3164
3165 assert!(
3166 result
3167 .unwrap_err()
3168 .to_string()
3169 .contains("execution event before permission posture confirmation"),
3170 "{kind} must fail closed before posture"
3171 );
3172 assert!(events.is_empty(), "{kind} must not be forwarded");
3173 assert!(
3174 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
3175 "{kind} must not advance the permission audit"
3176 );
3177 }
3178 }
3179
3180 #[tokio::test]
3181 async fn actor_permission_handshake_rejects_approval_before_posture() {
3182 let session_id = "permission-early-approval";
3183 let (result, session, events, sent) = drive_permission_handshake_frames(
3184 session_id,
3185 [ChildFrame::ApprovalRequest {
3186 id: "approval-before-posture".to_string(),
3187 body: serde_json::json!({
3188 "tool_name": "Bash",
3189 "permission": "execute",
3190 "resource": "echo must-not-run"
3191 }),
3192 }],
3193 expected_default_permission_posture(7),
3194 )
3195 .await;
3196
3197 assert!(result
3198 .unwrap_err()
3199 .to_string()
3200 .contains("requested approval before permission posture confirmation"));
3201 assert!(events.is_empty());
3202 assert!(
3203 sent.is_empty(),
3204 "an unconfirmed actor must receive no approval reply"
3205 );
3206 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3207 }
3208
3209 #[tokio::test]
3210 async fn actor_permission_handshake_rejects_mismatched_posture() {
3211 let session_id = "permission-mismatch";
3212 let (result, session, events, _) = drive_permission_handshake_frames(
3213 session_id,
3214 [permission_posture_frame(session_id, 8)],
3215 expected_default_permission_posture(7),
3216 )
3217 .await;
3218
3219 assert!(result
3220 .unwrap_err()
3221 .to_string()
3222 .contains("does not match the host-dispatched policy"));
3223 assert!(events.is_empty());
3224 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3225 }
3226
3227 #[tokio::test]
3228 async fn actor_permission_handshake_rejects_untrusted_executor_mapping() {
3229 let session_id = "permission-hostile-mapping";
3230 for hostile_mapping in [
3231 "wrong_executor:permission_mode=default",
3232 "test_actor:permission_mode=default;credential=must-not-persist",
3233 ] {
3234 let frame = ChildFrame::Event {
3235 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
3236 session_id: session_id.to_string(),
3237 policy_revision: 7,
3238 requested_mode: "default".to_string(),
3239 effective_mode: "default".to_string(),
3240 executor_mapping: hostile_mapping.to_string(),
3241 })
3242 .expect("serialize hostile posture fixture"),
3243 };
3244 let (result, session, events, _) = drive_permission_handshake_frames(
3245 session_id,
3246 [frame],
3247 expected_default_permission_posture(7),
3248 )
3249 .await;
3250
3251 let error = result.unwrap_err().to_string();
3252 assert!(error.contains("host-dispatched executor mapping"));
3253 assert!(
3254 !error.contains(hostile_mapping),
3255 "untrusted mapping must not be reflected in host errors"
3256 );
3257 assert!(events.is_empty());
3258 assert!(
3259 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
3260 "untrusted mapping must not reach durable or in-memory audit state"
3261 );
3262 }
3263 }
3264
3265 #[tokio::test]
3266 async fn actor_permission_handshake_rejects_duplicate_posture() {
3267 let session_id = "permission-duplicate";
3268 let (result, session, events, _) = drive_permission_handshake_frames(
3269 session_id,
3270 [
3271 permission_posture_frame(session_id, 7),
3272 permission_posture_frame(session_id, 7),
3273 completed_actor_frame(),
3274 ],
3275 expected_default_permission_posture(7),
3276 )
3277 .await;
3278
3279 assert!(result
3280 .unwrap_err()
3281 .to_string()
3282 .contains("duplicate permission posture activation"));
3283 assert_eq!(
3284 events.len(),
3285 1,
3286 "only the confirmed posture may be forwarded"
3287 );
3288 assert!(matches!(
3289 events[0],
3290 AgentEvent::PermissionPostureActivated { .. }
3291 ));
3292 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
3293 .expect("the first matching posture must be recorded");
3294 assert_eq!(audit.policy_revision, 7);
3295 }
3296
3297 #[tokio::test]
3298 async fn actor_permission_handshake_happy_path_persists_before_forwarding_execution() {
3299 let session_id = "permission-happy";
3300 let (result, session, events, _) = drive_permission_handshake_frames(
3301 session_id,
3302 [
3303 permission_posture_frame(session_id, 7),
3304 actor_event_frame(AgentEvent::RunnerProgress {
3305 session_id: session_id.to_string(),
3306 round_count: 1,
3307 }),
3308 actor_event_frame(AgentEvent::Token {
3309 content: "working".to_string(),
3310 }),
3311 actor_event_frame(AgentEvent::ToolStart {
3312 tool_call_id: "tool-1".to_string(),
3313 tool_name: "Read".to_string(),
3314 arguments: serde_json::json!({"file_path": "README.md"}),
3315 }),
3316 completed_actor_frame(),
3317 ],
3318 expected_default_permission_posture(7),
3319 )
3320 .await;
3321
3322 assert_eq!(result.unwrap().as_deref(), Some("done"));
3323 assert!(matches!(
3324 events.as_slice(),
3325 [
3326 AgentEvent::PermissionPostureActivated { .. },
3327 AgentEvent::RunnerProgress { .. },
3328 AgentEvent::Token { .. },
3329 AgentEvent::ToolStart { .. }
3330 ]
3331 ));
3332 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
3333 .expect("matching posture must be recorded before execution events are accepted");
3334 assert_eq!(audit.policy_revision, 7);
3335 assert_eq!(audit.executor_mapping, "test_actor:permission_mode=default");
3336 }
3337
3338 #[tokio::test]
3339 async fn actor_initial_batch_acks_in_order_and_rejects_stale_confirmation() {
3340 let temp = tempfile::tempdir().unwrap();
3341 let store = Arc::new(
3342 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
3343 .await
3344 .unwrap(),
3345 );
3346 let storage: Arc<dyn Storage> = store.clone();
3347 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
3348 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3349 store.clone(),
3350 bamboo_domain::SessionInboxLimits::default(),
3351 ));
3352 let session_id = "actor-confirmation-order";
3353 let run_id = "actor-run-current";
3354 let mut session = Session::new(session_id, "model");
3355 store.save_session(&session).await.unwrap();
3356 for (id, text) in [("actor-first", "first"), ("actor-second", "second")] {
3357 let mut envelope = bamboo_domain::SessionMessageEnvelope::user_input(session_id, text);
3358 envelope.id = bamboo_domain::SessionMessageId::parse(id).unwrap();
3359 inbox.deliver(&envelope).await.unwrap();
3360 }
3361 inbox
3362 .mark_activation_eligible(
3363 session_id,
3364 2,
3365 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
3366 )
3367 .await
3368 .unwrap();
3369 let router = SessionActivationRouter::new();
3370 let mut owner_registration = router.register_run(session_id, run_id).await.unwrap();
3371 let binding = SessionInboxRuntimeBinding {
3372 router,
3373 inbox: inbox.clone(),
3374 storage,
3375 persistence: locked,
3376 };
3377 let pairs = claim_canonical_deliveries(&binding, &mut session, run_id, usize::MAX)
3378 .await
3379 .unwrap();
3380 assert_eq!(
3381 pairs
3382 .iter()
3383 .map(|(claim, _)| claim.envelope.id.as_str())
3384 .collect::<Vec<_>>(),
3385 vec!["actor-first", "actor-second"]
3386 );
3387 let seeded = store.load_session(session_id).await.unwrap().unwrap();
3388 assert_eq!(
3389 seeded
3390 .messages
3391 .iter()
3392 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
3393 .map(|message| message.id.as_str())
3394 .collect::<Vec<_>>(),
3395 vec!["actor-first", "actor-second"],
3396 "host context must be durable before actor dispatch"
3397 );
3398 for (claim, _) in &pairs {
3399 assert_eq!(
3400 seeded
3401 .messages
3402 .iter()
3403 .filter(|message| bamboo_domain::is_matching_session_message(
3404 message,
3405 &claim.envelope
3406 ))
3407 .count(),
3408 1,
3409 "pre-dispatch host checkpoint must contain exactly one canonical marker for {}",
3410 claim.envelope.id
3411 );
3412 }
3413 assert!(
3414 seeded.session_inbox_admission().is_none_or(|cursor| {
3415 !cursor.contains(&pairs[0].0.envelope.id)
3416 && !cursor.contains(&pairs[1].0.envelope.id)
3417 }),
3418 "pre-dispatch transcript seeding must not forge worker confirmation"
3419 );
3420 assert_eq!(inbox.inspect(session_id).await.unwrap().claimed, 2);
3421 let claims = pairs
3422 .into_iter()
3423 .map(|(claim, _)| claim)
3424 .collect::<VecDeque<_>>();
3425 let first = claims[0].clone();
3426 let second = claims[1].clone();
3427 let mut stale = admission_confirmation(session_id, &first, "stale-run");
3428 stale.canonical_claim_generation = second.generation;
3429 let mut link = ConfirmationSequenceLink {
3430 frames: VecDeque::from([
3431 ChildFrame::SessionMessageAdmitted {
3432 confirmation: stale,
3433 },
3434 ChildFrame::SessionMessageAdmitted {
3435 confirmation: admission_confirmation(session_id, &first, run_id),
3436 },
3437 ChildFrame::SessionMessageAdmitted {
3438 confirmation: admission_confirmation(session_id, &second, run_id),
3439 },
3440 ChildFrame::Terminal {
3441 status: TerminalStatus::Completed,
3442 result: Some("done".to_string()),
3443 error: None,
3444 transcript: Vec::new(),
3445 },
3446 ]),
3447 sent: Vec::new(),
3448 };
3449 let (event_tx, _event_rx) = mpsc::channel(8);
3450 let cancel = CancellationToken::new();
3451 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
3452 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
3453 let result = drive(ActorDriveContext {
3454 client: &mut link,
3455 parent_session_id: "parent",
3456 child_session_id: session_id,
3457 child_attempt: 0,
3458 approval_registry: None,
3459 approval_decider: None,
3460 approval_reviewer: None,
3461 escalation_bridge: None,
3462 event_tx: &event_tx,
3463 cancel_token: &cancel,
3464 live_rx: &mut live_rx,
3465 delivery_rx: &mut delivery_rx,
3466 logical_session: &mut session,
3467 expected_permission_posture: None,
3468 session_inbox_runtime: Some(&binding),
3469 activation_run_id: Some(run_id),
3470 initial_inflight_claims: claims,
3471 first_frame_timeout: Some(Duration::from_secs(1)),
3472 })
3473 .await
3474 .unwrap();
3475 assert_eq!(result.as_deref(), Some("done"));
3476 assert_eq!(
3477 session
3478 .messages
3479 .iter()
3480 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
3481 .map(|message| message.id.as_str())
3482 .collect::<Vec<_>>(),
3483 vec!["actor-first", "actor-second"]
3484 );
3485 let backlog = inbox.inspect(session_id).await.unwrap();
3486 assert_eq!(backlog.pending + backlog.claimed, 0);
3487 let confirmed = store.load_session(session_id).await.unwrap().unwrap();
3488 let cursor = confirmed
3489 .session_inbox_admission()
3490 .expect("exact worker confirmation must checkpoint the admission cursor");
3491 assert!(cursor.contains(&first.envelope.id));
3492 assert!(cursor.contains(&second.envelope.id));
3493 for claim in [&first, &second] {
3494 assert_eq!(
3495 confirmed
3496 .messages
3497 .iter()
3498 .filter(|message| bamboo_domain::is_matching_session_message(
3499 message,
3500 &claim.envelope
3501 ))
3502 .count(),
3503 1,
3504 "confirmation must retain one exact canonical marker for {}",
3505 claim.envelope.id
3506 );
3507 }
3508 assert!(inbox
3509 .was_admitted(session_id, &first.envelope.id)
3510 .await
3511 .unwrap());
3512 assert!(inbox
3513 .was_admitted(session_id, &second.envelope.id)
3514 .await
3515 .unwrap());
3516 owner_registration.begin_finalization().await;
3517 owner_registration.finish(2).await.unwrap();
3518 }
3519
3520 #[derive(Default)]
3521 struct RecordingCodexTokenAuthority {
3522 issued_for: std::sync::Mutex<Vec<String>>,
3523 revoked: std::sync::Mutex<Vec<String>>,
3524 }
3525
3526 impl CodexRunTokenAuthority for RecordingCodexTokenAuthority {
3527 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String> {
3528 self.issued_for
3529 .lock()
3530 .expect("issued fixture lock")
3531 .push(session_id.to_string());
3532 Ok(IssuedCodexRunToken {
3533 token_id: format!("id-{session_id}"),
3534 token: format!("bcx1_secret-{session_id}"),
3535 })
3536 }
3537
3538 fn revoke(&self, token_id: &str) {
3539 self.revoked
3540 .lock()
3541 .expect("revoked fixture lock")
3542 .push(token_id.to_string());
3543 }
3544 }
3545
3546 fn codex_executor(auth_mode: Option<&str>, inherit_user_config: Option<bool>) -> ExecutorSpec {
3547 let bamboo_mode = auth_mode == Some("bamboo")
3548 || (auth_mode.is_none() && !inherit_user_config.unwrap_or(false));
3549 ExecutorSpec::Codex {
3550 binary: None,
3551 model: None,
3552 mode: None,
3553 sandbox: None,
3554 inherit_user_config,
3555 auth_mode: auth_mode.map(str::to_string),
3556 base_url: bamboo_mode.then(|| "http://127.0.0.1:9562/openai/v1".to_string()),
3557 wire_api: Some("responses".to_string()),
3558 provider_key_ref: None,
3559 forward_env: None,
3560 approval_policy: None,
3561 network_access: None,
3562 allow_danger_bypass: None,
3563 permission_profile: None,
3564 workspace_owned: None,
3565 }
3566 }
3567
3568 fn permission_resolution(
3569 requested: bamboo_domain::SessionPermissionMode,
3570 effective: bamboo_domain::PermissionMode,
3571 ) -> bamboo_domain::PermissionModeResolution {
3572 bamboo_domain::PermissionModeResolution {
3573 requested,
3574 effective,
3575 }
3576 }
3577
3578 #[test]
3579 fn permission_posture_mapping_contract_is_exact_for_supported_executors() {
3580 use bamboo_domain::{PermissionMode, SessionPermissionMode};
3581
3582 let default =
3583 permission_resolution(SessionPermissionMode::Default, PermissionMode::Default);
3584 assert_eq!(
3585 expected_permission_executor_mapping(&ExecutorSpec::BambooRuntime, default, false)
3586 .unwrap()
3587 .as_deref(),
3588 Some("bamboo_runtime:default")
3589 );
3590 assert_eq!(
3591 expected_permission_executor_mapping(&ExecutorSpec::Echo, default, false).unwrap(),
3592 None,
3593 "transport-only Echo must not claim the typed permission contract"
3594 );
3595 assert_eq!(
3596 expected_permission_executor_mapping(
3597 &ExecutorSpec::CliAdapter {
3598 command: "must-not-appear-in-contract".to_string(),
3599 args: vec!["credential-like-argument".to_string()],
3600 },
3601 default,
3602 false,
3603 )
3604 .unwrap(),
3605 None,
3606 "unimplemented CliAdapter must not leak command data into a contract"
3607 );
3608
3609 let claude = ExecutorSpec::ClaudeCode {
3610 binary: None,
3611 model: None,
3612 permission_mode: Some("default".to_string()),
3613 inherit_user_config: None,
3614 forward_env: None,
3615 };
3616 for (resolution, mapping) in [
3617 (
3618 permission_resolution(SessionPermissionMode::Default, PermissionMode::Plan),
3619 "claude_code:permission_mode=plan",
3620 ),
3621 (
3622 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
3623 "claude_code:permission_mode=bypassPermissions",
3624 ),
3625 (
3626 permission_resolution(SessionPermissionMode::Default, PermissionMode::AcceptEdits),
3627 "claude_code:permission_mode=acceptEdits",
3628 ),
3629 (
3630 permission_resolution(SessionPermissionMode::Default, PermissionMode::DontAsk),
3631 "claude_code:permission_mode=dontAsk",
3632 ),
3633 (
3634 permission_resolution(
3635 SessionPermissionMode::Bypass,
3636 PermissionMode::BypassPermissions,
3637 ),
3638 "claude_code:permission_mode=default",
3639 ),
3640 ] {
3641 assert_eq!(
3642 expected_permission_executor_mapping(&claude, resolution, false)
3643 .unwrap()
3644 .as_deref(),
3645 Some(mapping)
3646 );
3647 }
3648 assert_eq!(
3649 expected_permission_executor_mapping(&claude, default, true)
3650 .unwrap()
3651 .as_deref(),
3652 Some("claude_code:blocked_explicit_deny")
3653 );
3654
3655 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
3656 if let ExecutorSpec::Codex {
3657 approval_policy, ..
3658 } = &mut codex_exec
3659 {
3660 *approval_policy = Some("on-failure".to_string());
3661 }
3662 assert_eq!(
3663 expected_permission_executor_mapping(&codex_exec, default, false)
3664 .unwrap()
3665 .as_deref(),
3666 Some("codex_exec:approval_policy=on-failure")
3667 );
3668 assert_eq!(
3669 expected_permission_executor_mapping(
3670 &codex_exec,
3671 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
3672 false,
3673 )
3674 .unwrap()
3675 .as_deref(),
3676 Some("codex_exec:approval_policy=never")
3677 );
3678 assert_eq!(
3679 expected_permission_executor_mapping(&codex_exec, default, true)
3680 .unwrap()
3681 .as_deref(),
3682 Some("codex_exec:blocked_explicit_deny")
3683 );
3684
3685 let mut codex_app_server = codex_executor(Some("inherit"), Some(true));
3686 if let ExecutorSpec::Codex {
3687 mode,
3688 approval_policy,
3689 ..
3690 } = &mut codex_app_server
3691 {
3692 *mode = Some("app_server".to_string());
3693 *approval_policy = Some("on-request".to_string());
3694 }
3695 assert_eq!(
3696 expected_permission_executor_mapping(&codex_app_server, default, false)
3697 .unwrap()
3698 .as_deref(),
3699 Some("codex_app_server:approvalPolicy=on-request")
3700 );
3701 assert_eq!(
3702 expected_permission_executor_mapping(
3703 &codex_app_server,
3704 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
3705 false,
3706 )
3707 .unwrap()
3708 .as_deref(),
3709 Some("codex_app_server:approvalPolicy=never")
3710 );
3711 assert_eq!(
3712 expected_permission_executor_mapping(&codex_app_server, default, true)
3713 .unwrap()
3714 .as_deref(),
3715 Some("codex_app_server:blocked_explicit_deny")
3716 );
3717 }
3718
3719 #[test]
3720 fn only_bamboo_managed_non_git_workspaces_are_marked_owned() {
3721 let project = tempfile::tempdir().unwrap();
3722 let managed = project.path().join(".bamboo/worktree/child-571");
3723 std::fs::create_dir_all(&managed).unwrap();
3724 assert!(!workspace_is_bamboo_owned(managed.to_str().unwrap()));
3725 let marker = project
3726 .path()
3727 .join(".bamboo/worktree/.bamboo-owned/child-571");
3728 std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
3729 std::fs::write(&marker, "bamboo/child-571").unwrap();
3730 assert!(workspace_is_bamboo_owned(managed.to_str().unwrap()));
3731 let nested = managed.join("nested/path");
3732 std::fs::create_dir_all(&nested).unwrap();
3733 assert!(workspace_is_bamboo_owned(nested.to_str().unwrap()));
3734
3735 let arbitrary = tempfile::tempdir().unwrap();
3736 assert!(!workspace_is_bamboo_owned(
3737 arbitrary.path().to_str().unwrap()
3738 ));
3739 }
3740
3741 #[test]
3742 fn bamboo_codex_token_is_per_run_redacted_and_revoked_on_guard_drop() {
3743 let authority = Arc::new(RecordingCodexTokenAuthority::default());
3744 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
3745
3746 let (secrets, guard) = build_codex_run_secrets(
3747 &codex_executor(Some("bamboo"), None),
3748 Some(authority_dyn),
3749 "child-570",
3750 )
3751 .unwrap();
3752
3753 let token = secrets
3754 .codex_provider_token
3755 .as_ref()
3756 .expect("bamboo mode mints a token");
3757 assert_eq!(token.expose(), "bcx1_secret-child-570");
3758 assert!(!format!("{token:?}").contains("secret-child-570"));
3759 assert_eq!(
3760 authority.issued_for.lock().unwrap().as_slice(),
3761 ["child-570"]
3762 );
3763 assert!(authority.revoked.lock().unwrap().is_empty());
3764
3765 drop(guard);
3766 assert_eq!(
3767 authority.revoked.lock().unwrap().as_slice(),
3768 ["id-child-570"]
3769 );
3770 }
3771
3772 #[test]
3773 fn non_bamboo_codex_never_mints_and_bamboo_fails_closed_without_authority() {
3774 let authority = Arc::new(RecordingCodexTokenAuthority::default());
3775 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
3776 let (secrets, guard) = build_codex_run_secrets(
3777 &codex_executor(Some("custom"), None),
3778 Some(authority_dyn),
3779 "child-custom",
3780 )
3781 .unwrap();
3782 assert!(secrets.codex_provider_token.is_none());
3783 assert!(guard.is_none());
3784 assert!(authority.issued_for.lock().unwrap().is_empty());
3785
3786 let error = build_codex_run_secrets(
3787 &codex_executor(Some("bamboo"), None),
3788 None,
3789 "child-no-authority",
3790 )
3791 .err()
3792 .expect("bamboo mode without an authority must fail closed");
3793 assert!(error.to_string().contains("per-run token authority"));
3794 }
3795
3796 #[test]
3797 fn codex_provisioning_never_leaks_the_session_provider_credential() {
3798 let credentials = vec![ScopedCredential {
3799 provider: "openai".to_string(),
3800 api_key: "upstream-secret-must-not-cross".to_string(),
3801 base_url: None,
3802 provider_type: Some("openai".to_string()),
3803 credential_ref: Some("provider.openai.api_key".to_string()),
3804 }];
3805
3806 for (mode, label) in [
3807 (Some("inherit"), "inherit"),
3808 (Some("api_key"), "api_key"),
3809 (Some("bamboo"), "bamboo"),
3810 (None, "default-bamboo"),
3811 ] {
3812 let runner = ActorChildRunner::new(
3813 format!("codex-{label}-test"),
3814 PathBuf::from("/bin/false"),
3815 Vec::new(),
3816 std::env::temp_dir().join(format!("bamboo-codex-{label}-570")),
3817 codex_executor(mode, None),
3818 credentials.clone(),
3819 "openai".to_string(),
3820 1,
3821 );
3822 let mut session = Session::new(format!("child-{label}"), "model");
3823 session.add_message(bamboo_agent_core::Message::user("test"));
3824 let spec = runner.build_spec(
3825 &session,
3826 &crate::runtime::execution::SpawnJob {
3827 parent_session_id: "parent".to_string(),
3828 child_session_id: format!("child-{label}"),
3829 model: "gpt-5.4".to_string(),
3830 disabled_tools: None,
3831 },
3832 );
3833 assert!(
3834 spec.secrets.provider_credentials.is_empty(),
3835 "{label} Codex must not receive the session provider key"
3836 );
3837 }
3838 }
3839
3840 #[test]
3841 fn non_codex_provisioning_still_receives_only_its_selected_provider_credential() {
3842 let credentials = vec![
3843 ScopedCredential {
3844 provider: "openai".to_string(),
3845 api_key: "selected-openai-secret".to_string(),
3846 base_url: None,
3847 provider_type: Some("openai".to_string()),
3848 credential_ref: Some("provider.openai.api_key".to_string()),
3849 },
3850 ScopedCredential {
3851 provider: "other".to_string(),
3852 api_key: "unrelated-secret".to_string(),
3853 base_url: None,
3854 provider_type: Some("openai".to_string()),
3855 credential_ref: Some("provider.other.api_key".to_string()),
3856 },
3857 ];
3858 let runner = ActorChildRunner::new(
3859 "echo-test".to_string(),
3860 PathBuf::from("/bin/false"),
3861 Vec::new(),
3862 std::env::temp_dir().join("bamboo-echo-provider-570"),
3863 ExecutorSpec::Echo,
3864 credentials,
3865 "openai".to_string(),
3866 1,
3867 );
3868 let spec = runner.build_spec(
3869 &Session::new("child-echo", "model"),
3870 &crate::runtime::execution::SpawnJob {
3871 parent_session_id: "parent".to_string(),
3872 child_session_id: "child-echo".to_string(),
3873 model: "gpt-5.4".to_string(),
3874 disabled_tools: None,
3875 },
3876 );
3877
3878 assert_eq!(spec.secrets.provider_credentials.len(), 1);
3879 assert_eq!(
3880 spec.secrets.provider_credentials[0].api_key,
3881 "selected-openai-secret"
3882 );
3883 }
3884
3885 #[test]
3886 fn custom_codex_provisioning_scopes_only_the_referenced_credential() {
3887 let mut executor = codex_executor(Some("custom"), None);
3888 if let ExecutorSpec::Codex {
3889 base_url,
3890 provider_key_ref,
3891 ..
3892 } = &mut executor
3893 {
3894 *base_url = Some("https://provider.example/v1".to_string());
3895 *provider_key_ref = Some("provider.custom.api_key".to_string());
3896 }
3897 let credentials = vec![
3898 ScopedCredential {
3899 provider: "openai".to_string(),
3900 api_key: "session-provider-secret".to_string(),
3901 base_url: None,
3902 provider_type: Some("openai".to_string()),
3903 credential_ref: Some("provider.openai.api_key".to_string()),
3904 },
3905 ScopedCredential {
3906 provider: "custom".to_string(),
3907 api_key: "selected-secret".to_string(),
3908 base_url: None,
3909 provider_type: Some("openai".to_string()),
3910 credential_ref: Some("provider.custom.api_key".to_string()),
3911 },
3912 ScopedCredential {
3913 provider: "other".to_string(),
3914 api_key: "unrelated-secret".to_string(),
3915 base_url: None,
3916 provider_type: Some("openai".to_string()),
3917 credential_ref: Some("provider.other.api_key".to_string()),
3918 },
3919 ];
3920 let runner = ActorChildRunner::new(
3921 "codex-test".to_string(),
3922 PathBuf::from("/bin/false"),
3923 Vec::new(),
3924 std::env::temp_dir().join("bamboo-codex-570"),
3925 executor,
3926 credentials,
3927 "openai".to_string(),
3928 1,
3929 );
3930 let mut session = Session::new("child-custom", "model");
3931 session.add_message(bamboo_agent_core::Message::user("test"));
3932 let spec = runner.build_spec(
3933 &session,
3934 &crate::runtime::execution::SpawnJob {
3935 parent_session_id: "parent".to_string(),
3936 child_session_id: "child-custom".to_string(),
3937 model: "gpt-5.4".to_string(),
3938 disabled_tools: None,
3939 },
3940 );
3941
3942 assert_eq!(spec.secrets.provider_credentials.len(), 1);
3943 assert_eq!(
3944 spec.secrets.provider_credentials[0]
3945 .credential_ref
3946 .as_deref(),
3947 Some("provider.custom.api_key")
3948 );
3949 assert_eq!(
3950 spec.secrets.provider_credentials[0].api_key,
3951 "selected-secret"
3952 );
3953 }
3954
3955 fn spec_with(
3956 role: &str,
3957 provider: &str,
3958 model: &str,
3959 workspace: Option<&str>,
3960 disabled: Option<Vec<&str>>,
3961 ) -> ProvisionSpec {
3962 let mut spec = ProvisionSpec::new(
3963 ChildIdentity {
3964 child_id: "c".into(),
3965 parent_id: None,
3966 project_key: None,
3967 role: role.into(),
3968 depth: 0,
3969 },
3970 ExecutorSpec::Echo,
3971 "/tmp/fab".into(),
3972 );
3973 spec.workspace = workspace.map(|w| w.to_string());
3974 spec.model = Some(ModelRefSpec {
3975 provider: provider.into(),
3976 model: model.into(),
3977 });
3978 spec.disabled_tools = disabled.map(|d| d.into_iter().map(String::from).collect());
3979 spec
3980 }
3981
3982 #[test]
3983 fn fingerprint_matches_interchangeable_children() {
3984 let a = spec_with(
3987 "explorer",
3988 "p",
3989 "m",
3990 Some("/ws"),
3991 Some(vec!["Bash", "Edit"]),
3992 );
3993 let mut b = spec_with(
3994 "explorer",
3995 "p",
3996 "m",
3997 Some("/ws"),
3998 Some(vec!["Edit", "Bash"]),
3999 );
4000 b.identity.child_id = "other".into();
4001 assert_eq!(
4002 ActorChildRunner::fingerprint(&a),
4003 ActorChildRunner::fingerprint(&b)
4004 );
4005 }
4006
4007 #[test]
4008 fn logical_identity_is_invariant_across_local_remote_scheduled_and_warm_reuse() {
4009 let mut session =
4010 Session::new_child("logical-child-681", "logical-parent-681", "model", "child");
4011 session.root_session_id = "logical-root-681".to_string();
4012 let job = SpawnJob {
4013 parent_session_id: "logical-parent-681".to_string(),
4014 child_session_id: "logical-child-681".to_string(),
4015 model: "model".to_string(),
4016 disabled_tools: None,
4017 };
4018 let expected = LogicalSessionIdentity {
4019 session_id: "logical-child-681".to_string(),
4020 parent_session_id: Some("logical-parent-681".to_string()),
4021 root_session_id: "logical-root-681".to_string(),
4022 };
4023
4024 let placements_and_transport_ids = [
4025 (Placement::Local, "local-mailbox-first"),
4026 (
4027 Placement::Remote {
4028 endpoint: "wss://remote.example/actor".to_string(),
4029 },
4030 "remote-process-44",
4031 ),
4032 (
4033 Placement::Schedulable {
4034 pool: "gpu-pool".to_string(),
4035 },
4036 "scheduled-mailbox-9",
4037 ),
4038 (Placement::Local, "warm-mailbox-reused-77"),
4040 ];
4041 for (placement, transport_id) in placements_and_transport_ids {
4042 let mut provision = spec_with("worker", "provider", "model", None, None);
4043 provision.placement = placement;
4044 provision.identity.child_id = transport_id.to_string();
4045 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
4046 assert_ne!(
4047 provision.identity.child_id, expected.session_id,
4048 "test fixture must prove transport identity is independent"
4049 );
4050 }
4051 }
4052
4053 #[test]
4054 fn fingerprint_separates_distinct_runtimes() {
4055 let base = spec_with("explorer", "p", "m", Some("/ws"), None);
4056 let base_fp = ActorChildRunner::fingerprint(&base);
4057 assert_ne!(
4059 base_fp,
4060 ActorChildRunner::fingerprint(&spec_with("writer", "p", "m", Some("/ws"), None))
4061 );
4062 assert_ne!(
4063 base_fp,
4064 ActorChildRunner::fingerprint(&spec_with("explorer", "p2", "m", Some("/ws"), None))
4065 );
4066 assert_ne!(
4067 base_fp,
4068 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m2", Some("/ws"), None))
4069 );
4070 assert_ne!(
4071 base_fp,
4072 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws2"), None))
4073 );
4074 assert_ne!(
4075 base_fp,
4076 ActorChildRunner::fingerprint(&spec_with(
4077 "explorer",
4078 "p",
4079 "m",
4080 Some("/ws"),
4081 Some(vec!["Bash"])
4082 ))
4083 );
4084 }
4085
4086 #[test]
4087 fn fingerprint_splits_on_baked_capabilities() {
4088 let base_fp =
4093 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws"), None));
4094
4095 let mut depth = spec_with("explorer", "p", "m", Some("/ws"), None);
4096 depth.identity.depth = 2;
4097 assert_ne!(
4098 base_fp,
4099 ActorChildRunner::fingerprint(&depth),
4100 "depth must split"
4101 );
4102
4103 let mut nested = spec_with("explorer", "p", "m", Some("/ws"), None);
4104 nested.capabilities.nested_spawn = true;
4105 assert_ne!(
4106 base_fp,
4107 ActorChildRunner::fingerprint(&nested),
4108 "nested_spawn must split"
4109 );
4110
4111 let mut bypass = spec_with("explorer", "p", "m", Some("/ws"), None);
4112 bypass.capabilities.bypass = true;
4113 assert_ne!(
4114 base_fp,
4115 ActorChildRunner::fingerprint(&bypass),
4116 "bypass must split"
4117 );
4118
4119 let mut auto = spec_with("explorer", "p", "m", Some("/ws"), None);
4120 auto.capabilities.auto_approve_permissions = true;
4121 assert_ne!(
4122 base_fp,
4123 ActorChildRunner::fingerprint(&auto),
4124 "auto_approve_permissions must split"
4125 );
4126
4127 let mut global_auto = spec_with("explorer", "p", "m", Some("/ws"), None);
4128 global_auto.capabilities.permission_requested_mode = "default".to_string();
4129 global_auto.capabilities.permission_effective_mode = "auto".to_string();
4130 global_auto.capabilities.auto_approve_permissions = true;
4131 let mut explicit_auto = global_auto.clone();
4132 explicit_auto.capabilities.permission_requested_mode = "auto".to_string();
4133 assert_ne!(
4134 ActorChildRunner::fingerprint(&global_auto),
4135 ActorChildRunner::fingerprint(&explicit_auto),
4136 "permission_requested_mode must split global and explicit Auto"
4137 );
4138
4139 let mut plan_overlay = explicit_auto.clone();
4140 plan_overlay.capabilities.permission_effective_mode = "plan".to_string();
4141 assert_ne!(
4142 ActorChildRunner::fingerprint(&explicit_auto),
4143 ActorChildRunner::fingerprint(&plan_overlay),
4144 "permission_effective_mode must split Plan overlay from Auto"
4145 );
4146
4147 let mut enforce = spec_with("explorer", "p", "m", Some("/ws"), None);
4148 enforce.capabilities.enforce_permissions = true;
4149 assert_ne!(
4150 base_fp,
4151 ActorChildRunner::fingerprint(&enforce),
4152 "enforce_permissions must split"
4153 );
4154
4155 let mut cap = spec_with("explorer", "p", "m", Some("/ws"), None);
4156 cap.capabilities.max_spawn_depth = Some(8);
4157 assert_ne!(
4158 base_fp,
4159 ActorChildRunner::fingerprint(&cap),
4160 "max_spawn_depth must split"
4161 );
4162
4163 let mut nha = spec_with("explorer", "p", "m", Some("/ws"), None);
4167 nha.capabilities.no_human_approver = true;
4168 assert_ne!(
4169 base_fp,
4170 ActorChildRunner::fingerprint(&nha),
4171 "no_human_approver must split"
4172 );
4173
4174 let mut gro = spec_with("explorer", "p", "m", Some("/ws"), None);
4177 gro.capabilities.guardian_read_only = true;
4178 assert_ne!(
4179 base_fp,
4180 ActorChildRunner::fingerprint(&gro),
4181 "guardian_read_only must split"
4182 );
4183 }
4184
4185 #[test]
4186 fn fingerprint_splits_codex_exec_and_app_server_workers() {
4187 let mut exec = spec_with("explorer", "p", "m", Some("/ws"), None);
4188 exec.executor = codex_executor(Some("inherit"), None);
4189 let mut app_server = exec.clone();
4190 if let ExecutorSpec::Codex { mode, .. } = &mut app_server.executor {
4191 *mode = Some("app_server".to_string());
4192 }
4193 assert_ne!(
4194 ActorChildRunner::fingerprint(&exec),
4195 ActorChildRunner::fingerprint(&app_server)
4196 );
4197 }
4198
4199 struct StaticDecider(bool);
4200
4201 #[async_trait]
4202 impl ChildApprovalDecider for StaticDecider {
4203 async fn decide(&self, _child: &str, _req: &serde_json::Value) -> bool {
4204 self.0
4205 }
4206 }
4207
4208 struct RecordingReviewer {
4209 reviewed: mpsc::UnboundedSender<(String, String, serde_json::Value)>,
4210 }
4211
4212 #[async_trait]
4213 impl ChildApprovalReviewer for RecordingReviewer {
4214 async fn review(&self, parent: &str, child: &str, request: &serde_json::Value) -> bool {
4215 let _ = self
4216 .reviewed
4217 .send((parent.to_string(), child.to_string(), request.clone()));
4218 true
4219 }
4220 }
4221
4222 struct SilentLink;
4227 #[async_trait]
4228 impl bamboo_subagent::ChildLink for SilentLink {
4229 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
4230 Ok(())
4231 }
4232 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
4233 std::future::pending().await
4234 }
4235 }
4236
4237 struct InstantTerminalLink {
4239 done: bool,
4240 }
4241
4242 struct ApprovalRoundTripLink {
4243 step: u8,
4244 approval_reply: Option<(String, bool)>,
4245 }
4246
4247 #[async_trait]
4248 impl bamboo_subagent::ChildLink for ApprovalRoundTripLink {
4249 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
4250 if let ParentFrame::ApprovalReply { id, approved } = frame {
4251 self.approval_reply = Some((id, approved));
4252 self.step = 2;
4253 }
4254 Ok(())
4255 }
4256
4257 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
4258 match self.step {
4259 0 => {
4260 self.step = 1;
4261 Ok(Some(ChildFrame::ApprovalRequest {
4262 id: "approval-1".into(),
4263 body: serde_json::json!({
4264 "tool_name": "Bash",
4265 "permission": "execute",
4266 "resource": "rm -rf target",
4267 "permission_request": {"reason_code": "hard_dangerous"}
4268 }),
4269 }))
4270 }
4271 1 => std::future::pending().await,
4272 2 => {
4273 self.step = 3;
4274 Ok(Some(ChildFrame::Terminal {
4275 status: TerminalStatus::Completed,
4276 result: Some("done".into()),
4277 error: None,
4278 transcript: vec![],
4279 }))
4280 }
4281 _ => std::future::pending().await,
4282 }
4283 }
4284 }
4285
4286 #[tokio::test]
4287 async fn drive_routes_forced_ask_to_parent_reviewer_without_human_event() {
4288 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
4289 let (review_tx, mut review_rx) = mpsc::unbounded_channel();
4290 let reviewer: Arc<dyn ChildApprovalReviewer> = Arc::new(RecordingReviewer {
4291 reviewed: review_tx,
4292 });
4293 let cancel = CancellationToken::new();
4294 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4295 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4296 let mut logical_session = Session::new("child-reviewer", "model");
4297 let live_guard = crate::external_agents::live::register("child-reviewer", live_tx, 0, None);
4298 let mut link = ApprovalRoundTripLink {
4299 step: 0,
4300 approval_reply: None,
4301 };
4302
4303 let result = tokio::time::timeout(
4304 Duration::from_secs(1),
4305 drive(ActorDriveContext {
4306 client: &mut link,
4307 parent_session_id: "parent-reviewer",
4308 child_session_id: "child-reviewer",
4309 child_attempt: 0,
4310 approval_registry: None,
4311 approval_decider: None,
4312 approval_reviewer: Some(&reviewer),
4313 escalation_bridge: None,
4314 event_tx: &event_tx,
4315 cancel_token: &cancel,
4316 live_rx: &mut live_rx,
4317 delivery_rx: &mut delivery_rx,
4318 logical_session: &mut logical_session,
4319 expected_permission_posture: None,
4320 session_inbox_runtime: None,
4321 activation_run_id: None,
4322 initial_inflight_claims: VecDeque::new(),
4323 first_frame_timeout: None,
4324 }),
4325 )
4326 .await
4327 .expect("worker must receive the reviewer verdict before terminating");
4328
4329 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
4330 assert_eq!(
4331 link.approval_reply,
4332 Some(("approval-1".to_string(), true)),
4333 "reviewer verdict must traverse the live route back to the worker"
4334 );
4335 let (parent, child, body) = tokio::time::timeout(Duration::from_secs(1), review_rx.recv())
4336 .await
4337 .expect("reviewer should be invoked off-loop")
4338 .expect("review channel should remain open");
4339 assert_eq!(parent, "parent-reviewer");
4340 assert_eq!(child, "child-reviewer");
4341 assert_eq!(
4342 body.pointer("/permission_request/reason_code")
4343 .and_then(serde_json::Value::as_str),
4344 Some("hard_dangerous")
4345 );
4346 assert!(
4347 event_rx.try_recv().is_err(),
4348 "must not emit a human-review event"
4349 );
4350 drop(live_guard);
4351 }
4352
4353 #[tokio::test]
4354 async fn drive_denies_forced_ask_without_parent_reviewer_or_manual_event() {
4355 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
4356 let cancel = CancellationToken::new();
4357 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4358 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4359 let mut logical_session = Session::new("child-no-reviewer", "model");
4360 let mut link = ApprovalRoundTripLink {
4361 step: 0,
4362 approval_reply: None,
4363 };
4364
4365 let result = tokio::time::timeout(
4366 Duration::from_secs(1),
4367 drive(ActorDriveContext {
4368 client: &mut link,
4369 parent_session_id: "parent-no-reviewer",
4370 child_session_id: "child-no-reviewer",
4371 child_attempt: 0,
4372 approval_registry: None,
4373 approval_decider: None,
4374 approval_reviewer: None,
4375 escalation_bridge: None,
4376 event_tx: &event_tx,
4377 cancel_token: &cancel,
4378 live_rx: &mut live_rx,
4379 delivery_rx: &mut delivery_rx,
4380 logical_session: &mut logical_session,
4381 expected_permission_posture: None,
4382 session_inbox_runtime: None,
4383 activation_run_id: None,
4384 initial_inflight_claims: VecDeque::new(),
4385 first_frame_timeout: None,
4386 }),
4387 )
4388 .await
4389 .expect("fail-closed reply must unblock the child immediately");
4390
4391 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
4392 assert_eq!(link.approval_reply, Some(("approval-1".to_string(), false)));
4393 assert!(
4394 event_rx.try_recv().is_err(),
4395 "missing parent review must not surface a manual approval event"
4396 );
4397 }
4398 #[async_trait]
4399 impl bamboo_subagent::ChildLink for InstantTerminalLink {
4400 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
4401 Ok(())
4402 }
4403 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
4404 if self.done {
4405 std::future::pending().await
4406 } else {
4407 self.done = true;
4408 Ok(Some(ChildFrame::Terminal {
4409 status: TerminalStatus::Completed,
4410 result: Some("done".into()),
4411 error: None,
4412 transcript: vec![],
4413 }))
4414 }
4415 }
4416 }
4417
4418 #[tokio::test]
4419 async fn drive_trips_first_frame_watchdog_on_a_silent_worker() {
4420 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
4421 let cancel = CancellationToken::new();
4422 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4423 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4424 let mut logical_session = Session::new("child-x", "model");
4425 let mut link = SilentLink;
4426 let r = drive(ActorDriveContext {
4427 client: &mut link,
4428 parent_session_id: "parent-x",
4429 child_session_id: "child-x",
4430 child_attempt: 0,
4431 approval_registry: None,
4432 approval_decider: None,
4433 approval_reviewer: None,
4434 escalation_bridge: None,
4435 event_tx: &event_tx,
4436 cancel_token: &cancel,
4437 live_rx: &mut live_rx,
4438 delivery_rx: &mut delivery_rx,
4439 logical_session: &mut logical_session,
4440 expected_permission_posture: None,
4441 session_inbox_runtime: None,
4442 activation_run_id: None,
4443 initial_inflight_claims: VecDeque::new(),
4444 first_frame_timeout: Some(Duration::from_millis(100)),
4445 })
4446 .await;
4447 assert!(
4448 matches!(r, Err(AgentError::WorkerUnresponsive(_))),
4449 "a silent worker must trip the first-frame watchdog, got {r:?}"
4450 );
4451 }
4452
4453 #[tokio::test]
4454 async fn drive_does_not_trip_when_a_frame_arrives() {
4455 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
4456 let cancel = CancellationToken::new();
4457 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4458 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4459 let mut logical_session = Session::new("child-y", "model");
4460 let mut link = InstantTerminalLink { done: false };
4461 let r = drive(ActorDriveContext {
4464 client: &mut link,
4465 parent_session_id: "parent-y",
4466 child_session_id: "child-y",
4467 child_attempt: 0,
4468 approval_registry: None,
4469 approval_decider: None,
4470 approval_reviewer: None,
4471 escalation_bridge: None,
4472 event_tx: &event_tx,
4473 cancel_token: &cancel,
4474 live_rx: &mut live_rx,
4475 delivery_rx: &mut delivery_rx,
4476 logical_session: &mut logical_session,
4477 expected_permission_posture: None,
4478 session_inbox_runtime: None,
4479 activation_run_id: None,
4480 initial_inflight_claims: VecDeque::new(),
4481 first_frame_timeout: Some(Duration::from_millis(50)),
4482 })
4483 .await;
4484 assert_eq!(r.ok().flatten().as_deref(), Some("done"));
4485 }
4486
4487 #[tokio::test]
4488 async fn child_approval_fails_closed_without_decider() {
4489 let body = serde_json::json!({"tool_name":"Bash","permission":"run","resource":"rm -rf /"});
4491 assert!(!decide_child_approval(None, "child-1", &body).await);
4492 }
4493
4494 #[tokio::test]
4495 async fn child_approval_honors_wired_decider() {
4496 let body =
4497 serde_json::json!({"tool_name":"Write","permission":"write","resource":"/tmp/x"});
4498 let approve: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(true));
4499 let deny: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(false));
4500 assert!(decide_child_approval(Some(&approve), "child-1", &body).await);
4501 assert!(!decide_child_approval(Some(&deny), "child-1", &body).await);
4502 }
4503
4504 use crate::runtime::execution::SpawnJob;
4507 use bamboo_agent_core::Session;
4508
4509 fn bogus_runner(placements: HashMap<String, ResolvedRemotePlacement>) -> ActorChildRunner {
4512 ActorChildRunner::new(
4513 "test-actor".into(),
4514 PathBuf::from("/bin/false"),
4515 vec![],
4516 std::env::temp_dir().join("bamboo-test-fab-193"),
4517 ExecutorSpec::Echo,
4518 vec![],
4519 "anthropic".into(),
4520 4,
4521 )
4522 .with_remote_placements(placements)
4523 }
4524
4525 fn session_of_role(role: &str, assignment: &str) -> Session {
4528 let mut s = Session::new("child-1", "test-model");
4529 s.metadata
4530 .insert("subagent_type".to_string(), role.to_string());
4531 s.add_message(bamboo_agent_core::Message::user(assignment));
4532 s
4533 }
4534
4535 fn job_for(child: &str) -> SpawnJob {
4536 SpawnJob {
4537 parent_session_id: "parent-1".into(),
4538 child_session_id: child.into(),
4539 model: String::new(),
4540 disabled_tools: None,
4541 }
4542 }
4543
4544 #[derive(Default)]
4545 struct RecordingChildSessionPort {
4546 saved: std::sync::Mutex<Option<Session>>,
4547 }
4548
4549 impl RecordingChildSessionPort {
4550 fn saved_child(&self) -> Session {
4551 self.saved
4552 .lock()
4553 .expect("saved-child fixture lock")
4554 .clone()
4555 .expect("create_child_action must save the child")
4556 }
4557 }
4558
4559 #[async_trait]
4560 impl crate::session_app::child_session::ChildSessionPort for RecordingChildSessionPort {
4561 async fn load_root_session(
4562 &self,
4563 _root_id: &str,
4564 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
4565 unreachable!("create_child_action does not load the root")
4566 }
4567
4568 async fn load_child_for_parent(
4569 &self,
4570 _parent_id: &str,
4571 _child_id: &str,
4572 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
4573 unreachable!("create_child_action does not reload the child")
4574 }
4575
4576 async fn save_child_session(
4577 &self,
4578 child: &mut Session,
4579 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
4580 *self.saved.lock().expect("saved-child fixture lock") = Some(child.clone());
4581 Ok(())
4582 }
4583
4584 async fn save_child_session_authoritative_flags(
4585 &self,
4586 _child: &mut Session,
4587 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
4588 unreachable!("new-child creation uses the ordinary save")
4589 }
4590
4591 async fn is_child_running(&self, _child_id: &str) -> bool {
4592 false
4593 }
4594
4595 async fn list_children(
4596 &self,
4597 _parent_id: &str,
4598 ) -> Vec<crate::session_app::child_session::ChildSessionEntry> {
4599 Vec::new()
4600 }
4601
4602 async fn enqueue_child_run(
4603 &self,
4604 _parent: &Session,
4605 _child: &Session,
4606 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
4607 unreachable!("fixture creates the child with auto_run=false")
4608 }
4609
4610 async fn cancel_child_run_and_wait(
4611 &self,
4612 _child_id: &str,
4613 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
4614 unreachable!("create_child_action does not cancel")
4615 }
4616
4617 async fn delete_child_session(
4618 &self,
4619 _parent_id: &str,
4620 _child_id: &str,
4621 ) -> Result<
4622 crate::session_app::child_session::DeleteChildResult,
4623 crate::session_app::child_session::ChildSessionError,
4624 > {
4625 unreachable!("create_child_action does not delete")
4626 }
4627
4628 async fn get_child_runner_info(
4629 &self,
4630 _child_id: &str,
4631 ) -> Option<crate::session_app::child_session::ChildRunnerInfo> {
4632 None
4633 }
4634
4635 async fn register_parent_wait_for_child(
4636 &self,
4637 _parent_session_id: &str,
4638 _child_session_id: &str,
4639 _tool_call_id: Option<&str>,
4640 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
4641 unreachable!("create_child_action does not register a wait")
4642 }
4643
4644 async fn register_parent_wait_for_children(
4645 &self,
4646 _parent_session_id: &str,
4647 _child_session_ids: &[String],
4648 _policy: bamboo_domain::session::runtime_state::ChildWaitPolicy,
4649 ) -> Result<usize, crate::session_app::child_session::ChildSessionError> {
4650 unreachable!("create_child_action does not register a wait")
4651 }
4652
4653 async fn active_child_ids(&self, _parent_session_id: &str) -> Vec<String> {
4654 Vec::new()
4655 }
4656
4657 async fn find_resident_child(
4658 &self,
4659 _root_session_id: &str,
4660 _resident_name: &str,
4661 ) -> Option<String> {
4662 None
4663 }
4664
4665 async fn ensure_child_indexed(&self, _child_session_id: &str) {}
4666 }
4667
4668 #[test]
4669 fn build_spec_sets_remote_placement_for_matching_role() {
4670 let mut placements = HashMap::new();
4671 placements.insert(
4672 "explorer".to_string(),
4673 ResolvedRemotePlacement {
4674 endpoint: "wss://gpu-host:8443".into(),
4675 token: Some("T-secret".into()),
4676 ca_cert_file: None,
4677 host_label: None,
4678 },
4679 );
4680 let runner = bogus_runner(placements);
4681
4682 let s = session_of_role("explorer", "do the thing");
4684 let spec = runner.build_spec(&s, &job_for("child-1"));
4685 match &spec.placement {
4686 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://gpu-host:8443"),
4687 other => panic!("expected Remote, got {other:?}"),
4688 }
4689 assert_eq!(spec.secrets.worker_auth_token.as_deref(), Some("T-secret"));
4690 }
4691
4692 #[test]
4693 fn build_spec_leaves_local_for_unmatched_role() {
4694 let mut placements = HashMap::new();
4695 placements.insert(
4696 "explorer".to_string(),
4697 ResolvedRemotePlacement {
4698 endpoint: "wss://gpu-host:8443".into(),
4699 token: Some("T".into()),
4700 ca_cert_file: None,
4701 host_label: None,
4702 },
4703 );
4704 let runner = bogus_runner(placements);
4705
4706 let s = session_of_role("writer", "do the thing");
4708 let spec = runner.build_spec(&s, &job_for("child-1"));
4709 assert_eq!(spec.placement, Placement::Local);
4710 assert!(spec.secrets.worker_auth_token.is_none());
4711 }
4712
4713 #[test]
4714 fn build_spec_local_when_no_placements() {
4715 let runner = bogus_runner(HashMap::new());
4716 let s = session_of_role("explorer", "do the thing");
4717 let spec = runner.build_spec(&s, &job_for("child-1"));
4718 assert_eq!(spec.placement, Placement::Local);
4719 assert!(spec.secrets.worker_auth_token.is_none());
4720 }
4721
4722 #[tokio::test]
4723 async fn build_spec_preserves_exact_inherited_permission_mode_for_child_worker() {
4724 for (label, mode) in [
4728 ("bypass", bamboo_domain::SessionPermissionMode::Bypass),
4729 ("auto", bamboo_domain::SessionPermissionMode::Auto),
4730 ] {
4731 let runner = bogus_runner(HashMap::new());
4732 let mut parent = Session::new(format!("parent-{label}"), "test-model");
4733 parent
4734 .agent_runtime_state
4735 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
4736 .set_permission_mode(mode);
4737 let workspace = tempfile::tempdir().expect("workspace fixture");
4738 let port = RecordingChildSessionPort::default();
4739 let child_id = format!("child-{label}-{}", uuid::Uuid::new_v4());
4740 crate::session_app::child_session::create_child_action(
4741 &port,
4742 crate::session_app::child_session::CreateChildInput {
4743 parent_session: parent,
4744 child_id: child_id.clone(),
4745 title: format!("{label} child"),
4746 responsibility: "Run ordinary commands".to_string(),
4747 assignment_prompt: "run an ordinary command".to_string(),
4748 subagent_type: "explorer".to_string(),
4749 workspace: workspace.path().to_string_lossy().into_owned(),
4750 workspace_source: crate::project_context::WorkspaceSource::Explicit,
4751 model_override: None,
4752 model_ref_override: None,
4753 runtime_metadata: HashMap::new(),
4754 auto_run: false,
4755 reasoning_effort: None,
4756 lifecycle: None,
4757 resident_name: None,
4758 resident_context: None,
4759 disabled_tools: None,
4760 context_fork: None,
4761 },
4762 )
4763 .await
4764 .expect("create inherited-permission child");
4765 let child = port.saved_child();
4766
4767 assert_eq!(
4768 child
4769 .agent_runtime_state
4770 .as_ref()
4771 .map(bamboo_domain::AgentRuntimeState::effective_permission_mode),
4772 Some(mode),
4773 "create_child_action must inherit {label} from the parent"
4774 );
4775
4776 let spec = runner.build_spec(&child, &job_for(&child_id));
4777
4778 assert_eq!(
4779 spec.capabilities.bypass,
4780 mode == bamboo_domain::SessionPermissionMode::Bypass
4781 );
4782 assert_eq!(
4783 spec.capabilities.auto_approve_permissions,
4784 mode == bamboo_domain::SessionPermissionMode::Auto
4785 );
4786 assert!(
4787 spec.capabilities.enforce_permissions,
4788 "policy evaluation must remain active under {label}"
4789 );
4790 }
4791 }
4792
4793 #[tokio::test]
4794 async fn child_resident_and_guardian_inherit_project_through_actor_run_spec() {
4795 let project_id = bamboo_domain::ProjectId::parse("project-inherited").expect("Project id");
4796 let workspace = tempfile::tempdir().expect("workspace fixture");
4797
4798 for (role, lifecycle, resident_name) in [
4799 ("explorer", None, None),
4800 ("resident", Some("resident"), Some("stable-reviewer")),
4801 ("guardian", None, None),
4802 ] {
4803 let mut parent = Session::new(format!("parent-{role}"), "test-model");
4804 parent.set_project_id_meta(project_id.to_string());
4805 let port = RecordingChildSessionPort::default();
4806 let child_id = format!("child-{role}-{}", uuid::Uuid::new_v4());
4807 crate::session_app::child_session::create_child_action(
4808 &port,
4809 crate::session_app::child_session::CreateChildInput {
4810 parent_session: parent,
4811 child_id: child_id.clone(),
4812 title: format!("{role} child"),
4813 responsibility: "Review the assigned work".to_string(),
4814 assignment_prompt: "inspect the change".to_string(),
4815 subagent_type: role.to_string(),
4816 workspace: workspace.path().to_string_lossy().into_owned(),
4817 workspace_source: crate::project_context::WorkspaceSource::Explicit,
4818 model_override: None,
4819 model_ref_override: None,
4820 runtime_metadata: HashMap::new(),
4821 auto_run: false,
4822 reasoning_effort: None,
4823 lifecycle: lifecycle.map(str::to_string),
4824 resident_name: resident_name.map(str::to_string),
4825 resident_context: None,
4826 disabled_tools: None,
4827 context_fork: None,
4828 },
4829 )
4830 .await
4831 .expect("create Project-inheriting child");
4832 let child = port.saved_child();
4833 assert_eq!(
4834 crate::project_context::ProjectContextResolver::project_id_from_session(&child),
4835 Some(project_id.clone()),
4836 "{role} child must inherit its parent's Project"
4837 );
4838
4839 assert_eq!(
4840 project_id_for_actor_run(&child).expect("valid actor Project identity"),
4841 Some(project_id.clone()),
4842 "{role} actor RunSpec must preserve inherited Project identity"
4843 );
4844 }
4845 }
4846
4847 #[test]
4848 fn placement_metadata_stamps_remote_and_schedulable_not_local() {
4849 assert_eq!(placement_metadata(&Placement::Local, None), None);
4851
4852 let r = placement_metadata(
4854 &Placement::Remote {
4855 endpoint: "wss://10.0.0.5:8443/stream".into(),
4856 },
4857 None,
4858 )
4859 .unwrap();
4860 assert!(r.contains(r#""kind":"remote""#), "{r}");
4861 assert!(r.contains(r#""host":"10.0.0.5""#), "{r}");
4862
4863 let labeled = placement_metadata(
4865 &Placement::Remote {
4866 endpoint: "ws://169.254.230.101:8899".into(),
4867 },
4868 Some("mini"),
4869 )
4870 .unwrap();
4871 assert!(labeled.contains(r#""host":"mini""#), "{labeled}");
4872
4873 let s = placement_metadata(
4875 &Placement::Schedulable {
4876 pool: "explorers".into(),
4877 },
4878 Some("mini"),
4879 )
4880 .unwrap();
4881 assert!(s.contains(r#""kind":"remote""#), "{s}");
4882 assert!(s.contains(r#""host":"mini""#), "{s}");
4883
4884 let p: bamboo_storage::SessionPlacement = serde_json::from_str(&labeled).unwrap();
4886 assert_eq!(p.kind, "remote");
4887 assert_eq!(p.host, "mini");
4888 }
4889
4890 #[tokio::test]
4897 async fn execute_external_child_routes_role_to_remote_worker_without_spawning() {
4898 let token = "remote-test-token";
4900 let server = bamboo_subagent::transport::WsServer::bind_with_token(
4901 (std::net::Ipv4Addr::LOCALHOST, 0).into(),
4902 Some(token.to_string()),
4903 )
4904 .await
4905 .expect("bind resident worker");
4906 let endpoint = server.ws_endpoint(); let srv = tokio::spawn(async move {
4908 let _ = server
4910 .serve(Arc::new(bamboo_subagent::executor::EchoExecutor))
4911 .await;
4912 });
4913
4914 let mut placements = HashMap::new();
4916 placements.insert(
4917 "explorer".to_string(),
4918 ResolvedRemotePlacement {
4919 endpoint: endpoint.clone(),
4920 token: Some(token.to_string()),
4921 ca_cert_file: None,
4922 host_label: Some("mini-e2e".into()), },
4924 );
4925 let runner = bogus_runner(placements);
4926
4927 let mut session = session_of_role("explorer", "hello remote");
4929 let job = job_for("child-1");
4930 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(64);
4931 let cancel = CancellationToken::new();
4932
4933 let result = tokio::time::timeout(
4934 Duration::from_secs(10),
4935 runner.execute_external_child(&mut session, &job, event_tx, cancel),
4936 )
4937 .await
4938 .expect("run did not hang")
4939 .expect("remote run succeeded (connected to resident worker, did not spawn)");
4940
4941 let _ = result;
4942 let last = session
4945 .messages
4946 .iter()
4947 .rev()
4948 .find(|m| matches!(m.role, Role::Assistant))
4949 .expect("an assistant reply was written back");
4950 assert!(
4951 last.content.contains("echo:"),
4952 "expected echo reply, got {:?}",
4953 last.content
4954 );
4955
4956 let placement = session
4959 .metadata
4960 .get("placement")
4961 .expect("remote child session stamped with a placement");
4962 assert!(placement.contains(r#""kind":"remote""#), "{placement}");
4963 assert!(placement.contains(r#""host":"mini-e2e""#), "{placement}");
4964
4965 let mut saw_event = false;
4968 while let Ok(Some(_ev)) =
4969 tokio::time::timeout(Duration::from_millis(50), event_rx.recv()).await
4970 {
4971 saw_event = true;
4972 }
4973 let _ = saw_event;
4974
4975 srv.abort();
4976 }
4977
4978 fn bogus_sched_runner(
4984 remote: HashMap<String, ResolvedRemotePlacement>,
4985 sched: HashMap<String, ResolvedSchedulablePlacement>,
4986 ) -> ActorChildRunner {
4987 ActorChildRunner::new(
4988 "test-actor".into(),
4989 PathBuf::from("/bin/false"),
4990 vec![],
4991 std::env::temp_dir().join("bamboo-test-fab-181"),
4992 ExecutorSpec::Echo,
4993 vec![],
4994 "anthropic".into(),
4995 4,
4996 )
4997 .with_remote_placements(remote)
4998 .with_schedulable_placements(sched)
4999 }
5000
5001 fn sched_placement(
5002 pool: &str,
5003 _registry_url: impl Into<String>,
5004 ) -> ResolvedSchedulablePlacement {
5005 ResolvedSchedulablePlacement {
5006 pool: pool.into(),
5007 host_label: None,
5008 }
5009 }
5010
5011 #[test]
5012 fn build_spec_sets_schedulable_placement_for_matching_role() {
5013 let mut sched = HashMap::new();
5014 sched.insert(
5015 "explorer".to_string(),
5016 sched_placement("gpu-pool", "unused"),
5017 );
5018 let runner = bogus_sched_runner(HashMap::new(), sched);
5019
5020 let s = session_of_role("explorer", "do the thing");
5021 let spec = runner.build_spec(&s, &job_for("child-1"));
5022 match &spec.placement {
5023 Placement::Schedulable { pool } => assert_eq!(pool, "gpu-pool"),
5024 other => panic!("expected Schedulable, got {other:?}"),
5025 }
5026 assert!(spec.secrets.worker_auth_token.is_none());
5028 }
5029
5030 #[test]
5031 fn build_spec_remote_wins_when_role_in_both_maps() {
5032 let mut remote = HashMap::new();
5035 remote.insert(
5036 "explorer".to_string(),
5037 ResolvedRemotePlacement {
5038 endpoint: "wss://fixed-host:8443".into(),
5039 token: Some("T-remote".into()),
5040 ca_cert_file: None,
5041 host_label: None,
5042 },
5043 );
5044 let mut sched = HashMap::new();
5045 sched.insert(
5046 "explorer".to_string(),
5047 sched_placement("gpu-pool", "https://control-plane:9562"),
5048 );
5049 let runner = bogus_sched_runner(remote, sched);
5050
5051 let s = session_of_role("explorer", "do the thing");
5052 let spec = runner.build_spec(&s, &job_for("child-1"));
5053 match &spec.placement {
5054 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://fixed-host:8443"),
5055 other => panic!("expected Remote (precedence), got {other:?}"),
5056 }
5057 assert_eq!(spec.secrets.worker_auth_token.as_deref(), Some("T-remote"));
5058 }
5059
5060 #[test]
5061 fn build_spec_local_for_unmatched_schedulable_role() {
5062 let mut sched = HashMap::new();
5063 sched.insert(
5064 "explorer".to_string(),
5065 sched_placement("gpu-pool", "https://control-plane:9562"),
5066 );
5067 let runner = bogus_sched_runner(HashMap::new(), sched);
5068 let s = session_of_role("writer", "do the thing");
5069 let spec = runner.build_spec(&s, &job_for("child-1"));
5070 assert_eq!(spec.placement, Placement::Local);
5071 assert!(spec.secrets.worker_auth_token.is_none());
5072 }
5073
5074 #[test]
5079 fn placement_stamp_uses_node_label_for_remote_and_schedulable() {
5080 let mut remote = HashMap::new();
5082 remote.insert(
5083 "explorer".to_string(),
5084 ResolvedRemotePlacement {
5085 endpoint: "ws://169.254.230.101:8899".into(),
5086 token: None,
5087 ca_cert_file: None,
5088 host_label: Some("mini".into()),
5089 },
5090 );
5091 let runner = bogus_runner(remote);
5092 let spec = runner.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
5093 let stamp = runner
5094 .placement_stamp_for(&spec)
5095 .expect("remote child is stamped");
5096 assert!(stamp.contains(r#""kind":"remote""#), "{stamp}");
5097 assert!(stamp.contains(r#""host":"mini""#), "{stamp}");
5098
5099 let mut remote_nolabel = HashMap::new();
5101 remote_nolabel.insert(
5102 "explorer".to_string(),
5103 ResolvedRemotePlacement {
5104 endpoint: "ws://169.254.230.101:8899".into(),
5105 token: None,
5106 ca_cert_file: None,
5107 host_label: None,
5108 },
5109 );
5110 let r2 = bogus_runner(remote_nolabel);
5111 let spec2 = r2.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
5112 assert!(r2
5113 .placement_stamp_for(&spec2)
5114 .unwrap()
5115 .contains(r#""host":"169.254.230.101""#));
5116
5117 let mut sched = HashMap::new();
5119 sched.insert(
5120 "mac-mini-monitor".to_string(),
5121 ResolvedSchedulablePlacement {
5122 pool: "mac-mini-monitor".into(),
5123 host_label: Some("mini".into()),
5124 },
5125 );
5126 let sr = bogus_sched_runner(HashMap::new(), sched);
5127 let spec3 = sr.build_spec(&session_of_role("mac-mini-monitor", "go"), &job_for("c1"));
5128 let stamp3 = sr
5129 .placement_stamp_for(&spec3)
5130 .expect("scheduled child is stamped");
5131 assert!(stamp3.contains(r#""kind":"remote""#), "{stamp3}");
5132 assert!(stamp3.contains(r#""host":"mini""#), "{stamp3}");
5133
5134 let local = bogus_runner(HashMap::new());
5136 let spec4 = local.build_spec(&session_of_role("writer", "go"), &job_for("c1"));
5137 assert_eq!(local.placement_stamp_for(&spec4), None);
5138 }
5139
5140 async fn start_bus() -> (String, tempfile::TempDir) {
5143 let dir = tempfile::tempdir().unwrap();
5144 let core = std::sync::Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
5145 let server = std::sync::Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
5146 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5147 let addr = listener.local_addr().unwrap();
5148 tokio::spawn(async move {
5149 let _ = server.serve(listener).await;
5150 });
5151 (format!("ws://{addr}"), dir)
5152 }
5153
5154 async fn join_pool(endpoint: &str, id: &str, pool: &str) -> bamboo_broker::BrokerClient {
5155 let mut c = bamboo_broker::BrokerClient::connect(
5156 endpoint,
5157 bamboo_subagent::AgentRef {
5158 session_id: id.into(),
5159 role: Some(pool.into()),
5160 },
5161 "t",
5162 )
5163 .await
5164 .unwrap();
5165 c.subscribe().await.unwrap();
5166 c
5167 }
5168
5169 fn sched_runner_on_bus(endpoint: &str, child_role: &str, pool: &str) -> ActorChildRunner {
5170 let mut sched = HashMap::new();
5171 sched.insert(child_role.to_string(), sched_placement(pool, "unused"));
5172 bogus_sched_runner(HashMap::new(), sched).with_bus(Some(bamboo_subagent::BusEndpoint {
5173 endpoint: endpoint.into(),
5174 token: "t".into(),
5175 }))
5176 }
5177
5178 #[tokio::test]
5179 async fn resolve_schedulable_picks_a_live_bus_worker() {
5180 let (endpoint, _dir) = start_bus().await;
5181 let _w = join_pool(&endpoint, "w-gpu", "gpu-pool").await;
5182 let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
5183
5184 let mailbox = runner
5185 .resolve_schedulable_worker("explorer")
5186 .await
5187 .expect("a live pool worker is found on the bus");
5188 assert_eq!(mailbox, "w-gpu");
5189 }
5190
5191 #[tokio::test]
5192 async fn resolve_schedulable_round_robins_over_pool_workers() {
5193 let (endpoint, _dir) = start_bus().await;
5194 let _a = join_pool(&endpoint, "w-a", "gpu-pool").await;
5195 let _b = join_pool(&endpoint, "w-b", "gpu-pool").await;
5196 let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
5197
5198 let mut picked = std::collections::HashSet::new();
5200 for _ in 0..6 {
5201 picked.insert(runner.resolve_schedulable_worker("explorer").await.unwrap());
5202 }
5203 assert_eq!(
5204 picked,
5205 ["w-a".to_string(), "w-b".to_string()].into_iter().collect(),
5206 "round-robin must cover every connected pool worker"
5207 );
5208 }
5209
5210 #[tokio::test]
5211 async fn resolve_schedulable_errors_on_empty_pool() {
5212 let (endpoint, _dir) = start_bus().await;
5213 let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
5215
5216 let err = runner
5217 .resolve_schedulable_worker("explorer")
5218 .await
5219 .expect_err("an empty pool is terminal — no local fallback")
5220 .to_string();
5221 assert!(err.contains("no live worker in pool"), "got: {err}");
5222 assert!(err.contains("NOT spawning"), "got: {err}");
5223 }
5224
5225 #[tokio::test]
5232 async fn execute_external_child_runs_schedulable_over_bus_and_stamps_node_label() {
5233 let (endpoint, _dir) = start_bus().await;
5234
5235 let ep = endpoint.clone();
5237 let worker = tokio::spawn(async move {
5238 let _ = bamboo_broker::serve_executor(
5239 &ep,
5240 bamboo_subagent::AgentRef {
5241 session_id: "mmm-worker".into(),
5242 role: Some("mac-mini-monitor".into()),
5243 },
5244 "t",
5245 std::sync::Arc::new(bamboo_subagent::executor::EchoExecutor),
5246 )
5247 .await;
5248 });
5249
5250 let mut probe = bamboo_broker::BrokerClient::connect(
5253 &endpoint,
5254 bamboo_subagent::AgentRef {
5255 session_id: "probe".into(),
5256 role: None,
5257 },
5258 "t",
5259 )
5260 .await
5261 .unwrap();
5262 let mut ready = false;
5263 for _ in 0..100 {
5264 if probe
5265 .list_connected("mac-mini-monitor")
5266 .await
5267 .unwrap()
5268 .iter()
5269 .any(|id| id == "mmm-worker")
5270 {
5271 ready = true;
5272 break;
5273 }
5274 tokio::time::sleep(Duration::from_millis(30)).await;
5275 }
5276 assert!(ready, "worker never joined the pool");
5277
5278 let mut sched = HashMap::new();
5281 sched.insert(
5282 "mac-mini-monitor".to_string(),
5283 ResolvedSchedulablePlacement {
5284 pool: "mac-mini-monitor".into(),
5285 host_label: Some("mini".into()),
5286 },
5287 );
5288 let runner = bogus_sched_runner(HashMap::new(), sched).with_bus(Some(
5289 bamboo_subagent::BusEndpoint {
5290 endpoint: endpoint.clone(),
5291 token: "t".into(),
5292 },
5293 ));
5294
5295 let mut session = session_of_role("mac-mini-monitor", "hello scheduled");
5296 let job = job_for("child-1");
5297 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(64);
5298 let cancel = CancellationToken::new();
5299
5300 tokio::time::timeout(
5301 Duration::from_secs(10),
5302 runner.execute_external_child(&mut session, &job, event_tx, cancel),
5303 )
5304 .await
5305 .expect("run did not hang")
5306 .expect("schedulable run succeeded over the bus (no local spawn)");
5307
5308 let last = session
5310 .messages
5311 .iter()
5312 .rev()
5313 .find(|m| matches!(m.role, Role::Assistant))
5314 .expect("an assistant reply was written back");
5315 assert!(last.content.contains("echo:"), "got {:?}", last.content);
5316
5317 let placement = session
5319 .metadata
5320 .get("placement")
5321 .expect("scheduled child session stamped with a placement");
5322 assert!(placement.contains(r#""kind":"remote""#), "{placement}");
5323 assert!(placement.contains(r#""host":"mini""#), "{placement}");
5324
5325 worker.abort();
5326 }
5327}