Skip to main content

bamboo_engine/external_agents/
runtime.rs

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/// Host-owned observation of its actual concrete tool surface at activation.
16/// The caller cannot submit a resolved ceiling through the public tool schema.
17#[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
87/// Composite router that delegates to the first matching external child runner.
88pub 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    /// #68: fan the per-run escalation bridge out to every inner runner. The
187    /// composite is what `build_external_child_runner` returns and what the
188    /// worker retains, so without this forward the bind would hit the trait's
189    /// no-op default and the wrapped `ActorChildRunner`s would never see it.
190    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
224/// Build the child runner from the application config.
225///
226/// Sub-agents always run as actors (the in-process runtime was removed), so the
227/// built-in **local actor** worker is always part of the composite — its worker
228/// binary, arguments, and discovery dir are all derived; no expert tables
229/// needed. Expert `externalAgents` profiles add extra routers so
230/// `external.agent_id` metadata can pin specific roles to other agents. Returns
231/// a composite router that delegates to the first matching runner.
232pub fn build_external_child_runner(config: &Config) -> Arc<dyn ExternalChildRunner> {
233    build_external_child_runner_with_registry(config, None)
234}
235
236/// Build the child runner with an AppState-scoped durable approval registry.
237pub 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
244/// Build the child runner with durable approval state and an optional
245/// parent-agent model reviewer for forced-ask requests.
246pub 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
261/// Full server wiring, including the process-ephemeral Codex per-run token
262/// authority. Non-server callers keep using the compatibility wrapper above.
263pub 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
281/// Full server wiring with an AppState-owned live config source. Provider
282/// credentials and the default provider are resolved for each new child
283/// activation, so provider reloads do not require rebuilding the actor runner.
284pub 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/// The server binds this to the same Builtin Arc and complete base routing
304/// chain assembled for this AppState, before applying the Root role fence.
305#[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    // The built-in local actor worker is the default runtime for every
340    // sub-agent. Always build it; a build failure here is logged and leaves the
341    // composite without a default handler (dispatch then errors clearly).
342    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        // Actor protocol: spawn a local worker binary over the bamboo-subagent WS protocol.
357        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            // #217: default under the persistent data-dir subagents home
366            // instead of `env::temp_dir()`, so fabric discovery state
367            // survives reboots and stays inside the tenant's data dir.
368            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                // #443: binary/model/permission_mode/isolation/env-forward
379                // are plumbed from the profile's `claude_code_*` fields.
380                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
509/// Build the built-in local actor runner from the typed `subagents`
510/// config. Everything is derived: worker = the current bamboo executable +
511/// `subagent-worker`, fabric = per-user temp dir — unless expert fields
512/// override them.
513fn 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    // #217: default under the persistent data-dir subagents home instead of
538    // `env::temp_dir()` (mirrors the `build_external_child_runner` arm above).
539    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
588/// Convert the durable typed `subagents` section into the exact worker
589/// provisioning executor. This is deliberately independent of actor launch so
590/// the settings-to-spawn contract can be tested directly.
591fn 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
680/// Resolve every configured role, including invalid legacy entries. An explicit
681/// scheduled role must never silently fall through to Local.
682fn 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            // Ambiguous authority is unavailable, regardless of entry order.
718            if let Some(route) = out.get_mut(&p.role) {
719                route.broker_parent = None;
720            }
721        }
722    }
723    out
724}
725
726/// Friendly display name for a cluster node whose worker serves `role`
727/// (`deploy.default_role`) — the operator `label`, else its ssh host. Used to
728/// stamp the UI placement badge from the node's own metadata.
729fn 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
739/// Friendly display name for a cluster node whose ssh host matches `endpoint`'s
740/// host — so a `remote_placements` endpoint pointing at a known node shows the
741/// node's label rather than a bare IP.
742fn 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
775/// Resolve pinned remote placements. Broker peer selection is required to run;
776/// old direct-worker configurations remain visible but unavailable, without
777/// exposing their credentials or silently rerouting to Local.
778fn 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; // Invalid explicit routes remain selected, never Local fallback.
834        }
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                // Badge from the node's own metadata when the endpoint points at
842                // a known cluster node; else the endpoint host is used downstream.
843                host_label: node_label_for_endpoint(nodes, &p.endpoint),
844                broker_peer: Some(Err(())),
845                requirements: None,
846            },
847        );
848    }
849    out
850}
851
852/// Snapshot per-provider credentials from the parent config for actor
853/// provisioning. `api_key` (plaintext, in-memory only) is `#[serde(skip_serializing)]`
854/// on every legacy single-instance provider struct — it's hydrated from
855/// `api_key_encrypted` at load time but deliberately never round-tripped
856/// through serde, so a `serde_json::to_value` projection of `config.providers`
857/// sees none of it (#495). Read each typed struct's `api_key` field directly
858/// instead, mirroring how `provider_instances` below already has to.
859pub 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        // Native instances are authoritative. Export enabled explicit
890        // instances only; stale legacy slots must not leak into child workers.
891        for (id, instance) in &config.provider_instances {
892            push_instance(&mut out, id, instance);
893        }
894
895        // Narrow #780 compatibility seam: a hybrid default may still name a
896        // real legacy alias not yet materialized. Mirror the registry's exact
897        // rule and add only that default, never every legacy credential.
898        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    // Legacy single-instance slots: providers.anthropic / openai / gemini /
911    // bodhi. `copilot` is intentionally omitted — it authenticates via device
912    // flow and has no `api_key` field to extract.
913    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    /// #495 — a legacy single-instance provider (`config.providers.anthropic`
1158    /// etc.) must yield its `api_key` even though the field is
1159    /// `#[serde(skip_serializing)]`, because the extraction now reads the
1160    /// typed struct instead of projecting through `serde_json::to_value`.
1161    #[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    /// `bodhi` doesn't derive `Default`, so it's exercised separately —
1180    /// covers the last of the four legacy structs the fix touches
1181    /// (openai/anthropic/gemini already share the `Default`-derive path).
1182    #[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    /// A legacy slot with an empty `api_key` (struct present but never
1202    /// configured) must not produce a bogus empty credential.
1203    #[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    /// Explicit instances are authoritative: unrelated stale legacy slots do
1211    /// not cross the actor provisioning boundary.
1212    #[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}