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