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