1use std::sync::Arc;
2
3use crate::runtime::execution::{ExternalChildRunner, SessionInboxRuntimeBinding, SpawnJob};
4use async_trait::async_trait;
5use bamboo_a2a::A2AJsonRpcClient;
6use bamboo_agent_core::{AgentError, AgentEvent};
7use bamboo_llm::Config;
8use tokio::sync::mpsc;
9use tokio_util::sync::CancellationToken;
10
11use super::a2a_adapter::A2AExternalChildRunner;
12use super::actor_adapter::{ActorChildRunner, ChildApprovalReviewer, CodexRunTokenAuthority};
13use super::config::{parse_external_agents, ExternalAgentProtocol};
14
15#[async_trait]
18pub trait NativeToolCeilingSource: Send + Sync {
19 async fn observe(&self, session: &bamboo_domain::Session) -> Result<Vec<String>, String>;
20}
21
22pub fn uses_legacy_default_child_runtime(config: &Config) -> bool {
26 let subagents = config.subagents();
27 subagents.worker_bin.is_some()
28 || subagents.worker_args.is_some()
29 || !matches!(subagents.executor.as_deref(), None | Some("bamboo_runtime"))
30}
31
32pub fn requires_legacy_child_runtime(config: &Config) -> bool {
36 let subagents = config.subagents();
37 uses_legacy_default_child_runtime(config)
38 || !subagents.remote_placements.is_empty()
39 || !subagents.schedulable_placements.is_empty()
40 || !config.cluster_fabric.nodes.is_empty()
41 || parse_external_agents(config)
42 .values()
43 .any(|profile| matches!(profile.protocol, ExternalAgentProtocol::Actor))
44}
45
46fn codex_auth_mode_name(mode: bamboo_config::CodexAuthMode) -> String {
47 match mode {
48 bamboo_config::CodexAuthMode::Inherit => "inherit",
49 bamboo_config::CodexAuthMode::ApiKey => "api_key",
50 bamboo_config::CodexAuthMode::Custom => "custom",
51 bamboo_config::CodexAuthMode::Bamboo => "bamboo",
52 }
53 .to_string()
54}
55
56fn codex_wire_api_name(wire_api: bamboo_config::CodexWireApi) -> String {
57 match wire_api {
58 bamboo_config::CodexWireApi::Responses => "responses",
59 }
60 .to_string()
61}
62
63fn codex_mode_name(mode: bamboo_config::CodexMode) -> String {
64 match mode {
65 bamboo_config::CodexMode::Exec => "exec",
66 bamboo_config::CodexMode::AppServer => "app_server",
67 }
68 .to_string()
69}
70
71fn codex_sandbox_name(sandbox: bamboo_config::CodexSandbox) -> String {
72 match sandbox {
73 bamboo_config::CodexSandbox::ReadOnly => "read-only",
74 bamboo_config::CodexSandbox::WorkspaceWrite => "workspace-write",
75 bamboo_config::CodexSandbox::DangerFullAccess => "danger-full-access",
76 }
77 .to_string()
78}
79
80fn codex_approval_policy_name(policy: bamboo_config::CodexApprovalPolicy) -> String {
81 match policy {
82 bamboo_config::CodexApprovalPolicy::Never => "never",
83 bamboo_config::CodexApprovalPolicy::OnFailure => "on-failure",
84 bamboo_config::CodexApprovalPolicy::OnRequest => "on-request",
85 }
86 .to_string()
87}
88
89fn codex_base_url(
90 config: &Config,
91 mode: bamboo_config::CodexAuthMode,
92 custom: Option<String>,
93) -> Option<String> {
94 match mode {
95 bamboo_config::CodexAuthMode::Custom => custom,
96 bamboo_config::CodexAuthMode::Bamboo => {
97 let scheme = if config.server.tls.is_some() {
98 "https"
99 } else {
100 "http"
101 };
102 Some(format!(
103 "{scheme}://127.0.0.1:{}/openai/v1",
104 config.server.port
105 ))
106 }
107 bamboo_config::CodexAuthMode::Inherit | bamboo_config::CodexAuthMode::ApiKey => None,
108 }
109}
110
111pub struct CompositeExternalChildRunner {
113 runners: Vec<Arc<dyn ExternalChildRunner>>,
114 store: std::sync::RwLock<Option<Arc<bamboo_storage::SessionStoreV2>>>,
115}
116
117impl CompositeExternalChildRunner {
118 pub fn new(runners: Vec<Arc<dyn ExternalChildRunner>>) -> Self {
119 Self {
120 runners,
121 store: Default::default(),
122 }
123 }
124
125 async fn check_observer_route(&self, session: &bamboo_domain::Session) -> Result<(), String> {
126 use bamboo_domain::Storage;
127 let store = self
128 .store
129 .read()
130 .map_err(|_| "Child store lock is poisoned")?
131 .clone();
132 if let Some(store) = store {
133 if store
134 .supervisor_observer_activation(session)
135 .await
136 .map_err(|error| error.to_string())?
137 && !self.supports_native_ticket_budget()
138 {
139 return Err(
140 "registered Observers require the supported native-only backend".into(),
141 );
142 }
143 }
144 Ok(())
145 }
146}
147
148#[async_trait]
149impl ExternalChildRunner for CompositeExternalChildRunner {
150 fn native_task_start(
151 &self,
152 session_id: &str,
153 run_id: &str,
154 child_birth: chrono::DateTime<chrono::Utc>,
155 ) -> Option<crate::execution::spawn::NativeTaskStart> {
156 if !self.supports_native_ticket_budget() {
157 return None;
158 }
159 self.runners[0].native_task_start(session_id, run_id, child_birth)
160 }
161
162 fn native_task_liveness(
163 &self,
164 session_id: &str,
165 run_id: &str,
166 child_birth: chrono::DateTime<chrono::Utc>,
167 ) -> Option<crate::execution::spawn::NativeTaskLiveness> {
168 if !self.supports_native_ticket_budget() {
169 return None;
170 }
171 self.runners[0].native_task_liveness(session_id, run_id, child_birth)
172 }
173
174 fn supports_native_ticket_budget(&self) -> bool {
175 self.runners.len() == 1 && self.runners[0].supports_native_ticket_budget()
178 }
179
180 fn set_local_child_skill_factory(
181 &self,
182 factory: Option<Arc<dyn super::local_task::LocalChildSkillFactory>>,
183 ) {
184 for runner in &self.runners {
185 runner.set_local_child_skill_factory(factory.clone());
186 }
187 }
188
189 fn set_local_task_runtime(&self, runtime: Option<super::local_task::LocalTaskRuntime>) {
190 for runner in &self.runners {
191 runner.set_local_task_runtime(runtime.clone());
192 }
193 }
194
195 async fn prepare_child_execution(
196 &self,
197 session: &bamboo_agent_core::Session,
198 run_id: &str,
199 cancel: CancellationToken,
200 ) -> Result<Option<Arc<dyn bamboo_domain::RuntimeSessionPersistence>>, String> {
201 self.check_observer_route(session).await?;
202 for runner in &self.runners {
203 if runner.should_handle(session).await {
204 return runner
205 .prepare_child_execution(session, run_id, cancel)
206 .await;
207 }
208 }
209 Err("No child execution backend matched the canonical Session".into())
210 }
211
212 async fn abandon_child_execution(
213 &self,
214 session: &bamboo_agent_core::Session,
215 run_id: &str,
216 ) -> Result<(), String> {
217 for runner in &self.runners {
218 if runner.should_handle(session).await {
219 return runner.abandon_child_execution(session, run_id).await;
220 }
221 }
222 Err("Child execution backend disappeared before rollback".into())
223 }
224
225 fn quarantine_child_execution(&self, session: &bamboo_agent_core::Session, run_id: &str) {
226 for runner in &self.runners {
227 runner.quarantine_child_execution(session, run_id);
228 }
229 }
230
231 fn track_child_task(
232 &self,
233 session_id: &str,
234 run_id: &str,
235 mut handle: tokio::task::JoinHandle<()>,
236 ) -> Option<tokio::task::JoinHandle<()>> {
237 for runner in &self.runners {
238 handle = runner.track_child_task(session_id, run_id, handle)?;
239 }
240 Some(handle)
241 }
242
243 async fn finish_child_execution(
244 &self,
245 session: &bamboo_agent_core::Session,
246 run_id: &str,
247 history_committed: bool,
248 ) -> Result<(), String> {
249 for runner in &self.runners {
250 if runner.should_handle(session).await {
251 return runner
252 .finish_child_execution(session, run_id, history_committed)
253 .await;
254 }
255 }
256 Err("Child execution backend disappeared before finalization".into())
257 }
258
259 async fn shutdown(&self) -> Result<(), String> {
260 let mut failures = Vec::new();
261 for runner in &self.runners {
262 if let Err(error) = runner.shutdown().await {
263 failures.push(error);
264 }
265 }
266 if failures.is_empty() {
267 Ok(())
268 } else {
269 Err(failures.join("; "))
270 }
271 }
272
273 fn set_ticket_service(&self, service: Option<Arc<bamboo_tickets::TicketService>>) {
274 for runner in &self.runners {
275 runner.set_ticket_service(service.clone());
276 }
277 }
278
279 async fn validate_required_child_context_route(
280 &self,
281 session: &bamboo_agent_core::Session,
282 ) -> Result<(), String> {
283 for runner in &self.runners {
284 if runner.should_handle(session).await {
285 return runner.validate_required_child_context_route(session).await;
286 }
287 }
288 Err("required_child_context_unsupported: no matching registered worker route".into())
289 }
290 async fn should_handle(&self, session: &bamboo_agent_core::Session) -> bool {
291 for runner in &self.runners {
292 if runner.should_handle(session).await {
293 return true;
294 }
295 }
296 false
297 }
298
299 async fn execute_external_child(
300 &self,
301 session: &mut bamboo_agent_core::Session,
302 job: &SpawnJob,
303 event_tx: mpsc::Sender<AgentEvent>,
304 cancel_token: CancellationToken,
305 ) -> crate::runtime::runner::Result<()> {
306 self.check_observer_route(session)
307 .await
308 .map_err(AgentError::LLM)?;
309 for runner in &self.runners {
310 if runner.should_handle(session).await {
311 return runner
312 .execute_external_child(session, job, event_tx, cancel_token)
313 .await;
314 }
315 }
316 Err(AgentError::LLM(
317 "No matching external child runner found for session metadata".to_string(),
318 ))
319 }
320
321 async fn commit_durable_child_delivery(
322 &self,
323 session: &bamboo_agent_core::Session,
324 activation_run_id: &str,
325 ) -> Result<(), String> {
326 for runner in &self.runners {
327 runner
328 .commit_durable_child_delivery(session, activation_run_id)
329 .await?;
330 }
331 Ok(())
332 }
333
334 async fn confirm_durable_child_delivery(
335 &self,
336 session: &bamboo_agent_core::Session,
337 activation_run_id: &str,
338 history_committed: bool,
339 ) -> Result<(), String> {
340 for runner in &self.runners {
341 runner
342 .confirm_durable_child_delivery(session, activation_run_id, history_committed)
343 .await?;
344 }
345 Ok(())
346 }
347
348 async fn prepare_durable_child_delivery(
349 &self,
350 session: &bamboo_agent_core::Session,
351 activation_run_id: &str,
352 ) -> Result<bool, String> {
353 let mut prepared = false;
354 for runner in &self.runners {
355 prepared |= runner
356 .prepare_durable_child_delivery(session, activation_run_id)
357 .await?;
358 }
359 Ok(prepared)
360 }
361
362 fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
367 for runner in &self.runners {
368 runner.set_escalation_bridge(bridge.clone());
369 }
370 }
371
372 fn set_session_inbox_runtime(&self, binding: Option<SessionInboxRuntimeBinding>) {
373 for runner in &self.runners {
374 runner.set_session_inbox_runtime(binding.clone());
375 }
376 }
377
378 fn set_actor_directory_store(&self, store: Option<Arc<bamboo_storage::SessionStoreV2>>) {
379 if let Ok(mut current) = self.store.write() {
380 *current = store.clone();
381 }
382 for runner in &self.runners {
383 runner.set_actor_directory_store(store.clone());
384 }
385 }
386
387 fn set_canonical_subagent_tool(&self, tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>) {
388 for runner in &self.runners {
389 runner.set_canonical_subagent_tool(tool.clone());
390 }
391 }
392
393 fn set_actor_event_observer(
394 &self,
395 observer: Option<Arc<dyn super::actor_event_stream::ActorEventObserver>>,
396 ) {
397 for runner in &self.runners {
398 runner.set_actor_event_observer(observer.clone());
399 }
400 }
401}
402
403pub fn build_external_child_runner(config: &Config) -> Arc<dyn ExternalChildRunner> {
412 build_external_child_runner_with_registry(config, None)
413}
414
415pub fn build_external_child_runner_with_registry(
417 config: &Config,
418 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
419) -> Arc<dyn ExternalChildRunner> {
420 build_external_child_runner_with_registry_and_reviewer(config, approval_registry, None, None)
421}
422
423pub fn build_external_child_runner_with_registry_and_reviewer(
426 config: &Config,
427 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
428 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
429 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
430) -> Arc<dyn ExternalChildRunner> {
431 build_external_child_runner_with_codex_tokens(
432 config,
433 approval_registry,
434 approval_reviewer,
435 permission_config,
436 None,
437 )
438}
439
440pub fn build_legacy_worker_child_runner(
444 config: &Config,
445 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
446 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
447 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
448) -> Arc<dyn ExternalChildRunner> {
449 let mut config = config.clone();
450 let subagents = config.subagents_mut();
451 if subagents.worker_bin.is_none() && subagents.worker_args.is_none() {
452 subagents.worker_args = Some(vec!["subagent-worker".into()]);
454 }
455 build_external_child_runner_with_registry_and_reviewer(
456 &config,
457 approval_registry,
458 approval_reviewer,
459 permission_config,
460 )
461}
462
463pub fn build_external_child_runner_with_codex_tokens(
466 config: &Config,
467 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
468 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
469 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
470 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
471) -> Arc<dyn ExternalChildRunner> {
472 build_external_child_runner_internal(
473 config,
474 None,
475 approval_registry,
476 approval_reviewer,
477 permission_config,
478 codex_run_tokens,
479 None,
480 )
481}
482
483pub fn build_external_child_runner_with_live_config_and_codex_tokens(
487 config: &Config,
488 live_provider_config: Arc<tokio::sync::RwLock<Config>>,
489 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
490 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
491 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
492 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
493) -> Arc<dyn ExternalChildRunner> {
494 build_external_child_runner_internal(
495 config,
496 Some(live_provider_config),
497 approval_registry,
498 approval_reviewer,
499 permission_config,
500 codex_run_tokens,
501 None,
502 )
503}
504
505#[allow(clippy::too_many_arguments)]
508pub fn build_external_child_runner_with_native_tool_ceiling(
509 config: &Config,
510 live_provider_config: Arc<tokio::sync::RwLock<Config>>,
511 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
512 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
513 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
514 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
515 native_tool_ceiling: Arc<dyn NativeToolCeilingSource>,
516) -> Arc<dyn ExternalChildRunner> {
517 build_external_child_runner_internal(
518 config,
519 Some(live_provider_config),
520 approval_registry,
521 approval_reviewer,
522 permission_config,
523 codex_run_tokens,
524 Some(native_tool_ceiling),
525 )
526}
527
528fn build_external_child_runner_internal(
529 config: &Config,
530 live_provider_config: Option<Arc<tokio::sync::RwLock<Config>>>,
531 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
532 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
533 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
534 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
535 native_tool_ceiling: Option<Arc<dyn NativeToolCeilingSource>>,
536) -> Arc<dyn ExternalChildRunner> {
537 let agents = parse_external_agents(config);
538
539 let mut runners: Vec<Arc<dyn ExternalChildRunner>> = Vec::new();
540
541 if uses_legacy_default_child_runtime(config) {
545 match build_local_actor_runner(
546 config,
547 approval_registry.clone(),
548 approval_reviewer.clone(),
549 permission_config.clone(),
550 codex_run_tokens.clone(),
551 live_provider_config.clone(),
552 native_tool_ceiling.clone(),
553 ) {
554 Ok(runner) => runners.push(runner),
555 Err(error) => tracing::error!("explicit legacy child runner unavailable: {error}"),
556 }
557 } else {
558 runners.push(Arc::new(
559 super::local_task::LocalTaskChildRunner::new(
560 config,
561 native_tool_ceiling.clone(),
562 permission_config.clone(),
563 )
564 .with_approval(approval_registry.clone(), approval_reviewer.clone()),
565 ));
566 if !config.subagents().remote_placements.is_empty()
567 || !config.subagents().schedulable_placements.is_empty()
568 {
569 match build_local_actor_runner(
570 config,
571 approval_registry.clone(),
572 approval_reviewer.clone(),
573 permission_config.clone(),
574 codex_run_tokens.clone(),
575 live_provider_config.clone(),
576 native_tool_ceiling.clone(),
577 ) {
578 Ok(runner) => runners.push(runner),
579 Err(error) => tracing::error!("explicit remote child route unavailable: {error}"),
580 }
581 }
582 }
583
584 for (_agent_id, profile) in agents {
585 if matches!(profile.protocol, ExternalAgentProtocol::Actor) {
587 let Some(worker_bin) = profile.worker_bin.as_ref() else {
588 tracing::error!(
589 "Actor agent profile {} has no worker_bin; skipping",
590 profile.agent_id
591 );
592 continue;
593 };
594 let fabric_dir = profile
598 .fabric_dir
599 .clone()
600 .map(std::path::PathBuf::from)
601 .unwrap_or_else(bamboo_config::paths::subagents_dir);
602 let executor = match profile.executor.as_deref() {
603 Some("echo") => bamboo_subagent::provision::ExecutorSpec::Echo,
604 Some("bamboo_runtime") | None => {
605 bamboo_subagent::provision::ExecutorSpec::BambooRuntime
606 }
607 Some("claude_code") => bamboo_subagent::provision::ExecutorSpec::ClaudeCode {
610 binary: profile.claude_code_binary.clone(),
611 model: profile.claude_code_model.clone(),
612 permission_mode: profile.claude_code_permission_mode.clone(),
613 inherit_user_config: profile.claude_code_inherit_user_config,
614 forward_env: profile.claude_code_forward_env.clone(),
615 },
616 Some("codex") => bamboo_subagent::provision::ExecutorSpec::Codex {
617 binary: profile.codex_binary.clone(),
618 model: profile.codex_model.clone(),
619 mode: profile.codex_mode.map(codex_mode_name),
620 sandbox: profile.codex_sandbox.map(codex_sandbox_name),
621 inherit_user_config: None,
622 auth_mode: Some(codex_auth_mode_name(
623 profile.codex_auth_mode.unwrap_or_default(),
624 )),
625 base_url: codex_base_url(
626 config,
627 profile.codex_auth_mode.unwrap_or_default(),
628 profile.codex_base_url.clone(),
629 ),
630 wire_api: profile.codex_wire_api.map(codex_wire_api_name),
631 provider_key_ref: profile
632 .codex_provider_key_ref
633 .as_ref()
634 .map(|reference| reference.as_str().to_string()),
635 forward_env: profile.codex_forward_env.clone(),
636 approval_policy: profile
637 .codex_approval_policy
638 .map(codex_approval_policy_name),
639 network_access: profile.codex_network_access,
640 allow_danger_bypass: profile.codex_allow_danger_bypass,
641 permission_profile: Some(profile.permission_profile.clone()),
642 workspace_owned: None,
643 },
644 Some(other) => {
645 tracing::error!(
646 "Actor agent profile {} has unknown executor '{}'; skipping",
647 profile.agent_id,
648 other
649 );
650 continue;
651 }
652 };
653 let mut runner = ActorChildRunner::new(
654 profile.agent_id.clone(),
655 std::path::PathBuf::from(worker_bin),
656 profile.worker_args.clone(),
657 fabric_dir,
658 executor,
659 extract_provider_credentials(config),
660 config.effective_default_provider().to_string(),
661 config
662 .subagents()
663 .max_concurrent
664 .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS),
665 )
666 .with_max_spawn_depth(config.subagents().effective_max_spawn_depth());
667 if let Some(registry) = approval_registry.clone() {
668 runner = runner.with_approval_registry(registry);
669 }
670 if let Some(reviewer) = approval_reviewer.clone() {
671 runner = runner.with_approval_reviewer(reviewer);
672 }
673 if let Some(config) = permission_config.clone() {
674 runner = runner.with_permission_config(config);
675 }
676 if let Some(config) = live_provider_config.clone() {
677 runner = runner.with_live_provider_config(config);
678 }
679 runner = runner.with_codex_run_tokens(codex_run_tokens.clone());
680 runners.push(Arc::new(runner));
681 continue;
682 }
683
684 if !matches!(profile.protocol, ExternalAgentProtocol::A2aJsonRpc) {
685 tracing::warn!(
686 "External agent profile {} uses unsupported protocol {:?}",
687 profile.agent_id,
688 profile.protocol
689 );
690 continue;
691 }
692
693 let auth_token = match profile.auth_ref.as_ref() {
694 Some(ref_name) => match std::env::var(ref_name) {
695 Ok(token) => Some(token),
696 Err(_) => {
697 tracing::error!(
698 "External agent profile {} auth_ref env var {} is not set",
699 profile.agent_id,
700 ref_name
701 );
702 continue;
703 }
704 },
705 None => None,
706 };
707
708 let client_config = match A2AExternalChildRunner::build_client_config(&profile, auth_token)
709 {
710 Ok(cfg) => cfg,
711 Err(e) => {
712 tracing::error!(
713 "Failed to build A2A client config for profile {}: {}",
714 profile.agent_id,
715 e
716 );
717 continue;
718 }
719 };
720
721 let client = match A2AJsonRpcClient::new(client_config) {
722 Ok(c) => c,
723 Err(e) => {
724 tracing::error!(
725 "Failed to create A2A JSON-RPC client for profile {}: {}",
726 profile.agent_id,
727 e
728 );
729 continue;
730 }
731 };
732
733 runners.push(Arc::new(A2AExternalChildRunner::new(client, profile)));
734 }
735
736 Arc::new(CompositeExternalChildRunner::new(runners))
737}
738
739fn builtin_local_worker_launch(worker_bin: &std::path::Path, worker_args: &[String]) -> bool {
741 if worker_args != ["subagent-worker".to_string()] {
742 return false;
743 }
744 let Ok(current) = std::env::current_exe().and_then(|path| path.canonicalize()) else {
749 return false;
750 };
751 worker_bin
752 .canonicalize()
753 .is_ok_and(|worker| worker == current)
754}
755
756fn build_local_actor_runner(
758 config: &Config,
759 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
760 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
761 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
762 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
763 live_provider_config: Option<Arc<tokio::sync::RwLock<Config>>>,
764 native_tool_ceiling: Option<Arc<dyn NativeToolCeilingSource>>,
765) -> Result<Arc<dyn ExternalChildRunner>, String> {
766 let sub = config.subagents();
767
768 let (worker_bin, worker_args) = match &sub.worker_bin {
769 Some(custom) => (
770 std::path::PathBuf::from(custom),
771 sub.worker_args.clone().unwrap_or_default(),
772 ),
773 None => (
774 std::env::current_exe().map_err(|e| format!("cannot locate own executable: {e}"))?,
775 sub.worker_args
776 .clone()
777 .unwrap_or_else(|| vec!["subagent-worker".to_string()]),
778 ),
779 };
780
781 let fabric_dir = sub
784 .fabric_dir
785 .clone()
786 .map(std::path::PathBuf::from)
787 .unwrap_or_else(bamboo_config::paths::subagents_dir);
788
789 let executor = subagent_executor_spec(config)?;
790 let builtin_required_context_route = builtin_local_worker_launch(&worker_bin, &worker_args);
791
792 let mut runner = ActorChildRunner::new(
793 super::config::LOCAL_ACTOR_AGENT_ID.to_string(),
794 worker_bin,
795 worker_args,
796 fabric_dir,
797 executor,
798 extract_provider_credentials(config),
799 config.effective_default_provider().to_string(),
800 sub.max_concurrent
801 .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS),
802 )
803 .with_max_spawn_depth(sub.effective_max_spawn_depth())
804 .with_builtin_required_context_route(builtin_required_context_route)
805 .with_remote_placements(resolve_remote_placements(
806 &sub.remote_placements,
807 &config.cluster_fabric.nodes,
808 ))
809 .with_schedulable_placements(resolve_schedulable_placements(
810 &sub.schedulable_placements,
811 &config.cluster_fabric.nodes,
812 ))
813 .with_bus(sub.broker.as_ref().map(|b| bamboo_subagent::BusEndpoint {
814 endpoint: b.endpoint.clone(),
815 token: b.token.clone(),
816 }))
817 .with_codex_run_tokens(codex_run_tokens)
818 .with_native_tool_ceiling_source(native_tool_ceiling);
819 if let Some(registry) = approval_registry {
820 runner = runner.with_approval_registry(registry);
821 }
822 if let Some(reviewer) = approval_reviewer {
823 runner = runner.with_approval_reviewer(reviewer);
824 }
825 if let Some(config) = permission_config {
826 runner = runner.with_permission_config(config);
827 }
828 if let Some(config) = live_provider_config {
829 runner = runner.with_live_provider_config(config);
830 }
831 Ok(Arc::new(runner))
832}
833
834fn subagent_executor_spec(
838 config: &Config,
839) -> Result<bamboo_subagent::provision::ExecutorSpec, String> {
840 let sub = config.subagents();
841 Ok(match sub.executor.as_deref() {
842 Some("echo") => bamboo_subagent::provision::ExecutorSpec::Echo,
843 Some("bamboo_runtime") | None => bamboo_subagent::provision::ExecutorSpec::BambooRuntime,
844 Some("claude_code") => bamboo_subagent::provision::ExecutorSpec::ClaudeCode {
845 binary: sub.claude_code_binary.clone(),
846 model: sub.claude_code_model.clone(),
847 permission_mode: sub.claude_code_permission_mode.clone(),
848 inherit_user_config: sub.claude_code_inherit_user_config,
849 forward_env: sub.claude_code_forward_env.clone(),
850 },
851 Some("codex") => bamboo_subagent::provision::ExecutorSpec::Codex {
852 binary: sub.codex_binary.clone(),
853 model: sub.codex_model.clone(),
854 mode: sub.codex_mode.map(codex_mode_name),
855 sandbox: sub.codex_sandbox.map(codex_sandbox_name),
856 inherit_user_config: None,
857 auth_mode: Some(codex_auth_mode_name(
858 sub.codex_auth_mode.unwrap_or_default(),
859 )),
860 base_url: codex_base_url(
861 config,
862 sub.codex_auth_mode.unwrap_or_default(),
863 sub.codex_base_url.clone(),
864 ),
865 wire_api: sub.codex_wire_api.map(codex_wire_api_name),
866 provider_key_ref: sub
867 .codex_provider_key_ref
868 .as_ref()
869 .map(|reference| reference.as_str().to_string()),
870 forward_env: sub.codex_forward_env.clone(),
871 approval_policy: sub.codex_approval_policy.map(codex_approval_policy_name),
872 network_access: sub.codex_network_access,
873 allow_danger_bypass: sub.codex_allow_danger_bypass,
874 permission_profile: None,
875 workspace_owned: None,
876 },
877 Some(other) => return Err(format!("unknown subagents.executor '{other}'")),
878 })
879}
880
881fn valid_placement_identifier(value: &str) -> bool {
882 !value.is_empty()
883 && value.len() <= 128
884 && value
885 .bytes()
886 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
887}
888
889fn valid_placement_requirements(value: &bamboo_config::OperatorPlacementRequirements) -> bool {
890 valid_placement_identifier(&value.trust_zone)
891 && value
892 .workspace_label
893 .as_deref()
894 .is_none_or(valid_placement_identifier)
895 && value
896 .network_zone
897 .as_deref()
898 .is_none_or(valid_placement_identifier)
899 && value.required_tools.len() <= 256
900 && value
901 .required_tools
902 .iter()
903 .all(|tool| valid_placement_identifier(tool))
904}
905
906fn valid_scoped_broker_endpoint(value: &str) -> bool {
907 value.len() <= 2048
908 && url::Url::parse(value).is_ok_and(|url| {
909 url.scheme() == "wss"
910 && url.host_str().is_some()
911 && url.username().is_empty()
912 && url.password().is_none()
913 && url.query().is_none()
914 && url.fragment().is_none()
915 })
916}
917
918fn resolved_broker_token(name: Option<&str>) -> Option<String> {
919 name.filter(|name| !name.is_empty() && name.len() <= 256)
920 .and_then(|name| std::env::var(name).ok())
921 .filter(|token| {
922 (32..=256).contains(&token.len()) && token.bytes().all(|byte| byte.is_ascii_graphic())
923 })
924}
925
926fn resolve_schedulable_placements(
929 placements: &[bamboo_config::SchedulablePlacement],
930 nodes: &[bamboo_config::cluster_fabric::Node],
931) -> std::collections::HashMap<String, super::actor_adapter::ResolvedSchedulablePlacement> {
932 let mut out = std::collections::HashMap::new();
933 for p in placements {
934 let token = resolved_broker_token(p.token_env.as_deref());
935 let valid = valid_placement_identifier(&p.pool)
936 && valid_scoped_broker_endpoint(&p.registry_url)
937 && p.token_env.is_some()
938 && token.is_some()
939 && p.ca_cert_file.as_ref().is_some_and(|path| {
940 bamboo_broker::client_config_trusting_cert(std::path::Path::new(path)).is_ok()
941 })
942 && p.broker_parent.as_ref().is_some_and(|parent| {
943 valid_placement_identifier(&parent.parent_mailbox)
944 && valid_placement_identifier(&parent.parent_role)
945 })
946 && p.placement_requirements
947 .as_ref()
948 .is_some_and(valid_placement_requirements);
949 let resolved = super::actor_adapter::ResolvedSchedulablePlacement {
950 pool: p.pool.clone(),
951 host_label: node_label_for_role(nodes, &p.pool),
952 endpoint: p.registry_url.clone(),
953 token: if valid { token } else { None },
954 ca_cert_file: p.ca_cert_file.as_ref().map(std::path::PathBuf::from),
955 broker_parent: if valid { p.broker_parent.clone() } else { None },
956 requirements: if valid {
957 p.placement_requirements.clone()
958 } else {
959 None
960 },
961 };
962 if out.insert(p.role.clone(), resolved).is_some() {
963 if let Some(route) = out.get_mut(&p.role) {
965 route.broker_parent = None;
966 }
967 }
968 }
969 out
970}
971
972fn node_label_for_role(
976 nodes: &[bamboo_config::cluster_fabric::Node],
977 role: &str,
978) -> Option<String> {
979 nodes
980 .iter()
981 .find(|n| n.deploy.default_role.as_deref() == Some(role))
982 .map(node_display_name)
983}
984
985fn node_label_for_endpoint(
989 nodes: &[bamboo_config::cluster_fabric::Node],
990 endpoint: &str,
991) -> Option<String> {
992 let host = endpoint
993 .trim()
994 .trim_start_matches("wss://")
995 .trim_start_matches("ws://")
996 .split(['/', ':'])
997 .next()
998 .unwrap_or("");
999 if host.is_empty() {
1000 return None;
1001 }
1002 nodes
1003 .iter()
1004 .find(|n| match &n.placement {
1005 bamboo_config::cluster_fabric::NodePlacement::Ssh(t) => t.host == host,
1006 bamboo_config::cluster_fabric::NodePlacement::Local => false,
1007 })
1008 .map(node_display_name)
1009}
1010
1011fn node_display_name(n: &bamboo_config::cluster_fabric::Node) -> String {
1012 if !n.label.trim().is_empty() {
1013 return n.label.clone();
1014 }
1015 match &n.placement {
1016 bamboo_config::cluster_fabric::NodePlacement::Ssh(t) => t.host.clone(),
1017 bamboo_config::cluster_fabric::NodePlacement::Local => "local".to_string(),
1018 }
1019}
1020
1021fn resolve_remote_placements(
1025 placements: &[bamboo_config::RemoteActorPlacement],
1026 nodes: &[bamboo_config::cluster_fabric::Node],
1027) -> std::collections::HashMap<String, super::actor_adapter::ResolvedRemotePlacement> {
1028 let mut out = std::collections::HashMap::new();
1029 for p in placements {
1030 let duplicate_strict = placements
1031 .iter()
1032 .any(|other| other.role == p.role && other.broker_peer.is_some())
1033 && placements
1034 .iter()
1035 .filter(|other| other.role == p.role)
1036 .count()
1037 != 1;
1038 if duplicate_strict {
1039 out.insert(
1040 p.role.clone(),
1041 super::actor_adapter::ResolvedRemotePlacement {
1042 endpoint: String::new(),
1043 token: None,
1044 ca_cert_file: None,
1045 host_label: Some("remote".into()),
1046 broker_peer: Some(Err(())),
1047 requirements: None,
1048 },
1049 );
1050 continue;
1051 }
1052 if let Some(peer) = &p.broker_peer {
1053 let token = resolved_broker_token(p.token_env.as_deref());
1054 let valid = peer.valid()
1055 && valid_scoped_broker_endpoint(&p.endpoint)
1056 && p.token_env.is_some()
1057 && token.is_some()
1058 && p.ca_cert_file.as_ref().is_some_and(|path| {
1059 bamboo_broker::client_config_trusting_cert(std::path::Path::new(path)).is_ok()
1060 })
1061 && p.placement_requirements
1062 .as_ref()
1063 .is_some_and(valid_placement_requirements);
1064 out.insert(
1065 p.role.clone(),
1066 super::actor_adapter::ResolvedRemotePlacement {
1067 endpoint: p.endpoint.clone(),
1068 token: if valid { token } else { None },
1069 ca_cert_file: p.ca_cert_file.as_ref().map(std::path::PathBuf::from),
1070 host_label: Some("remote".into()),
1071 broker_peer: Some(if valid { Ok(peer.clone()) } else { Err(()) }),
1072 requirements: if valid {
1073 p.placement_requirements.clone()
1074 } else {
1075 None
1076 },
1077 },
1078 );
1079 continue; }
1081 out.insert(
1082 p.role.clone(),
1083 super::actor_adapter::ResolvedRemotePlacement {
1084 endpoint: p.endpoint.clone(),
1085 token: None,
1086 ca_cert_file: p.ca_cert_file.as_ref().map(std::path::PathBuf::from),
1087 host_label: node_label_for_endpoint(nodes, &p.endpoint),
1090 broker_peer: Some(Err(())),
1091 requirements: None,
1092 },
1093 );
1094 }
1095 out
1096}
1097
1098pub fn extract_provider_credentials(
1106 config: &Config,
1107) -> Vec<bamboo_subagent::provision::ScopedCredential> {
1108 let mut out = Vec::new();
1109
1110 fn push_instance(
1111 out: &mut Vec<bamboo_subagent::provision::ScopedCredential>,
1112 id: &str,
1113 instance: &bamboo_config::ProviderInstanceConfig,
1114 ) {
1115 if !instance.enabled {
1116 return;
1117 }
1118 let api_key = instance.api_key.trim().to_string();
1119 if api_key.is_empty() {
1120 return;
1121 }
1122 out.push(bamboo_subagent::provision::ScopedCredential {
1123 provider: id.to_string(),
1124 api_key,
1125 base_url: instance.base_url.clone(),
1126 provider_type: Some(instance.provider_type.clone()),
1127 credential_ref: instance
1128 .credential_ref
1129 .as_ref()
1130 .map(|reference| reference.as_str().to_string()),
1131 });
1132 }
1133
1134 if !config.provider_instances.is_empty() {
1135 for (id, instance) in &config.provider_instances {
1138 push_instance(&mut out, id, instance);
1139 }
1140
1141 let default_id = config.effective_default_provider();
1145 if !config.provider_instances.contains_key(default_id) {
1146 if let Some((_, instance)) = bamboo_config::synthesize_legacy_instances(config)
1147 .into_iter()
1148 .find(|(id, _)| id == default_id)
1149 {
1150 push_instance(&mut out, default_id, &instance);
1151 }
1152 }
1153 return out;
1154 }
1155
1156 let mut push_legacy =
1160 |name: &str, api_key: &str, base_url: Option<String>, credential_ref: Option<String>| {
1161 let api_key = api_key.trim().to_string();
1162 if api_key.is_empty() {
1163 return;
1164 }
1165 out.push(bamboo_subagent::provision::ScopedCredential {
1166 provider: name.to_string(),
1167 api_key,
1168 base_url,
1169 provider_type: Some(name.to_string()),
1170 credential_ref,
1171 });
1172 };
1173 if let Some(c) = &config.providers().openai {
1174 push_legacy(
1175 "openai",
1176 &c.api_key,
1177 c.base_url.clone(),
1178 c.credential_ref
1179 .as_ref()
1180 .map(|reference| reference.as_str().to_string()),
1181 );
1182 }
1183 if let Some(c) = &config.providers().anthropic {
1184 push_legacy(
1185 "anthropic",
1186 &c.api_key,
1187 c.base_url.clone(),
1188 c.credential_ref
1189 .as_ref()
1190 .map(|reference| reference.as_str().to_string()),
1191 );
1192 }
1193 if let Some(c) = &config.providers().gemini {
1194 push_legacy(
1195 "gemini",
1196 &c.api_key,
1197 c.base_url.clone(),
1198 c.credential_ref
1199 .as_ref()
1200 .map(|reference| reference.as_str().to_string()),
1201 );
1202 }
1203 if let Some(c) = &config.providers().bodhi {
1204 push_legacy(
1205 "bodhi",
1206 &c.api_key,
1207 c.base_url.clone(),
1208 c.credential_ref
1209 .as_ref()
1210 .map(|reference| reference.as_str().to_string()),
1211 );
1212 }
1213
1214 out
1215}
1216
1217#[cfg(test)]
1218mod codex_runtime_config_tests {
1219 #[test]
1220 fn builtin_local_worker_launch_requires_current_executable_and_exact_arguments() {
1221 let current = std::env::current_exe().unwrap();
1222 let exact = vec!["subagent-worker".to_string()];
1223 assert!(super::builtin_local_worker_launch(¤t, &exact));
1224 assert!(super::builtin_local_worker_launch(
1225 ¤t.canonicalize().unwrap(),
1226 &exact
1227 ));
1228 for args in [
1229 vec![],
1230 vec!["serve".into()],
1231 vec!["subagent-worker".into(), "--extra".into()],
1232 ] {
1233 assert!(!super::builtin_local_worker_launch(¤t, &args));
1234 }
1235 let temp = tempfile::tempdir().unwrap();
1236 assert!(!super::builtin_local_worker_launch(
1237 &temp.path().join("missing"),
1238 &exact
1239 ));
1240 let foreign = temp.path().join("foreign-worker");
1241 std::fs::write(
1242 &foreign,
1243 "claimed worker capabilities do not establish executable identity",
1244 )
1245 .unwrap();
1246 assert!(!super::builtin_local_worker_launch(&foreign, &exact));
1247 }
1248
1249 #[tokio::test]
1250 async fn required_context_uses_registered_launch_snapshot_after_live_config_flip() {
1251 let mut candidate = bamboo_agent_core::Session::new("preflight", "model");
1252 candidate.metadata = super::super::config::resolve_runtime_metadata(
1253 &bamboo_llm::Config::default(),
1254 "worker",
1255 );
1256 candidate
1257 .metadata
1258 .insert("subagent_type".into(), "worker".into());
1259 for kind in ["custom", "args", "codex", "remote"] {
1260 let mut config = bamboo_llm::Config::default();
1261 let sub = config.subagents_mut();
1262 sub.broker = Some(bamboo_config::BrokerClientConfig {
1263 endpoint: "ws://127.0.0.1:9998".into(),
1264 token: "fixture".into(),
1265 ..Default::default()
1266 });
1267 match kind {
1268 "custom" => sub.worker_bin = Some("/bin/false".into()),
1269 "args" => sub.worker_args = Some(vec!["subagent-worker".into()]),
1270 "codex" => sub.executor = Some("codex".into()),
1271 _ => sub
1272 .remote_placements
1273 .push(bamboo_config::RemoteActorPlacement {
1274 role: "worker".into(),
1275 endpoint: "ws://127.0.0.1:9999".into(),
1276 ..Default::default()
1277 }),
1278 }
1279 let live = std::sync::Arc::new(tokio::sync::RwLock::new(config.clone()));
1280 let runner = super::build_external_child_runner_with_live_config_and_codex_tokens(
1281 &config,
1282 live.clone(),
1283 None,
1284 None,
1285 None,
1286 None,
1287 );
1288 *live.write().await = bamboo_llm::Config::default();
1289 let error = runner
1290 .validate_required_child_context_route(&candidate)
1291 .await
1292 .unwrap_err();
1293 assert!(
1294 error.contains("required_child_context_unsupported"),
1295 "{kind}"
1296 );
1297 }
1298 }
1299
1300 use super::{
1301 codex_approval_policy_name, codex_auth_mode_name, codex_base_url, codex_mode_name,
1302 codex_sandbox_name, codex_wire_api_name, subagent_executor_spec,
1303 };
1304 use bamboo_config::{
1305 CodexApprovalPolicy, CodexAuthMode, CodexMode, CodexSandbox, CodexWireApi, CredentialRef,
1306 };
1307 use bamboo_llm::Config;
1308 use bamboo_subagent::provision::ExecutorSpec;
1309
1310 #[test]
1311 fn codex_runtime_mapping_keeps_parent_loopback_and_custom_url_unambiguous() {
1312 let mut config = Config::default();
1313 config.server.port = 5700;
1314
1315 assert_eq!(codex_auth_mode_name(CodexAuthMode::Bamboo), "bamboo");
1316 assert_eq!(codex_mode_name(CodexMode::AppServer), "app_server");
1317 assert_eq!(codex_wire_api_name(CodexWireApi::Responses), "responses");
1318 assert_eq!(codex_sandbox_name(CodexSandbox::ReadOnly), "read-only");
1319 assert_eq!(
1320 codex_sandbox_name(CodexSandbox::WorkspaceWrite),
1321 "workspace-write"
1322 );
1323 assert_eq!(
1324 codex_approval_policy_name(CodexApprovalPolicy::OnFailure),
1325 "on-failure"
1326 );
1327 assert_eq!(
1328 codex_base_url(&config, CodexAuthMode::Bamboo, None).as_deref(),
1329 Some("http://127.0.0.1:5700/openai/v1")
1330 );
1331 assert_eq!(
1332 codex_base_url(
1333 &config,
1334 CodexAuthMode::Custom,
1335 Some("https://provider.example/v1".to_string()),
1336 )
1337 .as_deref(),
1338 Some("https://provider.example/v1")
1339 );
1340 assert_eq!(codex_base_url(&config, CodexAuthMode::Inherit, None), None);
1341 assert_eq!(codex_base_url(&config, CodexAuthMode::ApiKey, None), None);
1342 }
1343
1344 #[test]
1345 fn durable_codex_fields_map_without_loss_to_worker_spawn_spec() {
1346 let mut config = Config::default();
1347 let subagents = config.subagents_mut();
1348 subagents.executor = Some("codex".to_string());
1349 subagents.codex_binary = Some("/opt/codex/bin/codex".to_string());
1350 subagents.codex_model = Some("gpt-5.4".to_string());
1351 subagents.codex_mode = Some(CodexMode::AppServer);
1352 subagents.codex_auth_mode = Some(CodexAuthMode::Custom);
1353 subagents.codex_base_url = Some("https://provider.example/v1".to_string());
1354 subagents.codex_wire_api = Some(CodexWireApi::Responses);
1355 subagents.codex_provider_key_ref = Some(
1356 CredentialRef::parse("provider.codex-work.api_key").expect("valid credential ref"),
1357 );
1358 subagents.codex_forward_env = Some(vec!["HTTPS_PROXY".to_string()]);
1359 subagents.codex_sandbox = Some(CodexSandbox::WorkspaceWrite);
1360 subagents.codex_approval_policy = Some(CodexApprovalPolicy::OnRequest);
1361 subagents.codex_network_access = Some(true);
1362 subagents.codex_allow_danger_bypass = Some(false);
1363
1364 let spec = subagent_executor_spec(&config).expect("Codex config maps to executor spec");
1365 let ExecutorSpec::Codex {
1366 binary,
1367 model,
1368 mode,
1369 sandbox,
1370 auth_mode,
1371 base_url,
1372 wire_api,
1373 provider_key_ref,
1374 forward_env,
1375 approval_policy,
1376 network_access,
1377 allow_danger_bypass,
1378 ..
1379 } = spec
1380 else {
1381 panic!("expected Codex executor spec");
1382 };
1383 assert_eq!(binary.as_deref(), Some("/opt/codex/bin/codex"));
1384 assert_eq!(model.as_deref(), Some("gpt-5.4"));
1385 assert_eq!(mode.as_deref(), Some("app_server"));
1386 assert_eq!(sandbox.as_deref(), Some("workspace-write"));
1387 assert_eq!(auth_mode.as_deref(), Some("custom"));
1388 assert_eq!(base_url.as_deref(), Some("https://provider.example/v1"));
1389 assert_eq!(wire_api.as_deref(), Some("responses"));
1390 assert_eq!(
1391 provider_key_ref.as_deref(),
1392 Some("provider.codex-work.api_key")
1393 );
1394 assert_eq!(forward_env, Some(vec!["HTTPS_PROXY".to_string()]));
1395 assert_eq!(approval_policy.as_deref(), Some("on-request"));
1396 assert_eq!(network_access, Some(true));
1397 assert_eq!(allow_danger_bypass, Some(false));
1398 }
1399}
1400
1401#[cfg(test)]
1402mod extract_provider_credentials_tests {
1403 use super::extract_provider_credentials;
1404 use bamboo_config::{
1405 AnthropicConfig, BodhiConfig, Config, OpenAIConfig, ProviderInstanceConfig,
1406 };
1407
1408 fn instance(provider_type: &str, api_key: &str) -> ProviderInstanceConfig {
1409 ProviderInstanceConfig {
1410 provider_type: provider_type.to_string(),
1411 label: None,
1412 api_key: api_key.to_string(),
1413 api_key_encrypted: None,
1414 credential_ref: None,
1415 base_url: None,
1416 model: None,
1417 fast_model: None,
1418 vision_model: None,
1419 reasoning_effort: None,
1420 responses_only_models: Vec::new(),
1421 request_overrides: None,
1422 enabled: true,
1423 extra: Default::default(),
1424 }
1425 }
1426
1427 #[test]
1428 fn no_config_yields_no_credentials() {
1429 let config = Config::default();
1430 assert!(extract_provider_credentials(&config).is_empty());
1431 }
1432
1433 #[test]
1438 fn legacy_only_config_yields_credential() {
1439 let mut config = Config::default();
1440 config.providers_mut().anthropic = Some(AnthropicConfig {
1441 api_key: "sk-ant-legacy".to_string(),
1442 base_url: Some("https://api.anthropic.com".to_string()),
1443 ..Default::default()
1444 });
1445
1446 let creds = extract_provider_credentials(&config);
1447 assert_eq!(creds.len(), 1);
1448 let c = &creds[0];
1449 assert_eq!(c.provider, "anthropic");
1450 assert_eq!(c.api_key, "sk-ant-legacy");
1451 assert_eq!(c.base_url.as_deref(), Some("https://api.anthropic.com"));
1452 assert_eq!(c.provider_type.as_deref(), Some("anthropic"));
1453 }
1454
1455 #[test]
1459 fn legacy_bodhi_config_yields_credential() {
1460 let mut config = Config::default();
1461 config.providers_mut().bodhi = Some(BodhiConfig {
1462 api_key: "bhi_sk_legacy".to_string(),
1463 api_key_encrypted: None,
1464 credential_ref: None,
1465 base_url: None,
1466 target_provider: None,
1467 reasoning_effort: None,
1468 extra: Default::default(),
1469 });
1470
1471 let creds = extract_provider_credentials(&config);
1472 assert_eq!(creds.len(), 1);
1473 assert_eq!(creds[0].provider, "bodhi");
1474 assert_eq!(creds[0].api_key, "bhi_sk_legacy");
1475 }
1476
1477 #[test]
1480 fn legacy_config_with_empty_api_key_is_skipped() {
1481 let mut config = Config::default();
1482 config.providers_mut().openai = Some(OpenAIConfig::default());
1483 assert!(extract_provider_credentials(&config).is_empty());
1484 }
1485
1486 #[test]
1489 fn instance_mode_omits_unrelated_legacy_credentials() {
1490 let mut config = Config::default();
1491 config.providers_mut().anthropic = Some(AnthropicConfig {
1492 api_key: "sk-ant-legacy".to_string(),
1493 ..Default::default()
1494 });
1495 let mut openai_work = instance("openai", "sk-oai-work");
1496 openai_work.credential_ref = Some(
1497 bamboo_config::CredentialRef::parse("provider.openai-work.api_key")
1498 .expect("valid provider credential reference"),
1499 );
1500 config
1501 .provider_instances
1502 .insert("openai-work".to_string(), openai_work);
1503 config.default_provider_instance = Some("openai-work".to_string());
1504
1505 let creds = extract_provider_credentials(&config);
1506
1507 assert_eq!(creds.len(), 1);
1508 assert_eq!(creds[0].provider, "openai-work");
1509 assert_eq!(creds[0].api_key, "sk-oai-work");
1510 assert_eq!(creds[0].provider_type.as_deref(), Some("openai"));
1511 assert_eq!(
1512 creds[0].credential_ref.as_deref(),
1513 Some("provider.openai-work.api_key")
1514 );
1515 }
1516
1517 #[test]
1518 fn hybrid_legacy_default_is_the_only_legacy_credential_exported() {
1519 let mut config = Config::default();
1520 config.providers_mut().anthropic = Some(AnthropicConfig {
1521 api_key: "sk-ant-default".to_string(),
1522 ..Default::default()
1523 });
1524 config.providers_mut().openai = Some(OpenAIConfig {
1525 api_key: "sk-oai-stale".to_string(),
1526 ..Default::default()
1527 });
1528 config
1529 .provider_instances
1530 .insert("work".to_string(), instance("openai", "sk-oai-work"));
1531 config.default_provider_instance = Some("anthropic".to_string());
1532
1533 let mut creds = extract_provider_credentials(&config);
1534 creds.sort_by(|a, b| a.provider.cmp(&b.provider));
1535
1536 assert_eq!(creds.len(), 2);
1537 assert_eq!(creds[0].provider, "anthropic");
1538 assert_eq!(creds[0].api_key, "sk-ant-default");
1539 assert_eq!(creds[1].provider, "work");
1540 assert_eq!(creds[1].api_key, "sk-oai-work");
1541 assert!(creds
1542 .iter()
1543 .all(|credential| credential.api_key != "sk-oai-stale"));
1544 }
1545
1546 #[test]
1547 fn hybrid_legacy_provider_fallback_without_explicit_default_remains_exportable() {
1548 let mut config = Config::default();
1549 config.provider = "anthropic".to_string();
1550 config.providers_mut().anthropic = Some(AnthropicConfig {
1551 api_key: "sk-ant-effective-default".to_string(),
1552 ..Default::default()
1553 });
1554 config
1555 .provider_instances
1556 .insert("work".to_string(), instance("openai", "sk-oai-work"));
1557
1558 let mut creds = extract_provider_credentials(&config);
1559 creds.sort_by(|a, b| a.provider.cmp(&b.provider));
1560
1561 assert_eq!(creds.len(), 2);
1562 assert_eq!(creds[0].provider, "anthropic");
1563 assert_eq!(creds[1].provider, "work");
1564 }
1565
1566 #[test]
1567 fn disabled_instance_credential_is_not_exported() {
1568 let mut config = Config::default();
1569 let mut disabled = instance("openai", "sk-disabled");
1570 disabled.enabled = false;
1571 config
1572 .provider_instances
1573 .insert("disabled".to_string(), disabled);
1574
1575 assert!(extract_provider_credentials(&config).is_empty());
1576 }
1577}
1578
1579#[cfg(test)]
1580mod placement_resolver_tests {
1581 use super::{node_display_name, resolve_remote_placements, resolve_schedulable_placements};
1582 use bamboo_config::cluster_fabric::{
1583 DeployProfile, Node, NodePlacement, SshAuth, SshTarget, TrustLevel,
1584 };
1585 use bamboo_config::{RemoteActorPlacement, SchedulablePlacement};
1586
1587 #[test]
1588 fn explicit_broker_route_retains_unavailable_selection_and_redacted_debug() {
1589 let route = bamboo_config::RemoteBrokerPeer {
1590 parent_mailbox: "host".into(),
1591 worker_mailbox: "worker".into(),
1592 parent_role: Some("host".into()),
1593 worker_role: Some("worker".into()),
1594 };
1595 assert!(route.valid());
1596 let mut invalid = route.clone();
1597 invalid.worker_mailbox = "Worker".into();
1598 assert!(!invalid.valid());
1599 invalid.worker_mailbox = "../worker".into();
1600 assert!(!invalid.valid());
1601 let placement = RemoteActorPlacement {
1602 role: "worker".into(),
1603 endpoint: "ws://private.invalid".into(),
1604 token_env: Some("BAMBOO_1431_MISSING_FIXTURE_TOKEN".into()),
1605 ca_cert_file: None,
1606 broker_peer: Some(route),
1607 placement_requirements: None,
1608 };
1609 let parsed: RemoteActorPlacement =
1610 serde_json::from_value(serde_json::to_value(&placement).unwrap()).unwrap();
1611 assert_eq!(parsed, placement);
1612 let resolved = resolve_remote_placements(&[placement], &[]);
1613 let selected = &resolved["worker"];
1614 assert!(matches!(selected.broker_peer, Some(Err(()))));
1615 assert!(selected.token.is_none());
1616 assert_eq!(selected.host_label.as_deref(), Some("remote"));
1617 assert!(!format!("{selected:?}").contains("private.invalid"));
1618 assert!(serde_json::from_value::<bamboo_config::RemoteBrokerPeer>(serde_json::json!({
1619 "parent_mailbox":"host", "worker_mailbox":"worker", "parent_role":null, "worker_role":null,"credential":"secret"
1620 })).is_err());
1621 }
1622
1623 fn ssh_node(id: &str, label: &str, host: &str, default_role: Option<&str>) -> Node {
1624 Node {
1625 id: id.into(),
1626 label: label.into(),
1627 placement: NodePlacement::Ssh(SshTarget {
1628 host: host.into(),
1629 port: 22,
1630 username: "u".into(),
1631 auth: SshAuth::SystemSshConfig,
1632 host_key_fingerprint: None,
1633 }),
1634 trust_level: TrustLevel::default(),
1635 deploy: DeployProfile {
1636 default_role: default_role.map(String::from),
1637 ..Default::default()
1638 },
1639 state: None,
1640 enabled: true,
1641 }
1642 }
1643
1644 #[test]
1645 fn node_display_name_prefers_label_then_ssh_host() {
1646 let n = ssh_node("n1", "mini", "mini.local", None);
1647 assert_eq!(node_display_name(&n), "mini");
1648 let mut unlabeled = n.clone();
1649 unlabeled.label = String::new();
1650 assert_eq!(node_display_name(&unlabeled), "mini.local");
1651 }
1652
1653 #[test]
1654 fn schedulable_placement_takes_host_label_from_node_by_default_role() {
1655 let nodes = vec![ssh_node(
1656 "n1",
1657 "mini",
1658 "mini.local",
1659 Some("mac-mini-monitor"),
1660 )];
1661 let placements = vec![SchedulablePlacement {
1662 role: "mac-mini-monitor".into(),
1663 pool: "mac-mini-monitor".into(),
1664 ..Default::default()
1665 }];
1666 let out = resolve_schedulable_placements(&placements, &nodes);
1667 let r = out.get("mac-mini-monitor").expect("role resolved");
1668 assert_eq!(r.pool, "mac-mini-monitor");
1669 assert_eq!(r.host_label.as_deref(), Some("mini"));
1670 }
1671
1672 #[test]
1673 fn remote_placement_takes_host_label_from_node_by_ssh_host() {
1674 let nodes = vec![ssh_node("n1", "mini", "mini.local", None)];
1675 let placements = vec![RemoteActorPlacement {
1676 role: "explorer".into(),
1677 endpoint: "ws://mini.local:8899".into(),
1678 ..Default::default()
1679 }];
1680 let out = resolve_remote_placements(&placements, &nodes);
1681 assert_eq!(
1682 out.get("explorer").unwrap().host_label.as_deref(),
1683 Some("mini")
1684 );
1685 assert!(matches!(out["explorer"].broker_peer, Some(Err(()))));
1686 assert!(out["explorer"].token.is_none());
1687 }
1688
1689 #[test]
1690 fn no_host_label_when_no_node_matches() {
1691 let nodes = vec![ssh_node("n1", "mini", "mini.local", Some("other-role"))];
1692 let sched = vec![SchedulablePlacement {
1693 role: "x".into(),
1694 pool: "unmatched".into(),
1695 ..Default::default()
1696 }];
1697 assert_eq!(
1698 resolve_schedulable_placements(&sched, &nodes)
1699 .get("x")
1700 .unwrap()
1701 .host_label,
1702 None
1703 );
1704 let remote = vec![RemoteActorPlacement {
1705 role: "y".into(),
1706 endpoint: "ws://other-host:9000".into(),
1707 ..Default::default()
1708 }];
1709 assert_eq!(
1710 resolve_remote_placements(&remote, &nodes)
1711 .get("y")
1712 .unwrap()
1713 .host_label,
1714 None
1715 );
1716 }
1717}