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
22fn codex_auth_mode_name(mode: bamboo_config::CodexAuthMode) -> String {
23 match mode {
24 bamboo_config::CodexAuthMode::Inherit => "inherit",
25 bamboo_config::CodexAuthMode::ApiKey => "api_key",
26 bamboo_config::CodexAuthMode::Custom => "custom",
27 bamboo_config::CodexAuthMode::Bamboo => "bamboo",
28 }
29 .to_string()
30}
31
32fn codex_wire_api_name(wire_api: bamboo_config::CodexWireApi) -> String {
33 match wire_api {
34 bamboo_config::CodexWireApi::Responses => "responses",
35 }
36 .to_string()
37}
38
39fn codex_mode_name(mode: bamboo_config::CodexMode) -> String {
40 match mode {
41 bamboo_config::CodexMode::Exec => "exec",
42 bamboo_config::CodexMode::AppServer => "app_server",
43 }
44 .to_string()
45}
46
47fn codex_sandbox_name(sandbox: bamboo_config::CodexSandbox) -> String {
48 match sandbox {
49 bamboo_config::CodexSandbox::ReadOnly => "read-only",
50 bamboo_config::CodexSandbox::WorkspaceWrite => "workspace-write",
51 bamboo_config::CodexSandbox::DangerFullAccess => "danger-full-access",
52 }
53 .to_string()
54}
55
56fn codex_approval_policy_name(policy: bamboo_config::CodexApprovalPolicy) -> String {
57 match policy {
58 bamboo_config::CodexApprovalPolicy::Never => "never",
59 bamboo_config::CodexApprovalPolicy::OnFailure => "on-failure",
60 bamboo_config::CodexApprovalPolicy::OnRequest => "on-request",
61 }
62 .to_string()
63}
64
65fn codex_base_url(
66 config: &Config,
67 mode: bamboo_config::CodexAuthMode,
68 custom: Option<String>,
69) -> Option<String> {
70 match mode {
71 bamboo_config::CodexAuthMode::Custom => custom,
72 bamboo_config::CodexAuthMode::Bamboo => {
73 let scheme = if config.server.tls.is_some() {
74 "https"
75 } else {
76 "http"
77 };
78 Some(format!(
79 "{scheme}://127.0.0.1:{}/openai/v1",
80 config.server.port
81 ))
82 }
83 bamboo_config::CodexAuthMode::Inherit | bamboo_config::CodexAuthMode::ApiKey => None,
84 }
85}
86
87pub struct CompositeExternalChildRunner {
89 runners: Vec<Arc<dyn ExternalChildRunner>>,
90}
91
92impl CompositeExternalChildRunner {
93 pub fn new(runners: Vec<Arc<dyn ExternalChildRunner>>) -> Self {
94 Self { runners }
95 }
96}
97
98#[async_trait]
99impl ExternalChildRunner for CompositeExternalChildRunner {
100 fn set_ticket_service(&self, service: Option<Arc<bamboo_tickets::TicketService>>) {
101 for runner in &self.runners {
102 runner.set_ticket_service(service.clone());
103 }
104 }
105
106 async fn validate_required_child_context_route(
107 &self,
108 session: &bamboo_agent_core::Session,
109 ) -> Result<(), String> {
110 for runner in &self.runners {
111 if runner.should_handle(session).await {
112 return runner.validate_required_child_context_route(session).await;
113 }
114 }
115 Err("required_child_context_unsupported: no matching registered worker route".into())
116 }
117 async fn should_handle(&self, session: &bamboo_agent_core::Session) -> bool {
118 for runner in &self.runners {
119 if runner.should_handle(session).await {
120 return true;
121 }
122 }
123 false
124 }
125
126 async fn execute_external_child(
127 &self,
128 session: &mut bamboo_agent_core::Session,
129 job: &SpawnJob,
130 event_tx: mpsc::Sender<AgentEvent>,
131 cancel_token: CancellationToken,
132 ) -> crate::runtime::runner::Result<()> {
133 for runner in &self.runners {
134 if runner.should_handle(session).await {
135 return runner
136 .execute_external_child(session, job, event_tx, cancel_token)
137 .await;
138 }
139 }
140 Err(AgentError::LLM(
141 "No matching external child runner found for session metadata".to_string(),
142 ))
143 }
144
145 async fn commit_durable_child_delivery(
146 &self,
147 session: &bamboo_agent_core::Session,
148 activation_run_id: &str,
149 ) -> Result<(), String> {
150 for runner in &self.runners {
151 runner
152 .commit_durable_child_delivery(session, activation_run_id)
153 .await?;
154 }
155 Ok(())
156 }
157
158 async fn confirm_durable_child_delivery(
159 &self,
160 session: &bamboo_agent_core::Session,
161 activation_run_id: &str,
162 history_committed: bool,
163 ) -> Result<(), String> {
164 for runner in &self.runners {
165 runner
166 .confirm_durable_child_delivery(session, activation_run_id, history_committed)
167 .await?;
168 }
169 Ok(())
170 }
171
172 async fn prepare_durable_child_delivery(
173 &self,
174 session: &bamboo_agent_core::Session,
175 activation_run_id: &str,
176 ) -> Result<bool, String> {
177 let mut prepared = false;
178 for runner in &self.runners {
179 prepared |= runner
180 .prepare_durable_child_delivery(session, activation_run_id)
181 .await?;
182 }
183 Ok(prepared)
184 }
185
186 fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
191 for runner in &self.runners {
192 runner.set_escalation_bridge(bridge.clone());
193 }
194 }
195
196 fn set_session_inbox_runtime(&self, binding: Option<SessionInboxRuntimeBinding>) {
197 for runner in &self.runners {
198 runner.set_session_inbox_runtime(binding.clone());
199 }
200 }
201
202 fn set_actor_directory_store(&self, store: Option<Arc<bamboo_storage::SessionStoreV2>>) {
203 for runner in &self.runners {
204 runner.set_actor_directory_store(store.clone());
205 }
206 }
207
208 fn set_canonical_subagent_tool(&self, tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>) {
209 for runner in &self.runners {
210 runner.set_canonical_subagent_tool(tool.clone());
211 }
212 }
213
214 fn set_actor_event_observer(
215 &self,
216 observer: Option<Arc<dyn super::actor_event_stream::ActorEventObserver>>,
217 ) {
218 for runner in &self.runners {
219 runner.set_actor_event_observer(observer.clone());
220 }
221 }
222}
223
224pub fn build_external_child_runner(config: &Config) -> Arc<dyn ExternalChildRunner> {
233 build_external_child_runner_with_registry(config, None)
234}
235
236pub fn build_external_child_runner_with_registry(
238 config: &Config,
239 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
240) -> Arc<dyn ExternalChildRunner> {
241 build_external_child_runner_with_registry_and_reviewer(config, approval_registry, None, None)
242}
243
244pub fn build_external_child_runner_with_registry_and_reviewer(
247 config: &Config,
248 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
249 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
250 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
251) -> Arc<dyn ExternalChildRunner> {
252 build_external_child_runner_with_codex_tokens(
253 config,
254 approval_registry,
255 approval_reviewer,
256 permission_config,
257 None,
258 )
259}
260
261pub fn build_external_child_runner_with_codex_tokens(
264 config: &Config,
265 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
266 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
267 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
268 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
269) -> Arc<dyn ExternalChildRunner> {
270 build_external_child_runner_internal(
271 config,
272 None,
273 approval_registry,
274 approval_reviewer,
275 permission_config,
276 codex_run_tokens,
277 None,
278 )
279}
280
281pub fn build_external_child_runner_with_live_config_and_codex_tokens(
285 config: &Config,
286 live_provider_config: Arc<tokio::sync::RwLock<Config>>,
287 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
288 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
289 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
290 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
291) -> Arc<dyn ExternalChildRunner> {
292 build_external_child_runner_internal(
293 config,
294 Some(live_provider_config),
295 approval_registry,
296 approval_reviewer,
297 permission_config,
298 codex_run_tokens,
299 None,
300 )
301}
302
303#[allow(clippy::too_many_arguments)]
306pub fn build_external_child_runner_with_native_tool_ceiling(
307 config: &Config,
308 live_provider_config: Arc<tokio::sync::RwLock<Config>>,
309 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
310 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
311 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
312 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
313 native_tool_ceiling: Arc<dyn NativeToolCeilingSource>,
314) -> Arc<dyn ExternalChildRunner> {
315 build_external_child_runner_internal(
316 config,
317 Some(live_provider_config),
318 approval_registry,
319 approval_reviewer,
320 permission_config,
321 codex_run_tokens,
322 Some(native_tool_ceiling),
323 )
324}
325
326fn build_external_child_runner_internal(
327 config: &Config,
328 live_provider_config: Option<Arc<tokio::sync::RwLock<Config>>>,
329 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
330 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
331 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
332 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
333 native_tool_ceiling: Option<Arc<dyn NativeToolCeilingSource>>,
334) -> Arc<dyn ExternalChildRunner> {
335 let agents = parse_external_agents(config);
336
337 let mut runners: Vec<Arc<dyn ExternalChildRunner>> = Vec::new();
338
339 match build_local_actor_runner(
343 config,
344 approval_registry.clone(),
345 approval_reviewer.clone(),
346 permission_config.clone(),
347 codex_run_tokens.clone(),
348 live_provider_config.clone(),
349 native_tool_ceiling,
350 ) {
351 Ok(runner) => runners.push(runner),
352 Err(e) => tracing::error!("local actor sub-agent runner unavailable: {e}"),
353 }
354
355 for (_agent_id, profile) in agents {
356 if matches!(profile.protocol, ExternalAgentProtocol::Actor) {
358 let Some(worker_bin) = profile.worker_bin.as_ref() else {
359 tracing::error!(
360 "Actor agent profile {} has no worker_bin; skipping",
361 profile.agent_id
362 );
363 continue;
364 };
365 let fabric_dir = profile
369 .fabric_dir
370 .clone()
371 .map(std::path::PathBuf::from)
372 .unwrap_or_else(bamboo_config::paths::subagents_dir);
373 let executor = match profile.executor.as_deref() {
374 Some("echo") => bamboo_subagent::provision::ExecutorSpec::Echo,
375 Some("bamboo_runtime") | None => {
376 bamboo_subagent::provision::ExecutorSpec::BambooRuntime
377 }
378 Some("claude_code") => bamboo_subagent::provision::ExecutorSpec::ClaudeCode {
381 binary: profile.claude_code_binary.clone(),
382 model: profile.claude_code_model.clone(),
383 permission_mode: profile.claude_code_permission_mode.clone(),
384 inherit_user_config: profile.claude_code_inherit_user_config,
385 forward_env: profile.claude_code_forward_env.clone(),
386 },
387 Some("codex") => bamboo_subagent::provision::ExecutorSpec::Codex {
388 binary: profile.codex_binary.clone(),
389 model: profile.codex_model.clone(),
390 mode: profile.codex_mode.map(codex_mode_name),
391 sandbox: profile.codex_sandbox.map(codex_sandbox_name),
392 inherit_user_config: None,
393 auth_mode: Some(codex_auth_mode_name(
394 profile.codex_auth_mode.unwrap_or_default(),
395 )),
396 base_url: codex_base_url(
397 config,
398 profile.codex_auth_mode.unwrap_or_default(),
399 profile.codex_base_url.clone(),
400 ),
401 wire_api: profile.codex_wire_api.map(codex_wire_api_name),
402 provider_key_ref: profile
403 .codex_provider_key_ref
404 .as_ref()
405 .map(|reference| reference.as_str().to_string()),
406 forward_env: profile.codex_forward_env.clone(),
407 approval_policy: profile
408 .codex_approval_policy
409 .map(codex_approval_policy_name),
410 network_access: profile.codex_network_access,
411 allow_danger_bypass: profile.codex_allow_danger_bypass,
412 permission_profile: Some(profile.permission_profile.clone()),
413 workspace_owned: None,
414 },
415 Some(other) => {
416 tracing::error!(
417 "Actor agent profile {} has unknown executor '{}'; skipping",
418 profile.agent_id,
419 other
420 );
421 continue;
422 }
423 };
424 let mut runner = ActorChildRunner::new(
425 profile.agent_id.clone(),
426 std::path::PathBuf::from(worker_bin),
427 profile.worker_args.clone(),
428 fabric_dir,
429 executor,
430 extract_provider_credentials(config),
431 config.effective_default_provider().to_string(),
432 config
433 .subagents()
434 .max_concurrent
435 .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS),
436 );
437 if let Some(registry) = approval_registry.clone() {
438 runner = runner.with_approval_registry(registry);
439 }
440 if let Some(reviewer) = approval_reviewer.clone() {
441 runner = runner.with_approval_reviewer(reviewer);
442 }
443 if let Some(config) = permission_config.clone() {
444 runner = runner.with_permission_config(config);
445 }
446 if let Some(config) = live_provider_config.clone() {
447 runner = runner.with_live_provider_config(config);
448 }
449 runner = runner.with_codex_run_tokens(codex_run_tokens.clone());
450 runners.push(Arc::new(runner));
451 continue;
452 }
453
454 if !matches!(profile.protocol, ExternalAgentProtocol::A2aJsonRpc) {
455 tracing::warn!(
456 "External agent profile {} uses unsupported protocol {:?}",
457 profile.agent_id,
458 profile.protocol
459 );
460 continue;
461 }
462
463 let auth_token = match profile.auth_ref.as_ref() {
464 Some(ref_name) => match std::env::var(ref_name) {
465 Ok(token) => Some(token),
466 Err(_) => {
467 tracing::error!(
468 "External agent profile {} auth_ref env var {} is not set",
469 profile.agent_id,
470 ref_name
471 );
472 continue;
473 }
474 },
475 None => None,
476 };
477
478 let client_config = match A2AExternalChildRunner::build_client_config(&profile, auth_token)
479 {
480 Ok(cfg) => cfg,
481 Err(e) => {
482 tracing::error!(
483 "Failed to build A2A client config for profile {}: {}",
484 profile.agent_id,
485 e
486 );
487 continue;
488 }
489 };
490
491 let client = match A2AJsonRpcClient::new(client_config) {
492 Ok(c) => c,
493 Err(e) => {
494 tracing::error!(
495 "Failed to create A2A JSON-RPC client for profile {}: {}",
496 profile.agent_id,
497 e
498 );
499 continue;
500 }
501 };
502
503 runners.push(Arc::new(A2AExternalChildRunner::new(client, profile)));
504 }
505
506 Arc::new(CompositeExternalChildRunner::new(runners))
507}
508
509fn build_local_actor_runner(
514 config: &Config,
515 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
516 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
517 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
518 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
519 live_provider_config: Option<Arc<tokio::sync::RwLock<Config>>>,
520 native_tool_ceiling: Option<Arc<dyn NativeToolCeilingSource>>,
521) -> Result<Arc<dyn ExternalChildRunner>, String> {
522 let sub = config.subagents();
523
524 let (worker_bin, worker_args) = match &sub.worker_bin {
525 Some(custom) => (
526 std::path::PathBuf::from(custom),
527 sub.worker_args.clone().unwrap_or_default(),
528 ),
529 None => (
530 std::env::current_exe().map_err(|e| format!("cannot locate own executable: {e}"))?,
531 sub.worker_args
532 .clone()
533 .unwrap_or_else(|| vec!["subagent-worker".to_string()]),
534 ),
535 };
536
537 let fabric_dir = sub
540 .fabric_dir
541 .clone()
542 .map(std::path::PathBuf::from)
543 .unwrap_or_else(bamboo_config::paths::subagents_dir);
544
545 let executor = subagent_executor_spec(config)?;
546
547 let mut runner = ActorChildRunner::new(
548 super::config::LOCAL_ACTOR_AGENT_ID.to_string(),
549 worker_bin,
550 worker_args,
551 fabric_dir,
552 executor,
553 extract_provider_credentials(config),
554 config.effective_default_provider().to_string(),
555 sub.max_concurrent
556 .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS),
557 )
558 .with_builtin_required_context_route(sub.worker_bin.is_none() && sub.worker_args.is_none())
559 .with_remote_placements(resolve_remote_placements(
560 &sub.remote_placements,
561 &config.cluster_fabric.nodes,
562 ))
563 .with_schedulable_placements(resolve_schedulable_placements(
564 &sub.schedulable_placements,
565 &config.cluster_fabric.nodes,
566 ))
567 .with_bus(sub.broker.as_ref().map(|b| bamboo_subagent::BusEndpoint {
568 endpoint: b.endpoint.clone(),
569 token: b.token.clone(),
570 }))
571 .with_codex_run_tokens(codex_run_tokens)
572 .with_native_tool_ceiling_source(native_tool_ceiling);
573 if let Some(registry) = approval_registry {
574 runner = runner.with_approval_registry(registry);
575 }
576 if let Some(reviewer) = approval_reviewer {
577 runner = runner.with_approval_reviewer(reviewer);
578 }
579 if let Some(config) = permission_config {
580 runner = runner.with_permission_config(config);
581 }
582 if let Some(config) = live_provider_config {
583 runner = runner.with_live_provider_config(config);
584 }
585 Ok(Arc::new(runner))
586}
587
588fn subagent_executor_spec(
592 config: &Config,
593) -> Result<bamboo_subagent::provision::ExecutorSpec, String> {
594 let sub = config.subagents();
595 Ok(match sub.executor.as_deref() {
596 Some("echo") => bamboo_subagent::provision::ExecutorSpec::Echo,
597 Some("bamboo_runtime") | None => bamboo_subagent::provision::ExecutorSpec::BambooRuntime,
598 Some("claude_code") => bamboo_subagent::provision::ExecutorSpec::ClaudeCode {
599 binary: sub.claude_code_binary.clone(),
600 model: sub.claude_code_model.clone(),
601 permission_mode: sub.claude_code_permission_mode.clone(),
602 inherit_user_config: sub.claude_code_inherit_user_config,
603 forward_env: sub.claude_code_forward_env.clone(),
604 },
605 Some("codex") => bamboo_subagent::provision::ExecutorSpec::Codex {
606 binary: sub.codex_binary.clone(),
607 model: sub.codex_model.clone(),
608 mode: sub.codex_mode.map(codex_mode_name),
609 sandbox: sub.codex_sandbox.map(codex_sandbox_name),
610 inherit_user_config: None,
611 auth_mode: Some(codex_auth_mode_name(
612 sub.codex_auth_mode.unwrap_or_default(),
613 )),
614 base_url: codex_base_url(
615 config,
616 sub.codex_auth_mode.unwrap_or_default(),
617 sub.codex_base_url.clone(),
618 ),
619 wire_api: sub.codex_wire_api.map(codex_wire_api_name),
620 provider_key_ref: sub
621 .codex_provider_key_ref
622 .as_ref()
623 .map(|reference| reference.as_str().to_string()),
624 forward_env: sub.codex_forward_env.clone(),
625 approval_policy: sub.codex_approval_policy.map(codex_approval_policy_name),
626 network_access: sub.codex_network_access,
627 allow_danger_bypass: sub.codex_allow_danger_bypass,
628 permission_profile: None,
629 workspace_owned: None,
630 },
631 Some(other) => return Err(format!("unknown subagents.executor '{other}'")),
632 })
633}
634
635fn valid_placement_identifier(value: &str) -> bool {
636 !value.is_empty()
637 && value.len() <= 128
638 && value
639 .bytes()
640 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
641}
642
643fn valid_placement_requirements(value: &bamboo_config::OperatorPlacementRequirements) -> bool {
644 valid_placement_identifier(&value.trust_zone)
645 && value
646 .workspace_label
647 .as_deref()
648 .is_none_or(valid_placement_identifier)
649 && value
650 .network_zone
651 .as_deref()
652 .is_none_or(valid_placement_identifier)
653 && value.required_tools.len() <= 256
654 && value
655 .required_tools
656 .iter()
657 .all(|tool| valid_placement_identifier(tool))
658}
659
660fn valid_scoped_broker_endpoint(value: &str) -> bool {
661 value.len() <= 2048
662 && url::Url::parse(value).is_ok_and(|url| {
663 url.scheme() == "wss"
664 && url.host_str().is_some()
665 && url.username().is_empty()
666 && url.password().is_none()
667 && url.query().is_none()
668 && url.fragment().is_none()
669 })
670}
671
672fn resolved_broker_token(name: Option<&str>) -> Option<String> {
673 name.filter(|name| !name.is_empty() && name.len() <= 256)
674 .and_then(|name| std::env::var(name).ok())
675 .filter(|token| {
676 (32..=256).contains(&token.len()) && token.bytes().all(|byte| byte.is_ascii_graphic())
677 })
678}
679
680fn resolve_schedulable_placements(
683 placements: &[bamboo_config::SchedulablePlacement],
684 nodes: &[bamboo_config::cluster_fabric::Node],
685) -> std::collections::HashMap<String, super::actor_adapter::ResolvedSchedulablePlacement> {
686 let mut out = std::collections::HashMap::new();
687 for p in placements {
688 let token = resolved_broker_token(p.token_env.as_deref());
689 let valid = valid_placement_identifier(&p.pool)
690 && valid_scoped_broker_endpoint(&p.registry_url)
691 && p.token_env.is_some()
692 && token.is_some()
693 && p.ca_cert_file.as_ref().is_some_and(|path| {
694 bamboo_broker::client_config_trusting_cert(std::path::Path::new(path)).is_ok()
695 })
696 && p.broker_parent.as_ref().is_some_and(|parent| {
697 valid_placement_identifier(&parent.parent_mailbox)
698 && valid_placement_identifier(&parent.parent_role)
699 })
700 && p.placement_requirements
701 .as_ref()
702 .is_some_and(valid_placement_requirements);
703 let resolved = super::actor_adapter::ResolvedSchedulablePlacement {
704 pool: p.pool.clone(),
705 host_label: node_label_for_role(nodes, &p.pool),
706 endpoint: p.registry_url.clone(),
707 token: if valid { token } else { None },
708 ca_cert_file: p.ca_cert_file.as_ref().map(std::path::PathBuf::from),
709 broker_parent: if valid { p.broker_parent.clone() } else { None },
710 requirements: if valid {
711 p.placement_requirements.clone()
712 } else {
713 None
714 },
715 };
716 if out.insert(p.role.clone(), resolved).is_some() {
717 if let Some(route) = out.get_mut(&p.role) {
719 route.broker_parent = None;
720 }
721 }
722 }
723 out
724}
725
726fn node_label_for_role(
730 nodes: &[bamboo_config::cluster_fabric::Node],
731 role: &str,
732) -> Option<String> {
733 nodes
734 .iter()
735 .find(|n| n.deploy.default_role.as_deref() == Some(role))
736 .map(node_display_name)
737}
738
739fn node_label_for_endpoint(
743 nodes: &[bamboo_config::cluster_fabric::Node],
744 endpoint: &str,
745) -> Option<String> {
746 let host = endpoint
747 .trim()
748 .trim_start_matches("wss://")
749 .trim_start_matches("ws://")
750 .split(['/', ':'])
751 .next()
752 .unwrap_or("");
753 if host.is_empty() {
754 return None;
755 }
756 nodes
757 .iter()
758 .find(|n| match &n.placement {
759 bamboo_config::cluster_fabric::NodePlacement::Ssh(t) => t.host == host,
760 bamboo_config::cluster_fabric::NodePlacement::Local => false,
761 })
762 .map(node_display_name)
763}
764
765fn node_display_name(n: &bamboo_config::cluster_fabric::Node) -> String {
766 if !n.label.trim().is_empty() {
767 return n.label.clone();
768 }
769 match &n.placement {
770 bamboo_config::cluster_fabric::NodePlacement::Ssh(t) => t.host.clone(),
771 bamboo_config::cluster_fabric::NodePlacement::Local => "local".to_string(),
772 }
773}
774
775fn resolve_remote_placements(
779 placements: &[bamboo_config::RemoteActorPlacement],
780 nodes: &[bamboo_config::cluster_fabric::Node],
781) -> std::collections::HashMap<String, super::actor_adapter::ResolvedRemotePlacement> {
782 let mut out = std::collections::HashMap::new();
783 for p in placements {
784 let duplicate_strict = placements
785 .iter()
786 .any(|other| other.role == p.role && other.broker_peer.is_some())
787 && placements
788 .iter()
789 .filter(|other| other.role == p.role)
790 .count()
791 != 1;
792 if duplicate_strict {
793 out.insert(
794 p.role.clone(),
795 super::actor_adapter::ResolvedRemotePlacement {
796 endpoint: String::new(),
797 token: None,
798 ca_cert_file: None,
799 host_label: Some("remote".into()),
800 broker_peer: Some(Err(())),
801 requirements: None,
802 },
803 );
804 continue;
805 }
806 if let Some(peer) = &p.broker_peer {
807 let token = resolved_broker_token(p.token_env.as_deref());
808 let valid = peer.valid()
809 && valid_scoped_broker_endpoint(&p.endpoint)
810 && p.token_env.is_some()
811 && token.is_some()
812 && p.ca_cert_file.as_ref().is_some_and(|path| {
813 bamboo_broker::client_config_trusting_cert(std::path::Path::new(path)).is_ok()
814 })
815 && p.placement_requirements
816 .as_ref()
817 .is_some_and(valid_placement_requirements);
818 out.insert(
819 p.role.clone(),
820 super::actor_adapter::ResolvedRemotePlacement {
821 endpoint: p.endpoint.clone(),
822 token: if valid { token } else { None },
823 ca_cert_file: p.ca_cert_file.as_ref().map(std::path::PathBuf::from),
824 host_label: Some("remote".into()),
825 broker_peer: Some(if valid { Ok(peer.clone()) } else { Err(()) }),
826 requirements: if valid {
827 p.placement_requirements.clone()
828 } else {
829 None
830 },
831 },
832 );
833 continue; }
835 out.insert(
836 p.role.clone(),
837 super::actor_adapter::ResolvedRemotePlacement {
838 endpoint: p.endpoint.clone(),
839 token: None,
840 ca_cert_file: p.ca_cert_file.as_ref().map(std::path::PathBuf::from),
841 host_label: node_label_for_endpoint(nodes, &p.endpoint),
844 broker_peer: Some(Err(())),
845 requirements: None,
846 },
847 );
848 }
849 out
850}
851
852pub fn extract_provider_credentials(
860 config: &Config,
861) -> Vec<bamboo_subagent::provision::ScopedCredential> {
862 let mut out = Vec::new();
863
864 fn push_instance(
865 out: &mut Vec<bamboo_subagent::provision::ScopedCredential>,
866 id: &str,
867 instance: &bamboo_config::ProviderInstanceConfig,
868 ) {
869 if !instance.enabled {
870 return;
871 }
872 let api_key = instance.api_key.trim().to_string();
873 if api_key.is_empty() {
874 return;
875 }
876 out.push(bamboo_subagent::provision::ScopedCredential {
877 provider: id.to_string(),
878 api_key,
879 base_url: instance.base_url.clone(),
880 provider_type: Some(instance.provider_type.clone()),
881 credential_ref: instance
882 .credential_ref
883 .as_ref()
884 .map(|reference| reference.as_str().to_string()),
885 });
886 }
887
888 if !config.provider_instances.is_empty() {
889 for (id, instance) in &config.provider_instances {
892 push_instance(&mut out, id, instance);
893 }
894
895 let default_id = config.effective_default_provider();
899 if !config.provider_instances.contains_key(default_id) {
900 if let Some((_, instance)) = bamboo_config::synthesize_legacy_instances(config)
901 .into_iter()
902 .find(|(id, _)| id == default_id)
903 {
904 push_instance(&mut out, default_id, &instance);
905 }
906 }
907 return out;
908 }
909
910 let mut push_legacy =
914 |name: &str, api_key: &str, base_url: Option<String>, credential_ref: Option<String>| {
915 let api_key = api_key.trim().to_string();
916 if api_key.is_empty() {
917 return;
918 }
919 out.push(bamboo_subagent::provision::ScopedCredential {
920 provider: name.to_string(),
921 api_key,
922 base_url,
923 provider_type: Some(name.to_string()),
924 credential_ref,
925 });
926 };
927 if let Some(c) = &config.providers().openai {
928 push_legacy(
929 "openai",
930 &c.api_key,
931 c.base_url.clone(),
932 c.credential_ref
933 .as_ref()
934 .map(|reference| reference.as_str().to_string()),
935 );
936 }
937 if let Some(c) = &config.providers().anthropic {
938 push_legacy(
939 "anthropic",
940 &c.api_key,
941 c.base_url.clone(),
942 c.credential_ref
943 .as_ref()
944 .map(|reference| reference.as_str().to_string()),
945 );
946 }
947 if let Some(c) = &config.providers().gemini {
948 push_legacy(
949 "gemini",
950 &c.api_key,
951 c.base_url.clone(),
952 c.credential_ref
953 .as_ref()
954 .map(|reference| reference.as_str().to_string()),
955 );
956 }
957 if let Some(c) = &config.providers().bodhi {
958 push_legacy(
959 "bodhi",
960 &c.api_key,
961 c.base_url.clone(),
962 c.credential_ref
963 .as_ref()
964 .map(|reference| reference.as_str().to_string()),
965 );
966 }
967
968 out
969}
970
971#[cfg(test)]
972mod codex_runtime_config_tests {
973 #[tokio::test]
974 async fn required_context_uses_registered_launch_snapshot_after_live_config_flip() {
975 let mut candidate = bamboo_agent_core::Session::new("preflight", "model");
976 candidate.metadata = super::super::config::resolve_runtime_metadata(
977 &bamboo_llm::Config::default(),
978 "worker",
979 );
980 candidate
981 .metadata
982 .insert("subagent_type".into(), "worker".into());
983 for kind in ["custom", "args", "codex", "remote"] {
984 let mut config = bamboo_llm::Config::default();
985 let sub = config.subagents_mut();
986 sub.broker = Some(bamboo_config::BrokerClientConfig {
987 endpoint: "ws://127.0.0.1:9998".into(),
988 token: "fixture".into(),
989 ..Default::default()
990 });
991 match kind {
992 "custom" => sub.worker_bin = Some("/bin/false".into()),
993 "args" => sub.worker_args = Some(vec!["subagent-worker".into()]),
994 "codex" => sub.executor = Some("codex".into()),
995 _ => sub
996 .remote_placements
997 .push(bamboo_config::RemoteActorPlacement {
998 role: "worker".into(),
999 endpoint: "ws://127.0.0.1:9999".into(),
1000 ..Default::default()
1001 }),
1002 }
1003 let live = std::sync::Arc::new(tokio::sync::RwLock::new(config.clone()));
1004 let runner = super::build_external_child_runner_with_live_config_and_codex_tokens(
1005 &config,
1006 live.clone(),
1007 None,
1008 None,
1009 None,
1010 None,
1011 );
1012 *live.write().await = bamboo_llm::Config::default();
1013 let error = runner
1014 .validate_required_child_context_route(&candidate)
1015 .await
1016 .unwrap_err();
1017 assert!(
1018 error.contains("required_child_context_unsupported"),
1019 "{kind}"
1020 );
1021 }
1022 }
1023
1024 use super::{
1025 codex_approval_policy_name, codex_auth_mode_name, codex_base_url, codex_mode_name,
1026 codex_sandbox_name, codex_wire_api_name, subagent_executor_spec,
1027 };
1028 use bamboo_config::{
1029 CodexApprovalPolicy, CodexAuthMode, CodexMode, CodexSandbox, CodexWireApi, CredentialRef,
1030 };
1031 use bamboo_llm::Config;
1032 use bamboo_subagent::provision::ExecutorSpec;
1033
1034 #[test]
1035 fn codex_runtime_mapping_keeps_parent_loopback_and_custom_url_unambiguous() {
1036 let mut config = Config::default();
1037 config.server.port = 5700;
1038
1039 assert_eq!(codex_auth_mode_name(CodexAuthMode::Bamboo), "bamboo");
1040 assert_eq!(codex_mode_name(CodexMode::AppServer), "app_server");
1041 assert_eq!(codex_wire_api_name(CodexWireApi::Responses), "responses");
1042 assert_eq!(codex_sandbox_name(CodexSandbox::ReadOnly), "read-only");
1043 assert_eq!(
1044 codex_sandbox_name(CodexSandbox::WorkspaceWrite),
1045 "workspace-write"
1046 );
1047 assert_eq!(
1048 codex_approval_policy_name(CodexApprovalPolicy::OnFailure),
1049 "on-failure"
1050 );
1051 assert_eq!(
1052 codex_base_url(&config, CodexAuthMode::Bamboo, None).as_deref(),
1053 Some("http://127.0.0.1:5700/openai/v1")
1054 );
1055 assert_eq!(
1056 codex_base_url(
1057 &config,
1058 CodexAuthMode::Custom,
1059 Some("https://provider.example/v1".to_string()),
1060 )
1061 .as_deref(),
1062 Some("https://provider.example/v1")
1063 );
1064 assert_eq!(codex_base_url(&config, CodexAuthMode::Inherit, None), None);
1065 assert_eq!(codex_base_url(&config, CodexAuthMode::ApiKey, None), None);
1066 }
1067
1068 #[test]
1069 fn durable_codex_fields_map_without_loss_to_worker_spawn_spec() {
1070 let mut config = Config::default();
1071 let subagents = config.subagents_mut();
1072 subagents.executor = Some("codex".to_string());
1073 subagents.codex_binary = Some("/opt/codex/bin/codex".to_string());
1074 subagents.codex_model = Some("gpt-5.4".to_string());
1075 subagents.codex_mode = Some(CodexMode::AppServer);
1076 subagents.codex_auth_mode = Some(CodexAuthMode::Custom);
1077 subagents.codex_base_url = Some("https://provider.example/v1".to_string());
1078 subagents.codex_wire_api = Some(CodexWireApi::Responses);
1079 subagents.codex_provider_key_ref = Some(
1080 CredentialRef::parse("provider.codex-work.api_key").expect("valid credential ref"),
1081 );
1082 subagents.codex_forward_env = Some(vec!["HTTPS_PROXY".to_string()]);
1083 subagents.codex_sandbox = Some(CodexSandbox::WorkspaceWrite);
1084 subagents.codex_approval_policy = Some(CodexApprovalPolicy::OnRequest);
1085 subagents.codex_network_access = Some(true);
1086 subagents.codex_allow_danger_bypass = Some(false);
1087
1088 let spec = subagent_executor_spec(&config).expect("Codex config maps to executor spec");
1089 let ExecutorSpec::Codex {
1090 binary,
1091 model,
1092 mode,
1093 sandbox,
1094 auth_mode,
1095 base_url,
1096 wire_api,
1097 provider_key_ref,
1098 forward_env,
1099 approval_policy,
1100 network_access,
1101 allow_danger_bypass,
1102 ..
1103 } = spec
1104 else {
1105 panic!("expected Codex executor spec");
1106 };
1107 assert_eq!(binary.as_deref(), Some("/opt/codex/bin/codex"));
1108 assert_eq!(model.as_deref(), Some("gpt-5.4"));
1109 assert_eq!(mode.as_deref(), Some("app_server"));
1110 assert_eq!(sandbox.as_deref(), Some("workspace-write"));
1111 assert_eq!(auth_mode.as_deref(), Some("custom"));
1112 assert_eq!(base_url.as_deref(), Some("https://provider.example/v1"));
1113 assert_eq!(wire_api.as_deref(), Some("responses"));
1114 assert_eq!(
1115 provider_key_ref.as_deref(),
1116 Some("provider.codex-work.api_key")
1117 );
1118 assert_eq!(forward_env, Some(vec!["HTTPS_PROXY".to_string()]));
1119 assert_eq!(approval_policy.as_deref(), Some("on-request"));
1120 assert_eq!(network_access, Some(true));
1121 assert_eq!(allow_danger_bypass, Some(false));
1122 }
1123}
1124
1125#[cfg(test)]
1126mod extract_provider_credentials_tests {
1127 use super::extract_provider_credentials;
1128 use bamboo_config::{
1129 AnthropicConfig, BodhiConfig, Config, OpenAIConfig, ProviderInstanceConfig,
1130 };
1131
1132 fn instance(provider_type: &str, api_key: &str) -> ProviderInstanceConfig {
1133 ProviderInstanceConfig {
1134 provider_type: provider_type.to_string(),
1135 label: None,
1136 api_key: api_key.to_string(),
1137 api_key_encrypted: None,
1138 credential_ref: None,
1139 base_url: None,
1140 model: None,
1141 fast_model: None,
1142 vision_model: None,
1143 reasoning_effort: None,
1144 responses_only_models: Vec::new(),
1145 request_overrides: None,
1146 enabled: true,
1147 extra: Default::default(),
1148 }
1149 }
1150
1151 #[test]
1152 fn no_config_yields_no_credentials() {
1153 let config = Config::default();
1154 assert!(extract_provider_credentials(&config).is_empty());
1155 }
1156
1157 #[test]
1162 fn legacy_only_config_yields_credential() {
1163 let mut config = Config::default();
1164 config.providers_mut().anthropic = Some(AnthropicConfig {
1165 api_key: "sk-ant-legacy".to_string(),
1166 base_url: Some("https://api.anthropic.com".to_string()),
1167 ..Default::default()
1168 });
1169
1170 let creds = extract_provider_credentials(&config);
1171 assert_eq!(creds.len(), 1);
1172 let c = &creds[0];
1173 assert_eq!(c.provider, "anthropic");
1174 assert_eq!(c.api_key, "sk-ant-legacy");
1175 assert_eq!(c.base_url.as_deref(), Some("https://api.anthropic.com"));
1176 assert_eq!(c.provider_type.as_deref(), Some("anthropic"));
1177 }
1178
1179 #[test]
1183 fn legacy_bodhi_config_yields_credential() {
1184 let mut config = Config::default();
1185 config.providers_mut().bodhi = Some(BodhiConfig {
1186 api_key: "bhi_sk_legacy".to_string(),
1187 api_key_encrypted: None,
1188 credential_ref: None,
1189 base_url: None,
1190 target_provider: None,
1191 reasoning_effort: None,
1192 extra: Default::default(),
1193 });
1194
1195 let creds = extract_provider_credentials(&config);
1196 assert_eq!(creds.len(), 1);
1197 assert_eq!(creds[0].provider, "bodhi");
1198 assert_eq!(creds[0].api_key, "bhi_sk_legacy");
1199 }
1200
1201 #[test]
1204 fn legacy_config_with_empty_api_key_is_skipped() {
1205 let mut config = Config::default();
1206 config.providers_mut().openai = Some(OpenAIConfig::default());
1207 assert!(extract_provider_credentials(&config).is_empty());
1208 }
1209
1210 #[test]
1213 fn instance_mode_omits_unrelated_legacy_credentials() {
1214 let mut config = Config::default();
1215 config.providers_mut().anthropic = Some(AnthropicConfig {
1216 api_key: "sk-ant-legacy".to_string(),
1217 ..Default::default()
1218 });
1219 let mut openai_work = instance("openai", "sk-oai-work");
1220 openai_work.credential_ref = Some(
1221 bamboo_config::CredentialRef::parse("provider.openai-work.api_key")
1222 .expect("valid provider credential reference"),
1223 );
1224 config
1225 .provider_instances
1226 .insert("openai-work".to_string(), openai_work);
1227 config.default_provider_instance = Some("openai-work".to_string());
1228
1229 let creds = extract_provider_credentials(&config);
1230
1231 assert_eq!(creds.len(), 1);
1232 assert_eq!(creds[0].provider, "openai-work");
1233 assert_eq!(creds[0].api_key, "sk-oai-work");
1234 assert_eq!(creds[0].provider_type.as_deref(), Some("openai"));
1235 assert_eq!(
1236 creds[0].credential_ref.as_deref(),
1237 Some("provider.openai-work.api_key")
1238 );
1239 }
1240
1241 #[test]
1242 fn hybrid_legacy_default_is_the_only_legacy_credential_exported() {
1243 let mut config = Config::default();
1244 config.providers_mut().anthropic = Some(AnthropicConfig {
1245 api_key: "sk-ant-default".to_string(),
1246 ..Default::default()
1247 });
1248 config.providers_mut().openai = Some(OpenAIConfig {
1249 api_key: "sk-oai-stale".to_string(),
1250 ..Default::default()
1251 });
1252 config
1253 .provider_instances
1254 .insert("work".to_string(), instance("openai", "sk-oai-work"));
1255 config.default_provider_instance = Some("anthropic".to_string());
1256
1257 let mut creds = extract_provider_credentials(&config);
1258 creds.sort_by(|a, b| a.provider.cmp(&b.provider));
1259
1260 assert_eq!(creds.len(), 2);
1261 assert_eq!(creds[0].provider, "anthropic");
1262 assert_eq!(creds[0].api_key, "sk-ant-default");
1263 assert_eq!(creds[1].provider, "work");
1264 assert_eq!(creds[1].api_key, "sk-oai-work");
1265 assert!(creds
1266 .iter()
1267 .all(|credential| credential.api_key != "sk-oai-stale"));
1268 }
1269
1270 #[test]
1271 fn hybrid_legacy_provider_fallback_without_explicit_default_remains_exportable() {
1272 let mut config = Config::default();
1273 config.provider = "anthropic".to_string();
1274 config.providers_mut().anthropic = Some(AnthropicConfig {
1275 api_key: "sk-ant-effective-default".to_string(),
1276 ..Default::default()
1277 });
1278 config
1279 .provider_instances
1280 .insert("work".to_string(), instance("openai", "sk-oai-work"));
1281
1282 let mut creds = extract_provider_credentials(&config);
1283 creds.sort_by(|a, b| a.provider.cmp(&b.provider));
1284
1285 assert_eq!(creds.len(), 2);
1286 assert_eq!(creds[0].provider, "anthropic");
1287 assert_eq!(creds[1].provider, "work");
1288 }
1289
1290 #[test]
1291 fn disabled_instance_credential_is_not_exported() {
1292 let mut config = Config::default();
1293 let mut disabled = instance("openai", "sk-disabled");
1294 disabled.enabled = false;
1295 config
1296 .provider_instances
1297 .insert("disabled".to_string(), disabled);
1298
1299 assert!(extract_provider_credentials(&config).is_empty());
1300 }
1301}
1302
1303#[cfg(test)]
1304mod placement_resolver_tests {
1305 use super::{node_display_name, resolve_remote_placements, resolve_schedulable_placements};
1306 use bamboo_config::cluster_fabric::{
1307 DeployProfile, Node, NodePlacement, SshAuth, SshTarget, TrustLevel,
1308 };
1309 use bamboo_config::{RemoteActorPlacement, SchedulablePlacement};
1310
1311 #[test]
1312 fn explicit_broker_route_retains_unavailable_selection_and_redacted_debug() {
1313 let route = bamboo_config::RemoteBrokerPeer {
1314 parent_mailbox: "host".into(),
1315 worker_mailbox: "worker".into(),
1316 parent_role: Some("host".into()),
1317 worker_role: Some("worker".into()),
1318 };
1319 assert!(route.valid());
1320 let mut invalid = route.clone();
1321 invalid.worker_mailbox = "Worker".into();
1322 assert!(!invalid.valid());
1323 invalid.worker_mailbox = "../worker".into();
1324 assert!(!invalid.valid());
1325 let placement = RemoteActorPlacement {
1326 role: "worker".into(),
1327 endpoint: "ws://private.invalid".into(),
1328 token_env: Some("BAMBOO_1431_MISSING_FIXTURE_TOKEN".into()),
1329 ca_cert_file: None,
1330 broker_peer: Some(route),
1331 placement_requirements: None,
1332 };
1333 let parsed: RemoteActorPlacement =
1334 serde_json::from_value(serde_json::to_value(&placement).unwrap()).unwrap();
1335 assert_eq!(parsed, placement);
1336 let resolved = resolve_remote_placements(&[placement], &[]);
1337 let selected = &resolved["worker"];
1338 assert!(matches!(selected.broker_peer, Some(Err(()))));
1339 assert!(selected.token.is_none());
1340 assert_eq!(selected.host_label.as_deref(), Some("remote"));
1341 assert!(!format!("{selected:?}").contains("private.invalid"));
1342 assert!(serde_json::from_value::<bamboo_config::RemoteBrokerPeer>(serde_json::json!({
1343 "parent_mailbox":"host", "worker_mailbox":"worker", "parent_role":null, "worker_role":null,"credential":"secret"
1344 })).is_err());
1345 }
1346
1347 fn ssh_node(id: &str, label: &str, host: &str, default_role: Option<&str>) -> Node {
1348 Node {
1349 id: id.into(),
1350 label: label.into(),
1351 placement: NodePlacement::Ssh(SshTarget {
1352 host: host.into(),
1353 port: 22,
1354 username: "u".into(),
1355 auth: SshAuth::SystemSshConfig,
1356 host_key_fingerprint: None,
1357 }),
1358 trust_level: TrustLevel::default(),
1359 deploy: DeployProfile {
1360 default_role: default_role.map(String::from),
1361 ..Default::default()
1362 },
1363 state: None,
1364 enabled: true,
1365 }
1366 }
1367
1368 #[test]
1369 fn node_display_name_prefers_label_then_ssh_host() {
1370 let n = ssh_node("n1", "mini", "mini.local", None);
1371 assert_eq!(node_display_name(&n), "mini");
1372 let mut unlabeled = n.clone();
1373 unlabeled.label = String::new();
1374 assert_eq!(node_display_name(&unlabeled), "mini.local");
1375 }
1376
1377 #[test]
1378 fn schedulable_placement_takes_host_label_from_node_by_default_role() {
1379 let nodes = vec![ssh_node(
1380 "n1",
1381 "mini",
1382 "mini.local",
1383 Some("mac-mini-monitor"),
1384 )];
1385 let placements = vec![SchedulablePlacement {
1386 role: "mac-mini-monitor".into(),
1387 pool: "mac-mini-monitor".into(),
1388 ..Default::default()
1389 }];
1390 let out = resolve_schedulable_placements(&placements, &nodes);
1391 let r = out.get("mac-mini-monitor").expect("role resolved");
1392 assert_eq!(r.pool, "mac-mini-monitor");
1393 assert_eq!(r.host_label.as_deref(), Some("mini"));
1394 }
1395
1396 #[test]
1397 fn remote_placement_takes_host_label_from_node_by_ssh_host() {
1398 let nodes = vec![ssh_node("n1", "mini", "mini.local", None)];
1399 let placements = vec![RemoteActorPlacement {
1400 role: "explorer".into(),
1401 endpoint: "ws://mini.local:8899".into(),
1402 ..Default::default()
1403 }];
1404 let out = resolve_remote_placements(&placements, &nodes);
1405 assert_eq!(
1406 out.get("explorer").unwrap().host_label.as_deref(),
1407 Some("mini")
1408 );
1409 assert!(matches!(out["explorer"].broker_peer, Some(Err(()))));
1410 assert!(out["explorer"].token.is_none());
1411 }
1412
1413 #[test]
1414 fn no_host_label_when_no_node_matches() {
1415 let nodes = vec![ssh_node("n1", "mini", "mini.local", Some("other-role"))];
1416 let sched = vec![SchedulablePlacement {
1417 role: "x".into(),
1418 pool: "unmatched".into(),
1419 ..Default::default()
1420 }];
1421 assert_eq!(
1422 resolve_schedulable_placements(&sched, &nodes)
1423 .get("x")
1424 .unwrap()
1425 .host_label,
1426 None
1427 );
1428 let remote = vec![RemoteActorPlacement {
1429 role: "y".into(),
1430 endpoint: "ws://other-host:9000".into(),
1431 ..Default::default()
1432 }];
1433 assert_eq!(
1434 resolve_remote_placements(&remote, &nodes)
1435 .get("y")
1436 .unwrap()
1437 .host_label,
1438 None
1439 );
1440 }
1441}