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
15fn codex_auth_mode_name(mode: bamboo_config::CodexAuthMode) -> String {
16 match mode {
17 bamboo_config::CodexAuthMode::Inherit => "inherit",
18 bamboo_config::CodexAuthMode::ApiKey => "api_key",
19 bamboo_config::CodexAuthMode::Custom => "custom",
20 bamboo_config::CodexAuthMode::Bamboo => "bamboo",
21 }
22 .to_string()
23}
24
25fn codex_wire_api_name(wire_api: bamboo_config::CodexWireApi) -> String {
26 match wire_api {
27 bamboo_config::CodexWireApi::Responses => "responses",
28 }
29 .to_string()
30}
31
32fn codex_mode_name(mode: bamboo_config::CodexMode) -> String {
33 match mode {
34 bamboo_config::CodexMode::Exec => "exec",
35 bamboo_config::CodexMode::AppServer => "app_server",
36 }
37 .to_string()
38}
39
40fn codex_sandbox_name(sandbox: bamboo_config::CodexSandbox) -> String {
41 match sandbox {
42 bamboo_config::CodexSandbox::ReadOnly => "read-only",
43 bamboo_config::CodexSandbox::WorkspaceWrite => "workspace-write",
44 bamboo_config::CodexSandbox::DangerFullAccess => "danger-full-access",
45 }
46 .to_string()
47}
48
49fn codex_approval_policy_name(policy: bamboo_config::CodexApprovalPolicy) -> String {
50 match policy {
51 bamboo_config::CodexApprovalPolicy::Never => "never",
52 bamboo_config::CodexApprovalPolicy::OnFailure => "on-failure",
53 bamboo_config::CodexApprovalPolicy::OnRequest => "on-request",
54 }
55 .to_string()
56}
57
58fn codex_base_url(
59 config: &Config,
60 mode: bamboo_config::CodexAuthMode,
61 custom: Option<String>,
62) -> Option<String> {
63 match mode {
64 bamboo_config::CodexAuthMode::Custom => custom,
65 bamboo_config::CodexAuthMode::Bamboo => {
66 let scheme = if config.server.tls.is_some() {
67 "https"
68 } else {
69 "http"
70 };
71 Some(format!(
72 "{scheme}://127.0.0.1:{}/openai/v1",
73 config.server.port
74 ))
75 }
76 bamboo_config::CodexAuthMode::Inherit | bamboo_config::CodexAuthMode::ApiKey => None,
77 }
78}
79
80pub struct CompositeExternalChildRunner {
82 runners: Vec<Arc<dyn ExternalChildRunner>>,
83}
84
85impl CompositeExternalChildRunner {
86 pub fn new(runners: Vec<Arc<dyn ExternalChildRunner>>) -> Self {
87 Self { runners }
88 }
89}
90
91#[async_trait]
92impl ExternalChildRunner for CompositeExternalChildRunner {
93 async fn should_handle(&self, session: &bamboo_agent_core::Session) -> bool {
94 for runner in &self.runners {
95 if runner.should_handle(session).await {
96 return true;
97 }
98 }
99 false
100 }
101
102 async fn execute_external_child(
103 &self,
104 session: &mut bamboo_agent_core::Session,
105 job: &SpawnJob,
106 event_tx: mpsc::Sender<AgentEvent>,
107 cancel_token: CancellationToken,
108 ) -> crate::runtime::runner::Result<()> {
109 for runner in &self.runners {
110 if runner.should_handle(session).await {
111 return runner
112 .execute_external_child(session, job, event_tx, cancel_token)
113 .await;
114 }
115 }
116 Err(AgentError::LLM(
117 "No matching external child runner found for session metadata".to_string(),
118 ))
119 }
120
121 fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
126 for runner in &self.runners {
127 runner.set_escalation_bridge(bridge.clone());
128 }
129 }
130
131 fn set_session_inbox_runtime(&self, binding: Option<SessionInboxRuntimeBinding>) {
132 for runner in &self.runners {
133 runner.set_session_inbox_runtime(binding.clone());
134 }
135 }
136}
137
138pub fn build_external_child_runner(config: &Config) -> Arc<dyn ExternalChildRunner> {
147 build_external_child_runner_with_registry(config, None)
148}
149
150pub fn build_external_child_runner_with_registry(
152 config: &Config,
153 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
154) -> Arc<dyn ExternalChildRunner> {
155 build_external_child_runner_with_registry_and_reviewer(config, approval_registry, None, None)
156}
157
158pub fn build_external_child_runner_with_registry_and_reviewer(
161 config: &Config,
162 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
163 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
164 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
165) -> Arc<dyn ExternalChildRunner> {
166 build_external_child_runner_with_codex_tokens(
167 config,
168 approval_registry,
169 approval_reviewer,
170 permission_config,
171 None,
172 )
173}
174
175pub fn build_external_child_runner_with_codex_tokens(
178 config: &Config,
179 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
180 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
181 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
182 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
183) -> Arc<dyn ExternalChildRunner> {
184 let agents = parse_external_agents(config);
185
186 let mut runners: Vec<Arc<dyn ExternalChildRunner>> = Vec::new();
187
188 match build_local_actor_runner(
192 config,
193 approval_registry.clone(),
194 approval_reviewer.clone(),
195 permission_config.clone(),
196 codex_run_tokens.clone(),
197 ) {
198 Ok(runner) => runners.push(runner),
199 Err(e) => tracing::error!("local actor sub-agent runner unavailable: {e}"),
200 }
201
202 for (_agent_id, profile) in agents {
203 if matches!(profile.protocol, ExternalAgentProtocol::Actor) {
205 let Some(worker_bin) = profile.worker_bin.as_ref() else {
206 tracing::error!(
207 "Actor agent profile {} has no worker_bin; skipping",
208 profile.agent_id
209 );
210 continue;
211 };
212 let fabric_dir = profile
216 .fabric_dir
217 .clone()
218 .map(std::path::PathBuf::from)
219 .unwrap_or_else(bamboo_config::paths::subagents_dir);
220 let executor = match profile.executor.as_deref() {
221 Some("echo") => bamboo_subagent::provision::ExecutorSpec::Echo,
222 Some("bamboo_runtime") | None => {
223 bamboo_subagent::provision::ExecutorSpec::BambooRuntime
224 }
225 Some("claude_code") => bamboo_subagent::provision::ExecutorSpec::ClaudeCode {
228 binary: profile.claude_code_binary.clone(),
229 model: profile.claude_code_model.clone(),
230 permission_mode: profile.claude_code_permission_mode.clone(),
231 inherit_user_config: profile.claude_code_inherit_user_config,
232 forward_env: profile.claude_code_forward_env.clone(),
233 },
234 Some("codex") => bamboo_subagent::provision::ExecutorSpec::Codex {
235 binary: profile.codex_binary.clone(),
236 model: profile.codex_model.clone(),
237 mode: profile.codex_mode.map(codex_mode_name),
238 sandbox: profile.codex_sandbox.map(codex_sandbox_name),
239 inherit_user_config: None,
240 auth_mode: Some(codex_auth_mode_name(
241 profile.codex_auth_mode.unwrap_or_default(),
242 )),
243 base_url: codex_base_url(
244 config,
245 profile.codex_auth_mode.unwrap_or_default(),
246 profile.codex_base_url.clone(),
247 ),
248 wire_api: profile.codex_wire_api.map(codex_wire_api_name),
249 provider_key_ref: profile
250 .codex_provider_key_ref
251 .as_ref()
252 .map(|reference| reference.as_str().to_string()),
253 forward_env: profile.codex_forward_env.clone(),
254 approval_policy: profile
255 .codex_approval_policy
256 .map(codex_approval_policy_name),
257 network_access: profile.codex_network_access,
258 allow_danger_bypass: profile.codex_allow_danger_bypass,
259 permission_profile: Some(profile.permission_profile.clone()),
260 workspace_owned: None,
261 },
262 Some(other) => {
263 tracing::error!(
264 "Actor agent profile {} has unknown executor '{}'; skipping",
265 profile.agent_id,
266 other
267 );
268 continue;
269 }
270 };
271 let mut runner = ActorChildRunner::new(
272 profile.agent_id.clone(),
273 std::path::PathBuf::from(worker_bin),
274 profile.worker_args.clone(),
275 fabric_dir,
276 executor,
277 extract_provider_credentials(config),
278 config.effective_default_provider().to_string(),
279 config
280 .subagents()
281 .max_concurrent
282 .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS),
283 );
284 if let Some(registry) = approval_registry.clone() {
285 runner = runner.with_approval_registry(registry);
286 }
287 if let Some(reviewer) = approval_reviewer.clone() {
288 runner = runner.with_approval_reviewer(reviewer);
289 }
290 if let Some(config) = permission_config.clone() {
291 runner = runner.with_permission_config(config);
292 }
293 runner = runner.with_codex_run_tokens(codex_run_tokens.clone());
294 runners.push(Arc::new(runner));
295 continue;
296 }
297
298 if !matches!(profile.protocol, ExternalAgentProtocol::A2aJsonRpc) {
299 tracing::warn!(
300 "External agent profile {} uses unsupported protocol {:?}",
301 profile.agent_id,
302 profile.protocol
303 );
304 continue;
305 }
306
307 let auth_token = match profile.auth_ref.as_ref() {
308 Some(ref_name) => match std::env::var(ref_name) {
309 Ok(token) => Some(token),
310 Err(_) => {
311 tracing::error!(
312 "External agent profile {} auth_ref env var {} is not set",
313 profile.agent_id,
314 ref_name
315 );
316 continue;
317 }
318 },
319 None => None,
320 };
321
322 let client_config = match A2AExternalChildRunner::build_client_config(&profile, auth_token)
323 {
324 Ok(cfg) => cfg,
325 Err(e) => {
326 tracing::error!(
327 "Failed to build A2A client config for profile {}: {}",
328 profile.agent_id,
329 e
330 );
331 continue;
332 }
333 };
334
335 let client = match A2AJsonRpcClient::new(client_config) {
336 Ok(c) => c,
337 Err(e) => {
338 tracing::error!(
339 "Failed to create A2A JSON-RPC client for profile {}: {}",
340 profile.agent_id,
341 e
342 );
343 continue;
344 }
345 };
346
347 runners.push(Arc::new(A2AExternalChildRunner::new(client, profile)));
348 }
349
350 Arc::new(CompositeExternalChildRunner::new(runners))
351}
352
353fn build_local_actor_runner(
358 config: &Config,
359 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
360 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
361 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
362 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
363) -> Result<Arc<dyn ExternalChildRunner>, String> {
364 let sub = config.subagents();
365
366 let (worker_bin, worker_args) = match &sub.worker_bin {
367 Some(custom) => (
368 std::path::PathBuf::from(custom),
369 sub.worker_args.clone().unwrap_or_default(),
370 ),
371 None => (
372 std::env::current_exe().map_err(|e| format!("cannot locate own executable: {e}"))?,
373 sub.worker_args
374 .clone()
375 .unwrap_or_else(|| vec!["subagent-worker".to_string()]),
376 ),
377 };
378
379 let fabric_dir = sub
382 .fabric_dir
383 .clone()
384 .map(std::path::PathBuf::from)
385 .unwrap_or_else(bamboo_config::paths::subagents_dir);
386
387 let executor = subagent_executor_spec(config)?;
388
389 let mut runner = ActorChildRunner::new(
390 super::config::LOCAL_ACTOR_AGENT_ID.to_string(),
391 worker_bin,
392 worker_args,
393 fabric_dir,
394 executor,
395 extract_provider_credentials(config),
396 config.effective_default_provider().to_string(),
397 sub.max_concurrent
398 .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS),
399 )
400 .with_remote_placements(resolve_remote_placements(
401 &sub.remote_placements,
402 &config.cluster_fabric.nodes,
403 ))
404 .with_schedulable_placements(resolve_schedulable_placements(
405 &sub.schedulable_placements,
406 &config.cluster_fabric.nodes,
407 ))
408 .with_bus(sub.broker.as_ref().map(|b| bamboo_subagent::BusEndpoint {
409 endpoint: b.endpoint.clone(),
410 token: b.token.clone(),
411 }))
412 .with_codex_run_tokens(codex_run_tokens);
413 if let Some(registry) = approval_registry {
414 runner = runner.with_approval_registry(registry);
415 }
416 if let Some(reviewer) = approval_reviewer {
417 runner = runner.with_approval_reviewer(reviewer);
418 }
419 if let Some(config) = permission_config {
420 runner = runner.with_permission_config(config);
421 }
422 Ok(Arc::new(runner))
423}
424
425fn subagent_executor_spec(
429 config: &Config,
430) -> Result<bamboo_subagent::provision::ExecutorSpec, String> {
431 let sub = config.subagents();
432 Ok(match sub.executor.as_deref() {
433 Some("echo") => bamboo_subagent::provision::ExecutorSpec::Echo,
434 Some("bamboo_runtime") | None => bamboo_subagent::provision::ExecutorSpec::BambooRuntime,
435 Some("claude_code") => bamboo_subagent::provision::ExecutorSpec::ClaudeCode {
436 binary: sub.claude_code_binary.clone(),
437 model: sub.claude_code_model.clone(),
438 permission_mode: sub.claude_code_permission_mode.clone(),
439 inherit_user_config: sub.claude_code_inherit_user_config,
440 forward_env: sub.claude_code_forward_env.clone(),
441 },
442 Some("codex") => bamboo_subagent::provision::ExecutorSpec::Codex {
443 binary: sub.codex_binary.clone(),
444 model: sub.codex_model.clone(),
445 mode: sub.codex_mode.map(codex_mode_name),
446 sandbox: sub.codex_sandbox.map(codex_sandbox_name),
447 inherit_user_config: None,
448 auth_mode: Some(codex_auth_mode_name(
449 sub.codex_auth_mode.unwrap_or_default(),
450 )),
451 base_url: codex_base_url(
452 config,
453 sub.codex_auth_mode.unwrap_or_default(),
454 sub.codex_base_url.clone(),
455 ),
456 wire_api: sub.codex_wire_api.map(codex_wire_api_name),
457 provider_key_ref: sub
458 .codex_provider_key_ref
459 .as_ref()
460 .map(|reference| reference.as_str().to_string()),
461 forward_env: sub.codex_forward_env.clone(),
462 approval_policy: sub.codex_approval_policy.map(codex_approval_policy_name),
463 network_access: sub.codex_network_access,
464 allow_danger_bypass: sub.codex_allow_danger_bypass,
465 permission_profile: None,
466 workspace_owned: None,
467 },
468 Some(other) => return Err(format!("unknown subagents.executor '{other}'")),
469 })
470}
471
472fn resolve_schedulable_placements(
481 placements: &[bamboo_config::SchedulablePlacement],
482 nodes: &[bamboo_config::cluster_fabric::Node],
483) -> std::collections::HashMap<String, super::actor_adapter::ResolvedSchedulablePlacement> {
484 placements
487 .iter()
488 .map(|p| {
489 (
490 p.role.clone(),
491 super::actor_adapter::ResolvedSchedulablePlacement {
492 pool: p.pool.clone(),
493 host_label: node_label_for_role(nodes, &p.pool),
496 },
497 )
498 })
499 .collect()
500}
501
502fn node_label_for_role(
506 nodes: &[bamboo_config::cluster_fabric::Node],
507 role: &str,
508) -> Option<String> {
509 nodes
510 .iter()
511 .find(|n| n.deploy.default_role.as_deref() == Some(role))
512 .map(node_display_name)
513}
514
515fn node_label_for_endpoint(
519 nodes: &[bamboo_config::cluster_fabric::Node],
520 endpoint: &str,
521) -> Option<String> {
522 let host = endpoint
523 .trim()
524 .trim_start_matches("wss://")
525 .trim_start_matches("ws://")
526 .split(['/', ':'])
527 .next()
528 .unwrap_or("");
529 if host.is_empty() {
530 return None;
531 }
532 nodes
533 .iter()
534 .find(|n| match &n.placement {
535 bamboo_config::cluster_fabric::NodePlacement::Ssh(t) => t.host == host,
536 bamboo_config::cluster_fabric::NodePlacement::Local => false,
537 })
538 .map(node_display_name)
539}
540
541fn node_display_name(n: &bamboo_config::cluster_fabric::Node) -> String {
542 if !n.label.trim().is_empty() {
543 return n.label.clone();
544 }
545 match &n.placement {
546 bamboo_config::cluster_fabric::NodePlacement::Ssh(t) => t.host.clone(),
547 bamboo_config::cluster_fabric::NodePlacement::Local => "local".to_string(),
548 }
549}
550
551fn endpoint_looks_public(endpoint: &str) -> bool {
562 if endpoint.starts_with("wss://") {
563 return true;
564 }
565 let host = endpoint
566 .strip_prefix("ws://")
567 .unwrap_or(endpoint)
568 .split(['/', ':'])
569 .next()
570 .unwrap_or("");
571 !(host == "localhost" || host == "127.0.0.1" || host == "::1" || host.is_empty())
572}
573
574fn resolve_remote_placements(
575 placements: &[bamboo_config::RemoteActorPlacement],
576 nodes: &[bamboo_config::cluster_fabric::Node],
577) -> std::collections::HashMap<String, super::actor_adapter::ResolvedRemotePlacement> {
578 let mut out = std::collections::HashMap::new();
579 for p in placements {
580 let token = match p.token_env.as_deref() {
581 Some(env_var) => match std::env::var(env_var) {
582 Ok(token) => Some(token),
583 Err(_) => {
584 tracing::error!(
585 "remote placement for role '{}' token_env '{}' is not set; \
586 skipping (role falls back to local, NOT unauthenticated remote)",
587 p.role,
588 env_var
589 );
590 continue;
591 }
592 },
593 None => {
594 if endpoint_looks_public(&p.endpoint) {
598 tracing::warn!(
599 "remote placement for role '{}' has no token_env but targets a \
600 public-looking endpoint '{}'; work will be dispatched with NO bearer. \
601 Set token_env (and use wss://) for any non-loopback worker.",
602 p.role,
603 p.endpoint
604 );
605 }
606 None
607 }
608 };
609 out.insert(
610 p.role.clone(),
611 super::actor_adapter::ResolvedRemotePlacement {
612 endpoint: p.endpoint.clone(),
613 token,
614 ca_cert_file: p.ca_cert_file.as_ref().map(std::path::PathBuf::from),
615 host_label: node_label_for_endpoint(nodes, &p.endpoint),
618 },
619 );
620 }
621 out
622}
623
624pub fn extract_provider_credentials(
632 config: &Config,
633) -> Vec<bamboo_subagent::provision::ScopedCredential> {
634 let mut out = Vec::new();
635
636 fn push_instance(
637 out: &mut Vec<bamboo_subagent::provision::ScopedCredential>,
638 id: &str,
639 instance: &bamboo_config::ProviderInstanceConfig,
640 ) {
641 if !instance.enabled {
642 return;
643 }
644 let api_key = instance.api_key.trim().to_string();
645 if api_key.is_empty() {
646 return;
647 }
648 out.push(bamboo_subagent::provision::ScopedCredential {
649 provider: id.to_string(),
650 api_key,
651 base_url: instance.base_url.clone(),
652 provider_type: Some(instance.provider_type.clone()),
653 credential_ref: instance
654 .credential_ref
655 .as_ref()
656 .map(|reference| reference.as_str().to_string()),
657 });
658 }
659
660 if !config.provider_instances.is_empty() {
661 for (id, instance) in &config.provider_instances {
664 push_instance(&mut out, id, instance);
665 }
666
667 let default_id = config.effective_default_provider();
671 if !config.provider_instances.contains_key(default_id) {
672 if let Some((_, instance)) = bamboo_config::synthesize_legacy_instances(config)
673 .into_iter()
674 .find(|(id, _)| id == default_id)
675 {
676 push_instance(&mut out, default_id, &instance);
677 }
678 }
679 return out;
680 }
681
682 let mut push_legacy =
686 |name: &str, api_key: &str, base_url: Option<String>, credential_ref: Option<String>| {
687 let api_key = api_key.trim().to_string();
688 if api_key.is_empty() {
689 return;
690 }
691 out.push(bamboo_subagent::provision::ScopedCredential {
692 provider: name.to_string(),
693 api_key,
694 base_url,
695 provider_type: Some(name.to_string()),
696 credential_ref,
697 });
698 };
699 if let Some(c) = &config.providers().openai {
700 push_legacy(
701 "openai",
702 &c.api_key,
703 c.base_url.clone(),
704 c.credential_ref
705 .as_ref()
706 .map(|reference| reference.as_str().to_string()),
707 );
708 }
709 if let Some(c) = &config.providers().anthropic {
710 push_legacy(
711 "anthropic",
712 &c.api_key,
713 c.base_url.clone(),
714 c.credential_ref
715 .as_ref()
716 .map(|reference| reference.as_str().to_string()),
717 );
718 }
719 if let Some(c) = &config.providers().gemini {
720 push_legacy(
721 "gemini",
722 &c.api_key,
723 c.base_url.clone(),
724 c.credential_ref
725 .as_ref()
726 .map(|reference| reference.as_str().to_string()),
727 );
728 }
729 if let Some(c) = &config.providers().bodhi {
730 push_legacy(
731 "bodhi",
732 &c.api_key,
733 c.base_url.clone(),
734 c.credential_ref
735 .as_ref()
736 .map(|reference| reference.as_str().to_string()),
737 );
738 }
739
740 out
741}
742
743#[cfg(test)]
744mod codex_runtime_config_tests {
745 use super::{
746 codex_approval_policy_name, codex_auth_mode_name, codex_base_url, codex_mode_name,
747 codex_sandbox_name, codex_wire_api_name, subagent_executor_spec,
748 };
749 use bamboo_config::{
750 CodexApprovalPolicy, CodexAuthMode, CodexMode, CodexSandbox, CodexWireApi, CredentialRef,
751 };
752 use bamboo_llm::Config;
753 use bamboo_subagent::provision::ExecutorSpec;
754
755 #[test]
756 fn codex_runtime_mapping_keeps_parent_loopback_and_custom_url_unambiguous() {
757 let mut config = Config::default();
758 config.server.port = 5700;
759
760 assert_eq!(codex_auth_mode_name(CodexAuthMode::Bamboo), "bamboo");
761 assert_eq!(codex_mode_name(CodexMode::AppServer), "app_server");
762 assert_eq!(codex_wire_api_name(CodexWireApi::Responses), "responses");
763 assert_eq!(codex_sandbox_name(CodexSandbox::ReadOnly), "read-only");
764 assert_eq!(
765 codex_sandbox_name(CodexSandbox::WorkspaceWrite),
766 "workspace-write"
767 );
768 assert_eq!(
769 codex_approval_policy_name(CodexApprovalPolicy::OnFailure),
770 "on-failure"
771 );
772 assert_eq!(
773 codex_base_url(&config, CodexAuthMode::Bamboo, None).as_deref(),
774 Some("http://127.0.0.1:5700/openai/v1")
775 );
776 assert_eq!(
777 codex_base_url(
778 &config,
779 CodexAuthMode::Custom,
780 Some("https://provider.example/v1".to_string()),
781 )
782 .as_deref(),
783 Some("https://provider.example/v1")
784 );
785 assert_eq!(codex_base_url(&config, CodexAuthMode::Inherit, None), None);
786 assert_eq!(codex_base_url(&config, CodexAuthMode::ApiKey, None), None);
787 }
788
789 #[test]
790 fn durable_codex_fields_map_without_loss_to_worker_spawn_spec() {
791 let mut config = Config::default();
792 let subagents = config.subagents_mut();
793 subagents.executor = Some("codex".to_string());
794 subagents.codex_binary = Some("/opt/codex/bin/codex".to_string());
795 subagents.codex_model = Some("gpt-5.4".to_string());
796 subagents.codex_mode = Some(CodexMode::AppServer);
797 subagents.codex_auth_mode = Some(CodexAuthMode::Custom);
798 subagents.codex_base_url = Some("https://provider.example/v1".to_string());
799 subagents.codex_wire_api = Some(CodexWireApi::Responses);
800 subagents.codex_provider_key_ref = Some(
801 CredentialRef::parse("provider.codex-work.api_key").expect("valid credential ref"),
802 );
803 subagents.codex_forward_env = Some(vec!["HTTPS_PROXY".to_string()]);
804 subagents.codex_sandbox = Some(CodexSandbox::WorkspaceWrite);
805 subagents.codex_approval_policy = Some(CodexApprovalPolicy::OnRequest);
806 subagents.codex_network_access = Some(true);
807 subagents.codex_allow_danger_bypass = Some(false);
808
809 let spec = subagent_executor_spec(&config).expect("Codex config maps to executor spec");
810 let ExecutorSpec::Codex {
811 binary,
812 model,
813 mode,
814 sandbox,
815 auth_mode,
816 base_url,
817 wire_api,
818 provider_key_ref,
819 forward_env,
820 approval_policy,
821 network_access,
822 allow_danger_bypass,
823 ..
824 } = spec
825 else {
826 panic!("expected Codex executor spec");
827 };
828 assert_eq!(binary.as_deref(), Some("/opt/codex/bin/codex"));
829 assert_eq!(model.as_deref(), Some("gpt-5.4"));
830 assert_eq!(mode.as_deref(), Some("app_server"));
831 assert_eq!(sandbox.as_deref(), Some("workspace-write"));
832 assert_eq!(auth_mode.as_deref(), Some("custom"));
833 assert_eq!(base_url.as_deref(), Some("https://provider.example/v1"));
834 assert_eq!(wire_api.as_deref(), Some("responses"));
835 assert_eq!(
836 provider_key_ref.as_deref(),
837 Some("provider.codex-work.api_key")
838 );
839 assert_eq!(forward_env, Some(vec!["HTTPS_PROXY".to_string()]));
840 assert_eq!(approval_policy.as_deref(), Some("on-request"));
841 assert_eq!(network_access, Some(true));
842 assert_eq!(allow_danger_bypass, Some(false));
843 }
844}
845
846#[cfg(test)]
847mod extract_provider_credentials_tests {
848 use super::extract_provider_credentials;
849 use bamboo_config::{
850 AnthropicConfig, BodhiConfig, Config, OpenAIConfig, ProviderInstanceConfig,
851 };
852
853 fn instance(provider_type: &str, api_key: &str) -> ProviderInstanceConfig {
854 ProviderInstanceConfig {
855 provider_type: provider_type.to_string(),
856 label: None,
857 api_key: api_key.to_string(),
858 api_key_encrypted: None,
859 credential_ref: None,
860 base_url: None,
861 model: None,
862 fast_model: None,
863 vision_model: None,
864 reasoning_effort: None,
865 responses_only_models: Vec::new(),
866 request_overrides: None,
867 enabled: true,
868 extra: Default::default(),
869 }
870 }
871
872 #[test]
873 fn no_config_yields_no_credentials() {
874 let config = Config::default();
875 assert!(extract_provider_credentials(&config).is_empty());
876 }
877
878 #[test]
883 fn legacy_only_config_yields_credential() {
884 let mut config = Config::default();
885 config.providers_mut().anthropic = Some(AnthropicConfig {
886 api_key: "sk-ant-legacy".to_string(),
887 base_url: Some("https://api.anthropic.com".to_string()),
888 ..Default::default()
889 });
890
891 let creds = extract_provider_credentials(&config);
892 assert_eq!(creds.len(), 1);
893 let c = &creds[0];
894 assert_eq!(c.provider, "anthropic");
895 assert_eq!(c.api_key, "sk-ant-legacy");
896 assert_eq!(c.base_url.as_deref(), Some("https://api.anthropic.com"));
897 assert_eq!(c.provider_type.as_deref(), Some("anthropic"));
898 }
899
900 #[test]
904 fn legacy_bodhi_config_yields_credential() {
905 let mut config = Config::default();
906 config.providers_mut().bodhi = Some(BodhiConfig {
907 api_key: "bhi_sk_legacy".to_string(),
908 api_key_encrypted: None,
909 credential_ref: None,
910 base_url: None,
911 target_provider: None,
912 reasoning_effort: None,
913 extra: Default::default(),
914 });
915
916 let creds = extract_provider_credentials(&config);
917 assert_eq!(creds.len(), 1);
918 assert_eq!(creds[0].provider, "bodhi");
919 assert_eq!(creds[0].api_key, "bhi_sk_legacy");
920 }
921
922 #[test]
925 fn legacy_config_with_empty_api_key_is_skipped() {
926 let mut config = Config::default();
927 config.providers_mut().openai = Some(OpenAIConfig::default());
928 assert!(extract_provider_credentials(&config).is_empty());
929 }
930
931 #[test]
934 fn instance_mode_omits_unrelated_legacy_credentials() {
935 let mut config = Config::default();
936 config.providers_mut().anthropic = Some(AnthropicConfig {
937 api_key: "sk-ant-legacy".to_string(),
938 ..Default::default()
939 });
940 let mut openai_work = instance("openai", "sk-oai-work");
941 openai_work.credential_ref = Some(
942 bamboo_config::CredentialRef::parse("provider.openai-work.api_key")
943 .expect("valid provider credential reference"),
944 );
945 config
946 .provider_instances
947 .insert("openai-work".to_string(), openai_work);
948 config.default_provider_instance = Some("openai-work".to_string());
949
950 let creds = extract_provider_credentials(&config);
951
952 assert_eq!(creds.len(), 1);
953 assert_eq!(creds[0].provider, "openai-work");
954 assert_eq!(creds[0].api_key, "sk-oai-work");
955 assert_eq!(creds[0].provider_type.as_deref(), Some("openai"));
956 assert_eq!(
957 creds[0].credential_ref.as_deref(),
958 Some("provider.openai-work.api_key")
959 );
960 }
961
962 #[test]
963 fn hybrid_legacy_default_is_the_only_legacy_credential_exported() {
964 let mut config = Config::default();
965 config.providers_mut().anthropic = Some(AnthropicConfig {
966 api_key: "sk-ant-default".to_string(),
967 ..Default::default()
968 });
969 config.providers_mut().openai = Some(OpenAIConfig {
970 api_key: "sk-oai-stale".to_string(),
971 ..Default::default()
972 });
973 config
974 .provider_instances
975 .insert("work".to_string(), instance("openai", "sk-oai-work"));
976 config.default_provider_instance = Some("anthropic".to_string());
977
978 let mut creds = extract_provider_credentials(&config);
979 creds.sort_by(|a, b| a.provider.cmp(&b.provider));
980
981 assert_eq!(creds.len(), 2);
982 assert_eq!(creds[0].provider, "anthropic");
983 assert_eq!(creds[0].api_key, "sk-ant-default");
984 assert_eq!(creds[1].provider, "work");
985 assert_eq!(creds[1].api_key, "sk-oai-work");
986 assert!(creds
987 .iter()
988 .all(|credential| credential.api_key != "sk-oai-stale"));
989 }
990
991 #[test]
992 fn hybrid_legacy_provider_fallback_without_explicit_default_remains_exportable() {
993 let mut config = Config::default();
994 config.provider = "anthropic".to_string();
995 config.providers_mut().anthropic = Some(AnthropicConfig {
996 api_key: "sk-ant-effective-default".to_string(),
997 ..Default::default()
998 });
999 config
1000 .provider_instances
1001 .insert("work".to_string(), instance("openai", "sk-oai-work"));
1002
1003 let mut creds = extract_provider_credentials(&config);
1004 creds.sort_by(|a, b| a.provider.cmp(&b.provider));
1005
1006 assert_eq!(creds.len(), 2);
1007 assert_eq!(creds[0].provider, "anthropic");
1008 assert_eq!(creds[1].provider, "work");
1009 }
1010
1011 #[test]
1012 fn disabled_instance_credential_is_not_exported() {
1013 let mut config = Config::default();
1014 let mut disabled = instance("openai", "sk-disabled");
1015 disabled.enabled = false;
1016 config
1017 .provider_instances
1018 .insert("disabled".to_string(), disabled);
1019
1020 assert!(extract_provider_credentials(&config).is_empty());
1021 }
1022}
1023
1024#[cfg(test)]
1025mod placement_resolver_tests {
1026 use super::{node_display_name, resolve_remote_placements, resolve_schedulable_placements};
1027 use bamboo_config::cluster_fabric::{
1028 DeployProfile, Node, NodePlacement, SshAuth, SshTarget, TrustLevel,
1029 };
1030 use bamboo_config::{RemoteActorPlacement, SchedulablePlacement};
1031
1032 fn ssh_node(id: &str, label: &str, host: &str, default_role: Option<&str>) -> Node {
1033 Node {
1034 id: id.into(),
1035 label: label.into(),
1036 placement: NodePlacement::Ssh(SshTarget {
1037 host: host.into(),
1038 port: 22,
1039 username: "u".into(),
1040 auth: SshAuth::SystemSshConfig,
1041 host_key_fingerprint: None,
1042 }),
1043 trust_level: TrustLevel::default(),
1044 deploy: DeployProfile {
1045 default_role: default_role.map(String::from),
1046 ..Default::default()
1047 },
1048 state: None,
1049 enabled: true,
1050 }
1051 }
1052
1053 #[test]
1054 fn node_display_name_prefers_label_then_ssh_host() {
1055 let n = ssh_node("n1", "mini", "mini.local", None);
1056 assert_eq!(node_display_name(&n), "mini");
1057 let mut unlabeled = n.clone();
1058 unlabeled.label = String::new();
1059 assert_eq!(node_display_name(&unlabeled), "mini.local");
1060 }
1061
1062 #[test]
1063 fn schedulable_placement_takes_host_label_from_node_by_default_role() {
1064 let nodes = vec![ssh_node(
1065 "n1",
1066 "mini",
1067 "mini.local",
1068 Some("mac-mini-monitor"),
1069 )];
1070 let placements = vec![SchedulablePlacement {
1071 role: "mac-mini-monitor".into(),
1072 pool: "mac-mini-monitor".into(),
1073 ..Default::default()
1074 }];
1075 let out = resolve_schedulable_placements(&placements, &nodes);
1076 let r = out.get("mac-mini-monitor").expect("role resolved");
1077 assert_eq!(r.pool, "mac-mini-monitor");
1078 assert_eq!(r.host_label.as_deref(), Some("mini"));
1079 }
1080
1081 #[test]
1082 fn remote_placement_takes_host_label_from_node_by_ssh_host() {
1083 let nodes = vec![ssh_node("n1", "mini", "mini.local", None)];
1084 let placements = vec![RemoteActorPlacement {
1085 role: "explorer".into(),
1086 endpoint: "ws://mini.local:8899".into(),
1087 ..Default::default()
1088 }];
1089 let out = resolve_remote_placements(&placements, &nodes);
1090 assert_eq!(
1091 out.get("explorer").unwrap().host_label.as_deref(),
1092 Some("mini")
1093 );
1094 }
1095
1096 #[test]
1097 fn no_host_label_when_no_node_matches() {
1098 let nodes = vec![ssh_node("n1", "mini", "mini.local", Some("other-role"))];
1099 let sched = vec![SchedulablePlacement {
1100 role: "x".into(),
1101 pool: "unmatched".into(),
1102 ..Default::default()
1103 }];
1104 assert_eq!(
1105 resolve_schedulable_placements(&sched, &nodes)
1106 .get("x")
1107 .unwrap()
1108 .host_label,
1109 None
1110 );
1111 let remote = vec![RemoteActorPlacement {
1112 role: "y".into(),
1113 endpoint: "ws://other-host:9000".into(),
1114 ..Default::default()
1115 }];
1116 assert_eq!(
1117 resolve_remote_placements(&remote, &nodes)
1118 .get("y")
1119 .unwrap()
1120 .host_label,
1121 None
1122 );
1123 }
1124}