1use std::sync::Arc;
2
3use crate::runtime::execution::{ExternalChildRunner, 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
132pub fn build_external_child_runner(config: &Config) -> Arc<dyn ExternalChildRunner> {
141 build_external_child_runner_with_registry(config, None)
142}
143
144pub fn build_external_child_runner_with_registry(
146 config: &Config,
147 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
148) -> Arc<dyn ExternalChildRunner> {
149 build_external_child_runner_with_registry_and_reviewer(config, approval_registry, None, None)
150}
151
152pub fn build_external_child_runner_with_registry_and_reviewer(
155 config: &Config,
156 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
157 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
158 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
159) -> Arc<dyn ExternalChildRunner> {
160 build_external_child_runner_with_codex_tokens(
161 config,
162 approval_registry,
163 approval_reviewer,
164 permission_config,
165 None,
166 )
167}
168
169pub fn build_external_child_runner_with_codex_tokens(
172 config: &Config,
173 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
174 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
175 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
176 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
177) -> Arc<dyn ExternalChildRunner> {
178 let agents = parse_external_agents(config);
179
180 let mut runners: Vec<Arc<dyn ExternalChildRunner>> = Vec::new();
181
182 match build_local_actor_runner(
186 config,
187 approval_registry.clone(),
188 approval_reviewer.clone(),
189 permission_config.clone(),
190 codex_run_tokens.clone(),
191 ) {
192 Ok(runner) => runners.push(runner),
193 Err(e) => tracing::error!("local actor sub-agent runner unavailable: {e}"),
194 }
195
196 for (_agent_id, profile) in agents {
197 if matches!(profile.protocol, ExternalAgentProtocol::Actor) {
199 let Some(worker_bin) = profile.worker_bin.as_ref() else {
200 tracing::error!(
201 "Actor agent profile {} has no worker_bin; skipping",
202 profile.agent_id
203 );
204 continue;
205 };
206 let fabric_dir = profile
210 .fabric_dir
211 .clone()
212 .map(std::path::PathBuf::from)
213 .unwrap_or_else(bamboo_config::paths::subagents_dir);
214 let executor = match profile.executor.as_deref() {
215 Some("echo") => bamboo_subagent::provision::ExecutorSpec::Echo,
216 Some("bamboo_runtime") | None => {
217 bamboo_subagent::provision::ExecutorSpec::BambooRuntime
218 }
219 Some("claude_code") => bamboo_subagent::provision::ExecutorSpec::ClaudeCode {
222 binary: profile.claude_code_binary.clone(),
223 model: profile.claude_code_model.clone(),
224 permission_mode: profile.claude_code_permission_mode.clone(),
225 inherit_user_config: profile.claude_code_inherit_user_config,
226 forward_env: profile.claude_code_forward_env.clone(),
227 },
228 Some("codex") => bamboo_subagent::provision::ExecutorSpec::Codex {
229 binary: profile.codex_binary.clone(),
230 model: profile.codex_model.clone(),
231 mode: profile.codex_mode.map(codex_mode_name),
232 sandbox: profile.codex_sandbox.map(codex_sandbox_name),
233 inherit_user_config: None,
234 auth_mode: Some(codex_auth_mode_name(
235 profile.codex_auth_mode.unwrap_or_default(),
236 )),
237 base_url: codex_base_url(
238 config,
239 profile.codex_auth_mode.unwrap_or_default(),
240 profile.codex_base_url.clone(),
241 ),
242 wire_api: profile.codex_wire_api.map(codex_wire_api_name),
243 provider_key_ref: profile
244 .codex_provider_key_ref
245 .as_ref()
246 .map(|reference| reference.as_str().to_string()),
247 forward_env: profile.codex_forward_env.clone(),
248 approval_policy: profile
249 .codex_approval_policy
250 .map(codex_approval_policy_name),
251 network_access: profile.codex_network_access,
252 allow_danger_bypass: profile.codex_allow_danger_bypass,
253 permission_profile: Some(profile.permission_profile.clone()),
254 workspace_owned: None,
255 },
256 Some(other) => {
257 tracing::error!(
258 "Actor agent profile {} has unknown executor '{}'; skipping",
259 profile.agent_id,
260 other
261 );
262 continue;
263 }
264 };
265 let mut runner = ActorChildRunner::new(
266 profile.agent_id.clone(),
267 std::path::PathBuf::from(worker_bin),
268 profile.worker_args.clone(),
269 fabric_dir,
270 executor,
271 extract_provider_credentials(config),
272 config.provider.clone(),
273 config
274 .subagents()
275 .max_concurrent
276 .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS),
277 );
278 if let Some(registry) = approval_registry.clone() {
279 runner = runner.with_approval_registry(registry);
280 }
281 if let Some(reviewer) = approval_reviewer.clone() {
282 runner = runner.with_approval_reviewer(reviewer);
283 }
284 if let Some(config) = permission_config.clone() {
285 runner = runner.with_permission_config(config);
286 }
287 runner = runner.with_codex_run_tokens(codex_run_tokens.clone());
288 runners.push(Arc::new(runner));
289 continue;
290 }
291
292 if !matches!(profile.protocol, ExternalAgentProtocol::A2aJsonRpc) {
293 tracing::warn!(
294 "External agent profile {} uses unsupported protocol {:?}",
295 profile.agent_id,
296 profile.protocol
297 );
298 continue;
299 }
300
301 let auth_token = match profile.auth_ref.as_ref() {
302 Some(ref_name) => match std::env::var(ref_name) {
303 Ok(token) => Some(token),
304 Err(_) => {
305 tracing::error!(
306 "External agent profile {} auth_ref env var {} is not set",
307 profile.agent_id,
308 ref_name
309 );
310 continue;
311 }
312 },
313 None => None,
314 };
315
316 let client_config = match A2AExternalChildRunner::build_client_config(&profile, auth_token)
317 {
318 Ok(cfg) => cfg,
319 Err(e) => {
320 tracing::error!(
321 "Failed to build A2A client config for profile {}: {}",
322 profile.agent_id,
323 e
324 );
325 continue;
326 }
327 };
328
329 let client = match A2AJsonRpcClient::new(client_config) {
330 Ok(c) => c,
331 Err(e) => {
332 tracing::error!(
333 "Failed to create A2A JSON-RPC client for profile {}: {}",
334 profile.agent_id,
335 e
336 );
337 continue;
338 }
339 };
340
341 runners.push(Arc::new(A2AExternalChildRunner::new(client, profile)));
342 }
343
344 Arc::new(CompositeExternalChildRunner::new(runners))
345}
346
347fn build_local_actor_runner(
352 config: &Config,
353 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
354 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
355 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
356 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
357) -> Result<Arc<dyn ExternalChildRunner>, String> {
358 let sub = config.subagents();
359
360 let (worker_bin, worker_args) = match &sub.worker_bin {
361 Some(custom) => (
362 std::path::PathBuf::from(custom),
363 sub.worker_args.clone().unwrap_or_default(),
364 ),
365 None => (
366 std::env::current_exe().map_err(|e| format!("cannot locate own executable: {e}"))?,
367 sub.worker_args
368 .clone()
369 .unwrap_or_else(|| vec!["subagent-worker".to_string()]),
370 ),
371 };
372
373 let fabric_dir = sub
376 .fabric_dir
377 .clone()
378 .map(std::path::PathBuf::from)
379 .unwrap_or_else(bamboo_config::paths::subagents_dir);
380
381 let executor = subagent_executor_spec(config)?;
382
383 let mut runner = ActorChildRunner::new(
384 super::config::LOCAL_ACTOR_AGENT_ID.to_string(),
385 worker_bin,
386 worker_args,
387 fabric_dir,
388 executor,
389 extract_provider_credentials(config),
390 config.provider.clone(),
391 sub.max_concurrent
392 .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS),
393 )
394 .with_remote_placements(resolve_remote_placements(
395 &sub.remote_placements,
396 &config.cluster_fabric.nodes,
397 ))
398 .with_schedulable_placements(resolve_schedulable_placements(
399 &sub.schedulable_placements,
400 &config.cluster_fabric.nodes,
401 ))
402 .with_bus(sub.broker.as_ref().map(|b| bamboo_subagent::BusEndpoint {
403 endpoint: b.endpoint.clone(),
404 token: b.token.clone(),
405 }))
406 .with_codex_run_tokens(codex_run_tokens);
407 if let Some(registry) = approval_registry {
408 runner = runner.with_approval_registry(registry);
409 }
410 if let Some(reviewer) = approval_reviewer {
411 runner = runner.with_approval_reviewer(reviewer);
412 }
413 if let Some(config) = permission_config {
414 runner = runner.with_permission_config(config);
415 }
416 Ok(Arc::new(runner))
417}
418
419fn subagent_executor_spec(
423 config: &Config,
424) -> Result<bamboo_subagent::provision::ExecutorSpec, String> {
425 let sub = config.subagents();
426 Ok(match sub.executor.as_deref() {
427 Some("echo") => bamboo_subagent::provision::ExecutorSpec::Echo,
428 Some("bamboo_runtime") | None => bamboo_subagent::provision::ExecutorSpec::BambooRuntime,
429 Some("claude_code") => bamboo_subagent::provision::ExecutorSpec::ClaudeCode {
430 binary: sub.claude_code_binary.clone(),
431 model: sub.claude_code_model.clone(),
432 permission_mode: sub.claude_code_permission_mode.clone(),
433 inherit_user_config: sub.claude_code_inherit_user_config,
434 forward_env: sub.claude_code_forward_env.clone(),
435 },
436 Some("codex") => bamboo_subagent::provision::ExecutorSpec::Codex {
437 binary: sub.codex_binary.clone(),
438 model: sub.codex_model.clone(),
439 mode: sub.codex_mode.map(codex_mode_name),
440 sandbox: sub.codex_sandbox.map(codex_sandbox_name),
441 inherit_user_config: None,
442 auth_mode: Some(codex_auth_mode_name(
443 sub.codex_auth_mode.unwrap_or_default(),
444 )),
445 base_url: codex_base_url(
446 config,
447 sub.codex_auth_mode.unwrap_or_default(),
448 sub.codex_base_url.clone(),
449 ),
450 wire_api: sub.codex_wire_api.map(codex_wire_api_name),
451 provider_key_ref: sub
452 .codex_provider_key_ref
453 .as_ref()
454 .map(|reference| reference.as_str().to_string()),
455 forward_env: sub.codex_forward_env.clone(),
456 approval_policy: sub.codex_approval_policy.map(codex_approval_policy_name),
457 network_access: sub.codex_network_access,
458 allow_danger_bypass: sub.codex_allow_danger_bypass,
459 permission_profile: None,
460 workspace_owned: None,
461 },
462 Some(other) => return Err(format!("unknown subagents.executor '{other}'")),
463 })
464}
465
466fn resolve_schedulable_placements(
475 placements: &[bamboo_config::SchedulablePlacement],
476 nodes: &[bamboo_config::cluster_fabric::Node],
477) -> std::collections::HashMap<String, super::actor_adapter::ResolvedSchedulablePlacement> {
478 placements
481 .iter()
482 .map(|p| {
483 (
484 p.role.clone(),
485 super::actor_adapter::ResolvedSchedulablePlacement {
486 pool: p.pool.clone(),
487 host_label: node_label_for_role(nodes, &p.pool),
490 },
491 )
492 })
493 .collect()
494}
495
496fn node_label_for_role(
500 nodes: &[bamboo_config::cluster_fabric::Node],
501 role: &str,
502) -> Option<String> {
503 nodes
504 .iter()
505 .find(|n| n.deploy.default_role.as_deref() == Some(role))
506 .map(node_display_name)
507}
508
509fn node_label_for_endpoint(
513 nodes: &[bamboo_config::cluster_fabric::Node],
514 endpoint: &str,
515) -> Option<String> {
516 let host = endpoint
517 .trim()
518 .trim_start_matches("wss://")
519 .trim_start_matches("ws://")
520 .split(['/', ':'])
521 .next()
522 .unwrap_or("");
523 if host.is_empty() {
524 return None;
525 }
526 nodes
527 .iter()
528 .find(|n| match &n.placement {
529 bamboo_config::cluster_fabric::NodePlacement::Ssh(t) => t.host == host,
530 bamboo_config::cluster_fabric::NodePlacement::Local => false,
531 })
532 .map(node_display_name)
533}
534
535fn node_display_name(n: &bamboo_config::cluster_fabric::Node) -> String {
536 if !n.label.trim().is_empty() {
537 return n.label.clone();
538 }
539 match &n.placement {
540 bamboo_config::cluster_fabric::NodePlacement::Ssh(t) => t.host.clone(),
541 bamboo_config::cluster_fabric::NodePlacement::Local => "local".to_string(),
542 }
543}
544
545fn endpoint_looks_public(endpoint: &str) -> bool {
556 if endpoint.starts_with("wss://") {
557 return true;
558 }
559 let host = endpoint
560 .strip_prefix("ws://")
561 .unwrap_or(endpoint)
562 .split(['/', ':'])
563 .next()
564 .unwrap_or("");
565 !(host == "localhost" || host == "127.0.0.1" || host == "::1" || host.is_empty())
566}
567
568fn resolve_remote_placements(
569 placements: &[bamboo_config::RemoteActorPlacement],
570 nodes: &[bamboo_config::cluster_fabric::Node],
571) -> std::collections::HashMap<String, super::actor_adapter::ResolvedRemotePlacement> {
572 let mut out = std::collections::HashMap::new();
573 for p in placements {
574 let token = match p.token_env.as_deref() {
575 Some(env_var) => match std::env::var(env_var) {
576 Ok(token) => Some(token),
577 Err(_) => {
578 tracing::error!(
579 "remote placement for role '{}' token_env '{}' is not set; \
580 skipping (role falls back to local, NOT unauthenticated remote)",
581 p.role,
582 env_var
583 );
584 continue;
585 }
586 },
587 None => {
588 if endpoint_looks_public(&p.endpoint) {
592 tracing::warn!(
593 "remote placement for role '{}' has no token_env but targets a \
594 public-looking endpoint '{}'; work will be dispatched with NO bearer. \
595 Set token_env (and use wss://) for any non-loopback worker.",
596 p.role,
597 p.endpoint
598 );
599 }
600 None
601 }
602 };
603 out.insert(
604 p.role.clone(),
605 super::actor_adapter::ResolvedRemotePlacement {
606 endpoint: p.endpoint.clone(),
607 token,
608 ca_cert_file: p.ca_cert_file.as_ref().map(std::path::PathBuf::from),
609 host_label: node_label_for_endpoint(nodes, &p.endpoint),
612 },
613 );
614 }
615 out
616}
617
618pub fn extract_provider_credentials(
626 config: &Config,
627) -> Vec<bamboo_subagent::provision::ScopedCredential> {
628 let mut out = Vec::new();
629
630 let mut push_legacy =
634 |name: &str, api_key: &str, base_url: Option<String>, credential_ref: Option<String>| {
635 let api_key = api_key.trim().to_string();
636 if api_key.is_empty() {
637 return;
638 }
639 out.push(bamboo_subagent::provision::ScopedCredential {
640 provider: name.to_string(),
641 api_key,
642 base_url,
643 provider_type: Some(name.to_string()),
644 credential_ref,
645 });
646 };
647 if let Some(c) = &config.providers().openai {
648 push_legacy(
649 "openai",
650 &c.api_key,
651 c.base_url.clone(),
652 c.credential_ref
653 .as_ref()
654 .map(|reference| reference.as_str().to_string()),
655 );
656 }
657 if let Some(c) = &config.providers().anthropic {
658 push_legacy(
659 "anthropic",
660 &c.api_key,
661 c.base_url.clone(),
662 c.credential_ref
663 .as_ref()
664 .map(|reference| reference.as_str().to_string()),
665 );
666 }
667 if let Some(c) = &config.providers().gemini {
668 push_legacy(
669 "gemini",
670 &c.api_key,
671 c.base_url.clone(),
672 c.credential_ref
673 .as_ref()
674 .map(|reference| reference.as_str().to_string()),
675 );
676 }
677 if let Some(c) = &config.providers().bodhi {
678 push_legacy(
679 "bodhi",
680 &c.api_key,
681 c.base_url.clone(),
682 c.credential_ref
683 .as_ref()
684 .map(|reference| reference.as_str().to_string()),
685 );
686 }
687
688 out.extend(config.provider_instances.iter().filter_map(|(id, inst)| {
693 let api_key = inst.api_key.trim().to_string();
694 if api_key.is_empty() {
695 return None;
696 }
697 Some(bamboo_subagent::provision::ScopedCredential {
698 provider: id.clone(),
699 api_key,
700 base_url: inst.base_url.clone(),
701 provider_type: Some(inst.provider_type.clone()),
702 credential_ref: inst
703 .credential_ref
704 .as_ref()
705 .map(|reference| reference.as_str().to_string()),
706 })
707 }));
708
709 out
710}
711
712#[cfg(test)]
713mod codex_runtime_config_tests {
714 use super::{
715 codex_approval_policy_name, codex_auth_mode_name, codex_base_url, codex_mode_name,
716 codex_sandbox_name, codex_wire_api_name, subagent_executor_spec,
717 };
718 use bamboo_config::{
719 CodexApprovalPolicy, CodexAuthMode, CodexMode, CodexSandbox, CodexWireApi, CredentialRef,
720 };
721 use bamboo_llm::Config;
722 use bamboo_subagent::provision::ExecutorSpec;
723
724 #[test]
725 fn codex_runtime_mapping_keeps_parent_loopback_and_custom_url_unambiguous() {
726 let mut config = Config::default();
727 config.server.port = 5700;
728
729 assert_eq!(codex_auth_mode_name(CodexAuthMode::Bamboo), "bamboo");
730 assert_eq!(codex_mode_name(CodexMode::AppServer), "app_server");
731 assert_eq!(codex_wire_api_name(CodexWireApi::Responses), "responses");
732 assert_eq!(codex_sandbox_name(CodexSandbox::ReadOnly), "read-only");
733 assert_eq!(
734 codex_sandbox_name(CodexSandbox::WorkspaceWrite),
735 "workspace-write"
736 );
737 assert_eq!(
738 codex_approval_policy_name(CodexApprovalPolicy::OnFailure),
739 "on-failure"
740 );
741 assert_eq!(
742 codex_base_url(&config, CodexAuthMode::Bamboo, None).as_deref(),
743 Some("http://127.0.0.1:5700/openai/v1")
744 );
745 assert_eq!(
746 codex_base_url(
747 &config,
748 CodexAuthMode::Custom,
749 Some("https://provider.example/v1".to_string()),
750 )
751 .as_deref(),
752 Some("https://provider.example/v1")
753 );
754 assert_eq!(codex_base_url(&config, CodexAuthMode::Inherit, None), None);
755 assert_eq!(codex_base_url(&config, CodexAuthMode::ApiKey, None), None);
756 }
757
758 #[test]
759 fn durable_codex_fields_map_without_loss_to_worker_spawn_spec() {
760 let mut config = Config::default();
761 let subagents = config.subagents_mut();
762 subagents.executor = Some("codex".to_string());
763 subagents.codex_binary = Some("/opt/codex/bin/codex".to_string());
764 subagents.codex_model = Some("gpt-5.4".to_string());
765 subagents.codex_mode = Some(CodexMode::AppServer);
766 subagents.codex_auth_mode = Some(CodexAuthMode::Custom);
767 subagents.codex_base_url = Some("https://provider.example/v1".to_string());
768 subagents.codex_wire_api = Some(CodexWireApi::Responses);
769 subagents.codex_provider_key_ref = Some(
770 CredentialRef::parse("provider.codex-work.api_key").expect("valid credential ref"),
771 );
772 subagents.codex_forward_env = Some(vec!["HTTPS_PROXY".to_string()]);
773 subagents.codex_sandbox = Some(CodexSandbox::WorkspaceWrite);
774 subagents.codex_approval_policy = Some(CodexApprovalPolicy::OnRequest);
775 subagents.codex_network_access = Some(true);
776 subagents.codex_allow_danger_bypass = Some(false);
777
778 let spec = subagent_executor_spec(&config).expect("Codex config maps to executor spec");
779 let ExecutorSpec::Codex {
780 binary,
781 model,
782 mode,
783 sandbox,
784 auth_mode,
785 base_url,
786 wire_api,
787 provider_key_ref,
788 forward_env,
789 approval_policy,
790 network_access,
791 allow_danger_bypass,
792 ..
793 } = spec
794 else {
795 panic!("expected Codex executor spec");
796 };
797 assert_eq!(binary.as_deref(), Some("/opt/codex/bin/codex"));
798 assert_eq!(model.as_deref(), Some("gpt-5.4"));
799 assert_eq!(mode.as_deref(), Some("app_server"));
800 assert_eq!(sandbox.as_deref(), Some("workspace-write"));
801 assert_eq!(auth_mode.as_deref(), Some("custom"));
802 assert_eq!(base_url.as_deref(), Some("https://provider.example/v1"));
803 assert_eq!(wire_api.as_deref(), Some("responses"));
804 assert_eq!(
805 provider_key_ref.as_deref(),
806 Some("provider.codex-work.api_key")
807 );
808 assert_eq!(forward_env, Some(vec!["HTTPS_PROXY".to_string()]));
809 assert_eq!(approval_policy.as_deref(), Some("on-request"));
810 assert_eq!(network_access, Some(true));
811 assert_eq!(allow_danger_bypass, Some(false));
812 }
813}
814
815#[cfg(test)]
816mod extract_provider_credentials_tests {
817 use super::extract_provider_credentials;
818 use bamboo_config::{
819 AnthropicConfig, BodhiConfig, Config, OpenAIConfig, ProviderInstanceConfig,
820 };
821
822 fn instance(provider_type: &str, api_key: &str) -> ProviderInstanceConfig {
823 ProviderInstanceConfig {
824 provider_type: provider_type.to_string(),
825 label: None,
826 api_key: api_key.to_string(),
827 api_key_encrypted: None,
828 credential_ref: None,
829 base_url: None,
830 model: None,
831 fast_model: None,
832 vision_model: None,
833 reasoning_effort: None,
834 responses_only_models: Vec::new(),
835 request_overrides: None,
836 enabled: true,
837 extra: Default::default(),
838 }
839 }
840
841 #[test]
842 fn no_config_yields_no_credentials() {
843 let config = Config::default();
844 assert!(extract_provider_credentials(&config).is_empty());
845 }
846
847 #[test]
852 fn legacy_only_config_yields_credential() {
853 let mut config = Config::default();
854 config.providers_mut().anthropic = Some(AnthropicConfig {
855 api_key: "sk-ant-legacy".to_string(),
856 base_url: Some("https://api.anthropic.com".to_string()),
857 ..Default::default()
858 });
859
860 let creds = extract_provider_credentials(&config);
861 assert_eq!(creds.len(), 1);
862 let c = &creds[0];
863 assert_eq!(c.provider, "anthropic");
864 assert_eq!(c.api_key, "sk-ant-legacy");
865 assert_eq!(c.base_url.as_deref(), Some("https://api.anthropic.com"));
866 assert_eq!(c.provider_type.as_deref(), Some("anthropic"));
867 }
868
869 #[test]
873 fn legacy_bodhi_config_yields_credential() {
874 let mut config = Config::default();
875 config.providers_mut().bodhi = Some(BodhiConfig {
876 api_key: "bhi_sk_legacy".to_string(),
877 api_key_encrypted: None,
878 credential_ref: None,
879 base_url: None,
880 target_provider: None,
881 reasoning_effort: None,
882 extra: Default::default(),
883 });
884
885 let creds = extract_provider_credentials(&config);
886 assert_eq!(creds.len(), 1);
887 assert_eq!(creds[0].provider, "bodhi");
888 assert_eq!(creds[0].api_key, "bhi_sk_legacy");
889 }
890
891 #[test]
894 fn legacy_config_with_empty_api_key_is_skipped() {
895 let mut config = Config::default();
896 config.providers_mut().openai = Some(OpenAIConfig::default());
897 assert!(extract_provider_credentials(&config).is_empty());
898 }
899
900 #[test]
903 fn legacy_and_instances_both_present_no_duplicates() {
904 let mut config = Config::default();
905 config.providers_mut().anthropic = Some(AnthropicConfig {
906 api_key: "sk-ant-legacy".to_string(),
907 ..Default::default()
908 });
909 let mut openai_work = instance("openai", "sk-oai-work");
910 openai_work.credential_ref = Some(
911 bamboo_config::CredentialRef::parse("provider.openai-work.api_key")
912 .expect("valid provider credential reference"),
913 );
914 config
915 .provider_instances
916 .insert("openai-work".to_string(), openai_work);
917
918 let mut creds = extract_provider_credentials(&config);
919 creds.sort_by(|a, b| a.provider.cmp(&b.provider));
920
921 assert_eq!(creds.len(), 2);
922 assert_eq!(creds[0].provider, "anthropic");
923 assert_eq!(creds[0].api_key, "sk-ant-legacy");
924 assert_eq!(creds[1].provider, "openai-work");
925 assert_eq!(creds[1].api_key, "sk-oai-work");
926 assert_eq!(creds[1].provider_type.as_deref(), Some("openai"));
927 assert_eq!(
928 creds[1].credential_ref.as_deref(),
929 Some("provider.openai-work.api_key")
930 );
931 }
932}
933
934#[cfg(test)]
935mod placement_resolver_tests {
936 use super::{node_display_name, resolve_remote_placements, resolve_schedulable_placements};
937 use bamboo_config::cluster_fabric::{
938 DeployProfile, Node, NodePlacement, SshAuth, SshTarget, TrustLevel,
939 };
940 use bamboo_config::{RemoteActorPlacement, SchedulablePlacement};
941
942 fn ssh_node(id: &str, label: &str, host: &str, default_role: Option<&str>) -> Node {
943 Node {
944 id: id.into(),
945 label: label.into(),
946 placement: NodePlacement::Ssh(SshTarget {
947 host: host.into(),
948 port: 22,
949 username: "u".into(),
950 auth: SshAuth::SystemSshConfig,
951 host_key_fingerprint: None,
952 }),
953 trust_level: TrustLevel::default(),
954 deploy: DeployProfile {
955 default_role: default_role.map(String::from),
956 ..Default::default()
957 },
958 state: None,
959 enabled: true,
960 }
961 }
962
963 #[test]
964 fn node_display_name_prefers_label_then_ssh_host() {
965 let n = ssh_node("n1", "mini", "mini.local", None);
966 assert_eq!(node_display_name(&n), "mini");
967 let mut unlabeled = n.clone();
968 unlabeled.label = String::new();
969 assert_eq!(node_display_name(&unlabeled), "mini.local");
970 }
971
972 #[test]
973 fn schedulable_placement_takes_host_label_from_node_by_default_role() {
974 let nodes = vec![ssh_node(
975 "n1",
976 "mini",
977 "mini.local",
978 Some("mac-mini-monitor"),
979 )];
980 let placements = vec![SchedulablePlacement {
981 role: "mac-mini-monitor".into(),
982 pool: "mac-mini-monitor".into(),
983 ..Default::default()
984 }];
985 let out = resolve_schedulable_placements(&placements, &nodes);
986 let r = out.get("mac-mini-monitor").expect("role resolved");
987 assert_eq!(r.pool, "mac-mini-monitor");
988 assert_eq!(r.host_label.as_deref(), Some("mini"));
989 }
990
991 #[test]
992 fn remote_placement_takes_host_label_from_node_by_ssh_host() {
993 let nodes = vec![ssh_node("n1", "mini", "mini.local", None)];
994 let placements = vec![RemoteActorPlacement {
995 role: "explorer".into(),
996 endpoint: "ws://mini.local:8899".into(),
997 ..Default::default()
998 }];
999 let out = resolve_remote_placements(&placements, &nodes);
1000 assert_eq!(
1001 out.get("explorer").unwrap().host_label.as_deref(),
1002 Some("mini")
1003 );
1004 }
1005
1006 #[test]
1007 fn no_host_label_when_no_node_matches() {
1008 let nodes = vec![ssh_node("n1", "mini", "mini.local", Some("other-role"))];
1009 let sched = vec![SchedulablePlacement {
1010 role: "x".into(),
1011 pool: "unmatched".into(),
1012 ..Default::default()
1013 }];
1014 assert_eq!(
1015 resolve_schedulable_placements(&sched, &nodes)
1016 .get("x")
1017 .unwrap()
1018 .host_label,
1019 None
1020 );
1021 let remote = vec![RemoteActorPlacement {
1022 role: "y".into(),
1023 endpoint: "ws://other-host:9000".into(),
1024 ..Default::default()
1025 }];
1026 assert_eq!(
1027 resolve_remote_placements(&remote, &nodes)
1028 .get("y")
1029 .unwrap()
1030 .host_label,
1031 None
1032 );
1033 }
1034}