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.provider.clone(),
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.provider.clone(),
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 let mut push_legacy =
640 |name: &str, api_key: &str, base_url: Option<String>, credential_ref: Option<String>| {
641 let api_key = api_key.trim().to_string();
642 if api_key.is_empty() {
643 return;
644 }
645 out.push(bamboo_subagent::provision::ScopedCredential {
646 provider: name.to_string(),
647 api_key,
648 base_url,
649 provider_type: Some(name.to_string()),
650 credential_ref,
651 });
652 };
653 if let Some(c) = &config.providers().openai {
654 push_legacy(
655 "openai",
656 &c.api_key,
657 c.base_url.clone(),
658 c.credential_ref
659 .as_ref()
660 .map(|reference| reference.as_str().to_string()),
661 );
662 }
663 if let Some(c) = &config.providers().anthropic {
664 push_legacy(
665 "anthropic",
666 &c.api_key,
667 c.base_url.clone(),
668 c.credential_ref
669 .as_ref()
670 .map(|reference| reference.as_str().to_string()),
671 );
672 }
673 if let Some(c) = &config.providers().gemini {
674 push_legacy(
675 "gemini",
676 &c.api_key,
677 c.base_url.clone(),
678 c.credential_ref
679 .as_ref()
680 .map(|reference| reference.as_str().to_string()),
681 );
682 }
683 if let Some(c) = &config.providers().bodhi {
684 push_legacy(
685 "bodhi",
686 &c.api_key,
687 c.base_url.clone(),
688 c.credential_ref
689 .as_ref()
690 .map(|reference| reference.as_str().to_string()),
691 );
692 }
693
694 out.extend(config.provider_instances.iter().filter_map(|(id, inst)| {
699 let api_key = inst.api_key.trim().to_string();
700 if api_key.is_empty() {
701 return None;
702 }
703 Some(bamboo_subagent::provision::ScopedCredential {
704 provider: id.clone(),
705 api_key,
706 base_url: inst.base_url.clone(),
707 provider_type: Some(inst.provider_type.clone()),
708 credential_ref: inst
709 .credential_ref
710 .as_ref()
711 .map(|reference| reference.as_str().to_string()),
712 })
713 }));
714
715 out
716}
717
718#[cfg(test)]
719mod codex_runtime_config_tests {
720 use super::{
721 codex_approval_policy_name, codex_auth_mode_name, codex_base_url, codex_mode_name,
722 codex_sandbox_name, codex_wire_api_name, subagent_executor_spec,
723 };
724 use bamboo_config::{
725 CodexApprovalPolicy, CodexAuthMode, CodexMode, CodexSandbox, CodexWireApi, CredentialRef,
726 };
727 use bamboo_llm::Config;
728 use bamboo_subagent::provision::ExecutorSpec;
729
730 #[test]
731 fn codex_runtime_mapping_keeps_parent_loopback_and_custom_url_unambiguous() {
732 let mut config = Config::default();
733 config.server.port = 5700;
734
735 assert_eq!(codex_auth_mode_name(CodexAuthMode::Bamboo), "bamboo");
736 assert_eq!(codex_mode_name(CodexMode::AppServer), "app_server");
737 assert_eq!(codex_wire_api_name(CodexWireApi::Responses), "responses");
738 assert_eq!(codex_sandbox_name(CodexSandbox::ReadOnly), "read-only");
739 assert_eq!(
740 codex_sandbox_name(CodexSandbox::WorkspaceWrite),
741 "workspace-write"
742 );
743 assert_eq!(
744 codex_approval_policy_name(CodexApprovalPolicy::OnFailure),
745 "on-failure"
746 );
747 assert_eq!(
748 codex_base_url(&config, CodexAuthMode::Bamboo, None).as_deref(),
749 Some("http://127.0.0.1:5700/openai/v1")
750 );
751 assert_eq!(
752 codex_base_url(
753 &config,
754 CodexAuthMode::Custom,
755 Some("https://provider.example/v1".to_string()),
756 )
757 .as_deref(),
758 Some("https://provider.example/v1")
759 );
760 assert_eq!(codex_base_url(&config, CodexAuthMode::Inherit, None), None);
761 assert_eq!(codex_base_url(&config, CodexAuthMode::ApiKey, None), None);
762 }
763
764 #[test]
765 fn durable_codex_fields_map_without_loss_to_worker_spawn_spec() {
766 let mut config = Config::default();
767 let subagents = config.subagents_mut();
768 subagents.executor = Some("codex".to_string());
769 subagents.codex_binary = Some("/opt/codex/bin/codex".to_string());
770 subagents.codex_model = Some("gpt-5.4".to_string());
771 subagents.codex_mode = Some(CodexMode::AppServer);
772 subagents.codex_auth_mode = Some(CodexAuthMode::Custom);
773 subagents.codex_base_url = Some("https://provider.example/v1".to_string());
774 subagents.codex_wire_api = Some(CodexWireApi::Responses);
775 subagents.codex_provider_key_ref = Some(
776 CredentialRef::parse("provider.codex-work.api_key").expect("valid credential ref"),
777 );
778 subagents.codex_forward_env = Some(vec!["HTTPS_PROXY".to_string()]);
779 subagents.codex_sandbox = Some(CodexSandbox::WorkspaceWrite);
780 subagents.codex_approval_policy = Some(CodexApprovalPolicy::OnRequest);
781 subagents.codex_network_access = Some(true);
782 subagents.codex_allow_danger_bypass = Some(false);
783
784 let spec = subagent_executor_spec(&config).expect("Codex config maps to executor spec");
785 let ExecutorSpec::Codex {
786 binary,
787 model,
788 mode,
789 sandbox,
790 auth_mode,
791 base_url,
792 wire_api,
793 provider_key_ref,
794 forward_env,
795 approval_policy,
796 network_access,
797 allow_danger_bypass,
798 ..
799 } = spec
800 else {
801 panic!("expected Codex executor spec");
802 };
803 assert_eq!(binary.as_deref(), Some("/opt/codex/bin/codex"));
804 assert_eq!(model.as_deref(), Some("gpt-5.4"));
805 assert_eq!(mode.as_deref(), Some("app_server"));
806 assert_eq!(sandbox.as_deref(), Some("workspace-write"));
807 assert_eq!(auth_mode.as_deref(), Some("custom"));
808 assert_eq!(base_url.as_deref(), Some("https://provider.example/v1"));
809 assert_eq!(wire_api.as_deref(), Some("responses"));
810 assert_eq!(
811 provider_key_ref.as_deref(),
812 Some("provider.codex-work.api_key")
813 );
814 assert_eq!(forward_env, Some(vec!["HTTPS_PROXY".to_string()]));
815 assert_eq!(approval_policy.as_deref(), Some("on-request"));
816 assert_eq!(network_access, Some(true));
817 assert_eq!(allow_danger_bypass, Some(false));
818 }
819}
820
821#[cfg(test)]
822mod extract_provider_credentials_tests {
823 use super::extract_provider_credentials;
824 use bamboo_config::{
825 AnthropicConfig, BodhiConfig, Config, OpenAIConfig, ProviderInstanceConfig,
826 };
827
828 fn instance(provider_type: &str, api_key: &str) -> ProviderInstanceConfig {
829 ProviderInstanceConfig {
830 provider_type: provider_type.to_string(),
831 label: None,
832 api_key: api_key.to_string(),
833 api_key_encrypted: None,
834 credential_ref: None,
835 base_url: None,
836 model: None,
837 fast_model: None,
838 vision_model: None,
839 reasoning_effort: None,
840 responses_only_models: Vec::new(),
841 request_overrides: None,
842 enabled: true,
843 extra: Default::default(),
844 }
845 }
846
847 #[test]
848 fn no_config_yields_no_credentials() {
849 let config = Config::default();
850 assert!(extract_provider_credentials(&config).is_empty());
851 }
852
853 #[test]
858 fn legacy_only_config_yields_credential() {
859 let mut config = Config::default();
860 config.providers_mut().anthropic = Some(AnthropicConfig {
861 api_key: "sk-ant-legacy".to_string(),
862 base_url: Some("https://api.anthropic.com".to_string()),
863 ..Default::default()
864 });
865
866 let creds = extract_provider_credentials(&config);
867 assert_eq!(creds.len(), 1);
868 let c = &creds[0];
869 assert_eq!(c.provider, "anthropic");
870 assert_eq!(c.api_key, "sk-ant-legacy");
871 assert_eq!(c.base_url.as_deref(), Some("https://api.anthropic.com"));
872 assert_eq!(c.provider_type.as_deref(), Some("anthropic"));
873 }
874
875 #[test]
879 fn legacy_bodhi_config_yields_credential() {
880 let mut config = Config::default();
881 config.providers_mut().bodhi = Some(BodhiConfig {
882 api_key: "bhi_sk_legacy".to_string(),
883 api_key_encrypted: None,
884 credential_ref: None,
885 base_url: None,
886 target_provider: None,
887 reasoning_effort: None,
888 extra: Default::default(),
889 });
890
891 let creds = extract_provider_credentials(&config);
892 assert_eq!(creds.len(), 1);
893 assert_eq!(creds[0].provider, "bodhi");
894 assert_eq!(creds[0].api_key, "bhi_sk_legacy");
895 }
896
897 #[test]
900 fn legacy_config_with_empty_api_key_is_skipped() {
901 let mut config = Config::default();
902 config.providers_mut().openai = Some(OpenAIConfig::default());
903 assert!(extract_provider_credentials(&config).is_empty());
904 }
905
906 #[test]
909 fn legacy_and_instances_both_present_no_duplicates() {
910 let mut config = Config::default();
911 config.providers_mut().anthropic = Some(AnthropicConfig {
912 api_key: "sk-ant-legacy".to_string(),
913 ..Default::default()
914 });
915 let mut openai_work = instance("openai", "sk-oai-work");
916 openai_work.credential_ref = Some(
917 bamboo_config::CredentialRef::parse("provider.openai-work.api_key")
918 .expect("valid provider credential reference"),
919 );
920 config
921 .provider_instances
922 .insert("openai-work".to_string(), openai_work);
923
924 let mut creds = extract_provider_credentials(&config);
925 creds.sort_by(|a, b| a.provider.cmp(&b.provider));
926
927 assert_eq!(creds.len(), 2);
928 assert_eq!(creds[0].provider, "anthropic");
929 assert_eq!(creds[0].api_key, "sk-ant-legacy");
930 assert_eq!(creds[1].provider, "openai-work");
931 assert_eq!(creds[1].api_key, "sk-oai-work");
932 assert_eq!(creds[1].provider_type.as_deref(), Some("openai"));
933 assert_eq!(
934 creds[1].credential_ref.as_deref(),
935 Some("provider.openai-work.api_key")
936 );
937 }
938}
939
940#[cfg(test)]
941mod placement_resolver_tests {
942 use super::{node_display_name, resolve_remote_placements, resolve_schedulable_placements};
943 use bamboo_config::cluster_fabric::{
944 DeployProfile, Node, NodePlacement, SshAuth, SshTarget, TrustLevel,
945 };
946 use bamboo_config::{RemoteActorPlacement, SchedulablePlacement};
947
948 fn ssh_node(id: &str, label: &str, host: &str, default_role: Option<&str>) -> Node {
949 Node {
950 id: id.into(),
951 label: label.into(),
952 placement: NodePlacement::Ssh(SshTarget {
953 host: host.into(),
954 port: 22,
955 username: "u".into(),
956 auth: SshAuth::SystemSshConfig,
957 host_key_fingerprint: None,
958 }),
959 trust_level: TrustLevel::default(),
960 deploy: DeployProfile {
961 default_role: default_role.map(String::from),
962 ..Default::default()
963 },
964 state: None,
965 enabled: true,
966 }
967 }
968
969 #[test]
970 fn node_display_name_prefers_label_then_ssh_host() {
971 let n = ssh_node("n1", "mini", "mini.local", None);
972 assert_eq!(node_display_name(&n), "mini");
973 let mut unlabeled = n.clone();
974 unlabeled.label = String::new();
975 assert_eq!(node_display_name(&unlabeled), "mini.local");
976 }
977
978 #[test]
979 fn schedulable_placement_takes_host_label_from_node_by_default_role() {
980 let nodes = vec![ssh_node(
981 "n1",
982 "mini",
983 "mini.local",
984 Some("mac-mini-monitor"),
985 )];
986 let placements = vec![SchedulablePlacement {
987 role: "mac-mini-monitor".into(),
988 pool: "mac-mini-monitor".into(),
989 ..Default::default()
990 }];
991 let out = resolve_schedulable_placements(&placements, &nodes);
992 let r = out.get("mac-mini-monitor").expect("role resolved");
993 assert_eq!(r.pool, "mac-mini-monitor");
994 assert_eq!(r.host_label.as_deref(), Some("mini"));
995 }
996
997 #[test]
998 fn remote_placement_takes_host_label_from_node_by_ssh_host() {
999 let nodes = vec![ssh_node("n1", "mini", "mini.local", None)];
1000 let placements = vec![RemoteActorPlacement {
1001 role: "explorer".into(),
1002 endpoint: "ws://mini.local:8899".into(),
1003 ..Default::default()
1004 }];
1005 let out = resolve_remote_placements(&placements, &nodes);
1006 assert_eq!(
1007 out.get("explorer").unwrap().host_label.as_deref(),
1008 Some("mini")
1009 );
1010 }
1011
1012 #[test]
1013 fn no_host_label_when_no_node_matches() {
1014 let nodes = vec![ssh_node("n1", "mini", "mini.local", Some("other-role"))];
1015 let sched = vec![SchedulablePlacement {
1016 role: "x".into(),
1017 pool: "unmatched".into(),
1018 ..Default::default()
1019 }];
1020 assert_eq!(
1021 resolve_schedulable_placements(&sched, &nodes)
1022 .get("x")
1023 .unwrap()
1024 .host_label,
1025 None
1026 );
1027 let remote = vec![RemoteActorPlacement {
1028 role: "y".into(),
1029 endpoint: "ws://other-host:9000".into(),
1030 ..Default::default()
1031 }];
1032 assert_eq!(
1033 resolve_remote_placements(&remote, &nodes)
1034 .get("y")
1035 .unwrap()
1036 .host_label,
1037 None
1038 );
1039 }
1040}