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
22/// Whether the default Child route explicitly requests the process runtime.
23/// Native Bamboo defaults, including tuning limits and a stale discovery path,
24/// do not select Worker IPC. An unknown explicit executor remains fail-closed.
25pub fn uses_legacy_default_child_runtime(config: &Config) -> bool {
26    let subagents = config.subagents();
27    subagents.worker_bin.is_some()
28        || subagents.worker_args.is_some()
29        || !matches!(subagents.executor.as_deref(), None | Some("bamboo_runtime"))
30}
31
32/// Whether existing explicit process/remote configuration needs legacy actor
33/// infrastructure. This is not a per-Child routing decision: a configured remote
34/// role or expert profile must not move unrelated native Children off Tokio.
35pub fn requires_legacy_child_runtime(config: &Config) -> bool {
36    let subagents = config.subagents();
37    uses_legacy_default_child_runtime(config)
38        || !subagents.remote_placements.is_empty()
39        || !subagents.schedulable_placements.is_empty()
40        || !config.cluster_fabric.nodes.is_empty()
41        || parse_external_agents(config)
42            .values()
43            .any(|profile| matches!(profile.protocol, ExternalAgentProtocol::Actor))
44}
45
46fn codex_auth_mode_name(mode: bamboo_config::CodexAuthMode) -> String {
47    match mode {
48        bamboo_config::CodexAuthMode::Inherit => "inherit",
49        bamboo_config::CodexAuthMode::ApiKey => "api_key",
50        bamboo_config::CodexAuthMode::Custom => "custom",
51        bamboo_config::CodexAuthMode::Bamboo => "bamboo",
52    }
53    .to_string()
54}
55
56fn codex_wire_api_name(wire_api: bamboo_config::CodexWireApi) -> String {
57    match wire_api {
58        bamboo_config::CodexWireApi::Responses => "responses",
59    }
60    .to_string()
61}
62
63fn codex_mode_name(mode: bamboo_config::CodexMode) -> String {
64    match mode {
65        bamboo_config::CodexMode::Exec => "exec",
66        bamboo_config::CodexMode::AppServer => "app_server",
67    }
68    .to_string()
69}
70
71fn codex_sandbox_name(sandbox: bamboo_config::CodexSandbox) -> String {
72    match sandbox {
73        bamboo_config::CodexSandbox::ReadOnly => "read-only",
74        bamboo_config::CodexSandbox::WorkspaceWrite => "workspace-write",
75        bamboo_config::CodexSandbox::DangerFullAccess => "danger-full-access",
76    }
77    .to_string()
78}
79
80fn codex_approval_policy_name(policy: bamboo_config::CodexApprovalPolicy) -> String {
81    match policy {
82        bamboo_config::CodexApprovalPolicy::Never => "never",
83        bamboo_config::CodexApprovalPolicy::OnFailure => "on-failure",
84        bamboo_config::CodexApprovalPolicy::OnRequest => "on-request",
85    }
86    .to_string()
87}
88
89fn codex_base_url(
90    config: &Config,
91    mode: bamboo_config::CodexAuthMode,
92    custom: Option<String>,
93) -> Option<String> {
94    match mode {
95        bamboo_config::CodexAuthMode::Custom => custom,
96        bamboo_config::CodexAuthMode::Bamboo => {
97            let scheme = if config.server.tls.is_some() {
98                "https"
99            } else {
100                "http"
101            };
102            Some(format!(
103                "{scheme}://127.0.0.1:{}/openai/v1",
104                config.server.port
105            ))
106        }
107        bamboo_config::CodexAuthMode::Inherit | bamboo_config::CodexAuthMode::ApiKey => None,
108    }
109}
110
111/// Composite router that delegates to the first matching external child runner.
112pub struct CompositeExternalChildRunner {
113    runners: Vec<Arc<dyn ExternalChildRunner>>,
114    store: std::sync::RwLock<Option<Arc<bamboo_storage::SessionStoreV2>>>,
115}
116
117impl CompositeExternalChildRunner {
118    pub fn new(runners: Vec<Arc<dyn ExternalChildRunner>>) -> Self {
119        Self {
120            runners,
121            store: Default::default(),
122        }
123    }
124
125    async fn check_observer_route(&self, session: &bamboo_domain::Session) -> Result<(), String> {
126        use bamboo_domain::Storage;
127        let store = self
128            .store
129            .read()
130            .map_err(|_| "Child store lock is poisoned")?
131            .clone();
132        if let Some(store) = store {
133            if store
134                .supervisor_observer_activation(session)
135                .await
136                .map_err(|error| error.to_string())?
137                && !self.supports_native_ticket_budget()
138            {
139                return Err(
140                    "registered Observers require the supported native-only backend".into(),
141                );
142            }
143        }
144        Ok(())
145    }
146}
147
148#[async_trait]
149impl ExternalChildRunner for CompositeExternalChildRunner {
150    fn native_task_start(
151        &self,
152        session_id: &str,
153        run_id: &str,
154        child_birth: chrono::DateTime<chrono::Utc>,
155    ) -> Option<crate::execution::spawn::NativeTaskStart> {
156        if !self.supports_native_ticket_budget() {
157            return None;
158        }
159        self.runners[0].native_task_start(session_id, run_id, child_birth)
160    }
161
162    fn native_task_liveness(
163        &self,
164        session_id: &str,
165        run_id: &str,
166        child_birth: chrono::DateTime<chrono::Utc>,
167    ) -> Option<crate::execution::spawn::NativeTaskLiveness> {
168        if !self.supports_native_ticket_budget() {
169            return None;
170        }
171        self.runners[0].native_task_liveness(session_id, run_id, child_birth)
172    }
173
174    fn supports_native_ticket_budget(&self) -> bool {
175        // Deliberately conservative for this native-only first slice. Mixed
176        // process/remote routers retain their existing explicit dispatch path.
177        self.runners.len() == 1 && self.runners[0].supports_native_ticket_budget()
178    }
179
180    fn set_local_child_skill_factory(
181        &self,
182        factory: Option<Arc<dyn super::local_task::LocalChildSkillFactory>>,
183    ) {
184        for runner in &self.runners {
185            runner.set_local_child_skill_factory(factory.clone());
186        }
187    }
188
189    fn set_local_task_runtime(&self, runtime: Option<super::local_task::LocalTaskRuntime>) {
190        for runner in &self.runners {
191            runner.set_local_task_runtime(runtime.clone());
192        }
193    }
194
195    async fn prepare_child_execution(
196        &self,
197        session: &bamboo_agent_core::Session,
198        run_id: &str,
199        cancel: CancellationToken,
200    ) -> Result<Option<Arc<dyn bamboo_domain::RuntimeSessionPersistence>>, String> {
201        self.check_observer_route(session).await?;
202        for runner in &self.runners {
203            if runner.should_handle(session).await {
204                return runner
205                    .prepare_child_execution(session, run_id, cancel)
206                    .await;
207            }
208        }
209        Err("No child execution backend matched the canonical Session".into())
210    }
211
212    async fn abandon_child_execution(
213        &self,
214        session: &bamboo_agent_core::Session,
215        run_id: &str,
216    ) -> Result<(), String> {
217        for runner in &self.runners {
218            if runner.should_handle(session).await {
219                return runner.abandon_child_execution(session, run_id).await;
220            }
221        }
222        Err("Child execution backend disappeared before rollback".into())
223    }
224
225    fn quarantine_child_execution(&self, session: &bamboo_agent_core::Session, run_id: &str) {
226        for runner in &self.runners {
227            runner.quarantine_child_execution(session, run_id);
228        }
229    }
230
231    fn track_child_task(
232        &self,
233        session_id: &str,
234        run_id: &str,
235        mut handle: tokio::task::JoinHandle<()>,
236    ) -> Option<tokio::task::JoinHandle<()>> {
237        for runner in &self.runners {
238            handle = runner.track_child_task(session_id, run_id, handle)?;
239        }
240        Some(handle)
241    }
242
243    async fn finish_child_execution(
244        &self,
245        session: &bamboo_agent_core::Session,
246        run_id: &str,
247        history_committed: bool,
248    ) -> Result<(), String> {
249        for runner in &self.runners {
250            if runner.should_handle(session).await {
251                return runner
252                    .finish_child_execution(session, run_id, history_committed)
253                    .await;
254            }
255        }
256        Err("Child execution backend disappeared before finalization".into())
257    }
258
259    async fn shutdown(&self) -> Result<(), String> {
260        let mut failures = Vec::new();
261        for runner in &self.runners {
262            if let Err(error) = runner.shutdown().await {
263                failures.push(error);
264            }
265        }
266        if failures.is_empty() {
267            Ok(())
268        } else {
269            Err(failures.join("; "))
270        }
271    }
272
273    fn set_ticket_service(&self, service: Option<Arc<bamboo_tickets::TicketService>>) {
274        for runner in &self.runners {
275            runner.set_ticket_service(service.clone());
276        }
277    }
278
279    async fn validate_required_child_context_route(
280        &self,
281        session: &bamboo_agent_core::Session,
282    ) -> Result<(), String> {
283        for runner in &self.runners {
284            if runner.should_handle(session).await {
285                return runner.validate_required_child_context_route(session).await;
286            }
287        }
288        Err("required_child_context_unsupported: no matching registered worker route".into())
289    }
290    async fn should_handle(&self, session: &bamboo_agent_core::Session) -> bool {
291        for runner in &self.runners {
292            if runner.should_handle(session).await {
293                return true;
294            }
295        }
296        false
297    }
298
299    async fn execute_external_child(
300        &self,
301        session: &mut bamboo_agent_core::Session,
302        job: &SpawnJob,
303        event_tx: mpsc::Sender<AgentEvent>,
304        cancel_token: CancellationToken,
305    ) -> crate::runtime::runner::Result<()> {
306        self.check_observer_route(session)
307            .await
308            .map_err(AgentError::LLM)?;
309        for runner in &self.runners {
310            if runner.should_handle(session).await {
311                return runner
312                    .execute_external_child(session, job, event_tx, cancel_token)
313                    .await;
314            }
315        }
316        Err(AgentError::LLM(
317            "No matching external child runner found for session metadata".to_string(),
318        ))
319    }
320
321    async fn commit_durable_child_delivery(
322        &self,
323        session: &bamboo_agent_core::Session,
324        activation_run_id: &str,
325    ) -> Result<(), String> {
326        for runner in &self.runners {
327            runner
328                .commit_durable_child_delivery(session, activation_run_id)
329                .await?;
330        }
331        Ok(())
332    }
333
334    async fn confirm_durable_child_delivery(
335        &self,
336        session: &bamboo_agent_core::Session,
337        activation_run_id: &str,
338        history_committed: bool,
339    ) -> Result<(), String> {
340        for runner in &self.runners {
341            runner
342                .confirm_durable_child_delivery(session, activation_run_id, history_committed)
343                .await?;
344        }
345        Ok(())
346    }
347
348    async fn prepare_durable_child_delivery(
349        &self,
350        session: &bamboo_agent_core::Session,
351        activation_run_id: &str,
352    ) -> Result<bool, String> {
353        let mut prepared = false;
354        for runner in &self.runners {
355            prepared |= runner
356                .prepare_durable_child_delivery(session, activation_run_id)
357                .await?;
358        }
359        Ok(prepared)
360    }
361
362    /// #68: fan the per-run escalation bridge out to every inner runner. The
363    /// composite is what `build_external_child_runner` returns and what the
364    /// worker retains, so without this forward the bind would hit the trait's
365    /// no-op default and the wrapped `ActorChildRunner`s would never see it.
366    fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
367        for runner in &self.runners {
368            runner.set_escalation_bridge(bridge.clone());
369        }
370    }
371
372    fn set_session_inbox_runtime(&self, binding: Option<SessionInboxRuntimeBinding>) {
373        for runner in &self.runners {
374            runner.set_session_inbox_runtime(binding.clone());
375        }
376    }
377
378    fn set_actor_directory_store(&self, store: Option<Arc<bamboo_storage::SessionStoreV2>>) {
379        if let Ok(mut current) = self.store.write() {
380            *current = store.clone();
381        }
382        for runner in &self.runners {
383            runner.set_actor_directory_store(store.clone());
384        }
385    }
386
387    fn set_canonical_subagent_tool(&self, tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>) {
388        for runner in &self.runners {
389            runner.set_canonical_subagent_tool(tool.clone());
390        }
391    }
392
393    fn set_actor_event_observer(
394        &self,
395        observer: Option<Arc<dyn super::actor_event_stream::ActorEventObserver>>,
396    ) {
397        for runner in &self.runners {
398            runner.set_actor_event_observer(observer.clone());
399        }
400    }
401}
402
403/// Build the child runner from the application config.
404///
405/// Sub-agents always run as actors (the in-process runtime was removed), so the
406/// built-in **local actor** worker is always part of the composite — its worker
407/// binary, arguments, and discovery dir are all derived; no expert tables
408/// needed. Expert `externalAgents` profiles add extra routers so
409/// `external.agent_id` metadata can pin specific roles to other agents. Returns
410/// a composite router that delegates to the first matching runner.
411pub fn build_external_child_runner(config: &Config) -> Arc<dyn ExternalChildRunner> {
412    build_external_child_runner_with_registry(config, None)
413}
414
415/// Build the child runner with an AppState-scoped durable approval registry.
416pub fn build_external_child_runner_with_registry(
417    config: &Config,
418    approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
419) -> Arc<dyn ExternalChildRunner> {
420    build_external_child_runner_with_registry_and_reviewer(config, approval_registry, None, None)
421}
422
423/// Build the child runner with durable approval state and an optional
424/// parent-agent model reviewer for forced-ask requests.
425pub fn build_external_child_runner_with_registry_and_reviewer(
426    config: &Config,
427    approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
428    approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
429    permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
430) -> Arc<dyn ExternalChildRunner> {
431    build_external_child_runner_with_codex_tokens(
432        config,
433        approval_registry,
434        approval_reviewer,
435        permission_config,
436        None,
437    )
438}
439
440/// Preserve the already-selected legacy Worker entrypoint's nested process
441/// backend. Its isolated store is not a Host canonical store for Tokio Children.
442/// This explicit compatibility assembly is never a native-runtime fallback.
443pub fn build_legacy_worker_child_runner(
444    config: &Config,
445    approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
446    approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
447    permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
448) -> Arc<dyn ExternalChildRunner> {
449    let mut config = config.clone();
450    let subagents = config.subagents_mut();
451    if subagents.worker_bin.is_none() && subagents.worker_args.is_none() {
452        // The old process backend already derived these exact arguments.
453        subagents.worker_args = Some(vec!["subagent-worker".into()]);
454    }
455    build_external_child_runner_with_registry_and_reviewer(
456        &config,
457        approval_registry,
458        approval_reviewer,
459        permission_config,
460    )
461}
462
463/// Full server wiring, including the process-ephemeral Codex per-run token
464/// authority. Non-server callers keep using the compatibility wrapper above.
465pub fn build_external_child_runner_with_codex_tokens(
466    config: &Config,
467    approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
468    approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
469    permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
470    codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
471) -> Arc<dyn ExternalChildRunner> {
472    build_external_child_runner_internal(
473        config,
474        None,
475        approval_registry,
476        approval_reviewer,
477        permission_config,
478        codex_run_tokens,
479        None,
480    )
481}
482
483/// Full server wiring with an AppState-owned live config source. Provider
484/// credentials and the default provider are resolved for each new child
485/// activation, so provider reloads do not require rebuilding the actor runner.
486pub fn build_external_child_runner_with_live_config_and_codex_tokens(
487    config: &Config,
488    live_provider_config: Arc<tokio::sync::RwLock<Config>>,
489    approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
490    approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
491    permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
492    codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
493) -> Arc<dyn ExternalChildRunner> {
494    build_external_child_runner_internal(
495        config,
496        Some(live_provider_config),
497        approval_registry,
498        approval_reviewer,
499        permission_config,
500        codex_run_tokens,
501        None,
502    )
503}
504
505/// The server binds this to the same Builtin Arc and complete base routing
506/// chain assembled for this AppState, before applying the Root role fence.
507#[allow(clippy::too_many_arguments)]
508pub fn build_external_child_runner_with_native_tool_ceiling(
509    config: &Config,
510    live_provider_config: Arc<tokio::sync::RwLock<Config>>,
511    approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
512    approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
513    permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
514    codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
515    native_tool_ceiling: Arc<dyn NativeToolCeilingSource>,
516) -> Arc<dyn ExternalChildRunner> {
517    build_external_child_runner_internal(
518        config,
519        Some(live_provider_config),
520        approval_registry,
521        approval_reviewer,
522        permission_config,
523        codex_run_tokens,
524        Some(native_tool_ceiling),
525    )
526}
527
528fn build_external_child_runner_internal(
529    config: &Config,
530    live_provider_config: Option<Arc<tokio::sync::RwLock<Config>>>,
531    approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
532    approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
533    permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
534    codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
535    native_tool_ceiling: Option<Arc<dyn NativeToolCeilingSource>>,
536) -> Arc<dyn ExternalChildRunner> {
537    let agents = parse_external_agents(config);
538
539    let mut runners: Vec<Arc<dyn ExternalChildRunner>> = Vec::new();
540
541    // Ordinary native Child executions stay in this Host process. The old
542    // Worker/broker implementation remains available only for explicit legacy
543    // configuration; a local task failure never selects it as a fallback.
544    if uses_legacy_default_child_runtime(config) {
545        match build_local_actor_runner(
546            config,
547            approval_registry.clone(),
548            approval_reviewer.clone(),
549            permission_config.clone(),
550            codex_run_tokens.clone(),
551            live_provider_config.clone(),
552            native_tool_ceiling.clone(),
553        ) {
554            Ok(runner) => runners.push(runner),
555            Err(error) => tracing::error!("explicit legacy child runner unavailable: {error}"),
556        }
557    } else {
558        runners.push(Arc::new(
559            super::local_task::LocalTaskChildRunner::new(
560                config,
561                native_tool_ceiling.clone(),
562                permission_config.clone(),
563            )
564            .with_approval(approval_registry.clone(), approval_reviewer.clone()),
565        ));
566        if !config.subagents().remote_placements.is_empty()
567            || !config.subagents().schedulable_placements.is_empty()
568        {
569            match build_local_actor_runner(
570                config,
571                approval_registry.clone(),
572                approval_reviewer.clone(),
573                permission_config.clone(),
574                codex_run_tokens.clone(),
575                live_provider_config.clone(),
576                native_tool_ceiling.clone(),
577            ) {
578                Ok(runner) => runners.push(runner),
579                Err(error) => tracing::error!("explicit remote child route unavailable: {error}"),
580            }
581        }
582    }
583
584    for (_agent_id, profile) in agents {
585        // Actor protocol: spawn a local worker binary over the bamboo-subagent WS protocol.
586        if matches!(profile.protocol, ExternalAgentProtocol::Actor) {
587            let Some(worker_bin) = profile.worker_bin.as_ref() else {
588                tracing::error!(
589                    "Actor agent profile {} has no worker_bin; skipping",
590                    profile.agent_id
591                );
592                continue;
593            };
594            // #217: default under the persistent data-dir subagents home
595            // instead of `env::temp_dir()`, so fabric discovery state
596            // survives reboots and stays inside the tenant's data dir.
597            let fabric_dir = profile
598                .fabric_dir
599                .clone()
600                .map(std::path::PathBuf::from)
601                .unwrap_or_else(bamboo_config::paths::subagents_dir);
602            let executor = match profile.executor.as_deref() {
603                Some("echo") => bamboo_subagent::provision::ExecutorSpec::Echo,
604                Some("bamboo_runtime") | None => {
605                    bamboo_subagent::provision::ExecutorSpec::BambooRuntime
606                }
607                // #443: binary/model/permission_mode/isolation/env-forward
608                // are plumbed from the profile's `claude_code_*` fields.
609                Some("claude_code") => bamboo_subagent::provision::ExecutorSpec::ClaudeCode {
610                    binary: profile.claude_code_binary.clone(),
611                    model: profile.claude_code_model.clone(),
612                    permission_mode: profile.claude_code_permission_mode.clone(),
613                    inherit_user_config: profile.claude_code_inherit_user_config,
614                    forward_env: profile.claude_code_forward_env.clone(),
615                },
616                Some("codex") => bamboo_subagent::provision::ExecutorSpec::Codex {
617                    binary: profile.codex_binary.clone(),
618                    model: profile.codex_model.clone(),
619                    mode: profile.codex_mode.map(codex_mode_name),
620                    sandbox: profile.codex_sandbox.map(codex_sandbox_name),
621                    inherit_user_config: None,
622                    auth_mode: Some(codex_auth_mode_name(
623                        profile.codex_auth_mode.unwrap_or_default(),
624                    )),
625                    base_url: codex_base_url(
626                        config,
627                        profile.codex_auth_mode.unwrap_or_default(),
628                        profile.codex_base_url.clone(),
629                    ),
630                    wire_api: profile.codex_wire_api.map(codex_wire_api_name),
631                    provider_key_ref: profile
632                        .codex_provider_key_ref
633                        .as_ref()
634                        .map(|reference| reference.as_str().to_string()),
635                    forward_env: profile.codex_forward_env.clone(),
636                    approval_policy: profile
637                        .codex_approval_policy
638                        .map(codex_approval_policy_name),
639                    network_access: profile.codex_network_access,
640                    allow_danger_bypass: profile.codex_allow_danger_bypass,
641                    permission_profile: Some(profile.permission_profile.clone()),
642                    workspace_owned: None,
643                },
644                Some(other) => {
645                    tracing::error!(
646                        "Actor agent profile {} has unknown executor '{}'; skipping",
647                        profile.agent_id,
648                        other
649                    );
650                    continue;
651                }
652            };
653            let mut runner = ActorChildRunner::new(
654                profile.agent_id.clone(),
655                std::path::PathBuf::from(worker_bin),
656                profile.worker_args.clone(),
657                fabric_dir,
658                executor,
659                extract_provider_credentials(config),
660                config.effective_default_provider().to_string(),
661                config
662                    .subagents()
663                    .max_concurrent
664                    .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS),
665            )
666            .with_max_spawn_depth(config.subagents().effective_max_spawn_depth());
667            if let Some(registry) = approval_registry.clone() {
668                runner = runner.with_approval_registry(registry);
669            }
670            if let Some(reviewer) = approval_reviewer.clone() {
671                runner = runner.with_approval_reviewer(reviewer);
672            }
673            if let Some(config) = permission_config.clone() {
674                runner = runner.with_permission_config(config);
675            }
676            if let Some(config) = live_provider_config.clone() {
677                runner = runner.with_live_provider_config(config);
678            }
679            runner = runner.with_codex_run_tokens(codex_run_tokens.clone());
680            runners.push(Arc::new(runner));
681            continue;
682        }
683
684        if !matches!(profile.protocol, ExternalAgentProtocol::A2aJsonRpc) {
685            tracing::warn!(
686                "External agent profile {} uses unsupported protocol {:?}",
687                profile.agent_id,
688                profile.protocol
689            );
690            continue;
691        }
692
693        let auth_token = match profile.auth_ref.as_ref() {
694            Some(ref_name) => match std::env::var(ref_name) {
695                Ok(token) => Some(token),
696                Err(_) => {
697                    tracing::error!(
698                        "External agent profile {} auth_ref env var {} is not set",
699                        profile.agent_id,
700                        ref_name
701                    );
702                    continue;
703                }
704            },
705            None => None,
706        };
707
708        let client_config = match A2AExternalChildRunner::build_client_config(&profile, auth_token)
709        {
710            Ok(cfg) => cfg,
711            Err(e) => {
712                tracing::error!(
713                    "Failed to build A2A client config for profile {}: {}",
714                    profile.agent_id,
715                    e
716                );
717                continue;
718            }
719        };
720
721        let client = match A2AJsonRpcClient::new(client_config) {
722            Ok(c) => c,
723            Err(e) => {
724                tracing::error!(
725                    "Failed to create A2A JSON-RPC client for profile {}: {}",
726                    profile.agent_id,
727                    e
728                );
729                continue;
730            }
731        };
732
733        runners.push(Arc::new(A2AExternalChildRunner::new(client, profile)));
734    }
735
736    Arc::new(CompositeExternalChildRunner::new(runners))
737}
738
739/// Classify the resolved local launch without granting execution authority.
740fn builtin_local_worker_launch(worker_bin: &std::path::Path, worker_args: &[String]) -> bool {
741    if worker_args != ["subagent-worker".to_string()] {
742        return false;
743    }
744    // Explicit selection of the retained local process backend is still the
745    // built-in route only for this Host executable and its exact Worker argv.
746    // Failed path observations never qualify. Actor admission rechecks this
747    // identity and the actual Worker capabilities before any Child is created.
748    let Ok(current) = std::env::current_exe().and_then(|path| path.canonicalize()) else {
749        return false;
750    };
751    worker_bin
752        .canonicalize()
753        .is_ok_and(|worker| worker == current)
754}
755
756/// Build the explicitly selected local process runner from typed configuration.
757fn build_local_actor_runner(
758    config: &Config,
759    approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
760    approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
761    permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
762    codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
763    live_provider_config: Option<Arc<tokio::sync::RwLock<Config>>>,
764    native_tool_ceiling: Option<Arc<dyn NativeToolCeilingSource>>,
765) -> Result<Arc<dyn ExternalChildRunner>, String> {
766    let sub = config.subagents();
767
768    let (worker_bin, worker_args) = match &sub.worker_bin {
769        Some(custom) => (
770            std::path::PathBuf::from(custom),
771            sub.worker_args.clone().unwrap_or_default(),
772        ),
773        None => (
774            std::env::current_exe().map_err(|e| format!("cannot locate own executable: {e}"))?,
775            sub.worker_args
776                .clone()
777                .unwrap_or_else(|| vec!["subagent-worker".to_string()]),
778        ),
779    };
780
781    // #217: default under the persistent data-dir subagents home instead of
782    // `env::temp_dir()` (mirrors the `build_external_child_runner` arm above).
783    let fabric_dir = sub
784        .fabric_dir
785        .clone()
786        .map(std::path::PathBuf::from)
787        .unwrap_or_else(bamboo_config::paths::subagents_dir);
788
789    let executor = subagent_executor_spec(config)?;
790    let builtin_required_context_route = builtin_local_worker_launch(&worker_bin, &worker_args);
791
792    let mut runner = ActorChildRunner::new(
793        super::config::LOCAL_ACTOR_AGENT_ID.to_string(),
794        worker_bin,
795        worker_args,
796        fabric_dir,
797        executor,
798        extract_provider_credentials(config),
799        config.effective_default_provider().to_string(),
800        sub.max_concurrent
801            .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS),
802    )
803    .with_max_spawn_depth(sub.effective_max_spawn_depth())
804    .with_builtin_required_context_route(builtin_required_context_route)
805    .with_remote_placements(resolve_remote_placements(
806        &sub.remote_placements,
807        &config.cluster_fabric.nodes,
808    ))
809    .with_schedulable_placements(resolve_schedulable_placements(
810        &sub.schedulable_placements,
811        &config.cluster_fabric.nodes,
812    ))
813    .with_bus(sub.broker.as_ref().map(|b| bamboo_subagent::BusEndpoint {
814        endpoint: b.endpoint.clone(),
815        token: b.token.clone(),
816    }))
817    .with_codex_run_tokens(codex_run_tokens)
818    .with_native_tool_ceiling_source(native_tool_ceiling);
819    if let Some(registry) = approval_registry {
820        runner = runner.with_approval_registry(registry);
821    }
822    if let Some(reviewer) = approval_reviewer {
823        runner = runner.with_approval_reviewer(reviewer);
824    }
825    if let Some(config) = permission_config {
826        runner = runner.with_permission_config(config);
827    }
828    if let Some(config) = live_provider_config {
829        runner = runner.with_live_provider_config(config);
830    }
831    Ok(Arc::new(runner))
832}
833
834/// Convert the durable typed `subagents` section into the exact worker
835/// provisioning executor. This is deliberately independent of actor launch so
836/// the settings-to-spawn contract can be tested directly.
837fn subagent_executor_spec(
838    config: &Config,
839) -> Result<bamboo_subagent::provision::ExecutorSpec, String> {
840    let sub = config.subagents();
841    Ok(match sub.executor.as_deref() {
842        Some("echo") => bamboo_subagent::provision::ExecutorSpec::Echo,
843        Some("bamboo_runtime") | None => bamboo_subagent::provision::ExecutorSpec::BambooRuntime,
844        Some("claude_code") => bamboo_subagent::provision::ExecutorSpec::ClaudeCode {
845            binary: sub.claude_code_binary.clone(),
846            model: sub.claude_code_model.clone(),
847            permission_mode: sub.claude_code_permission_mode.clone(),
848            inherit_user_config: sub.claude_code_inherit_user_config,
849            forward_env: sub.claude_code_forward_env.clone(),
850        },
851        Some("codex") => bamboo_subagent::provision::ExecutorSpec::Codex {
852            binary: sub.codex_binary.clone(),
853            model: sub.codex_model.clone(),
854            mode: sub.codex_mode.map(codex_mode_name),
855            sandbox: sub.codex_sandbox.map(codex_sandbox_name),
856            inherit_user_config: None,
857            auth_mode: Some(codex_auth_mode_name(
858                sub.codex_auth_mode.unwrap_or_default(),
859            )),
860            base_url: codex_base_url(
861                config,
862                sub.codex_auth_mode.unwrap_or_default(),
863                sub.codex_base_url.clone(),
864            ),
865            wire_api: sub.codex_wire_api.map(codex_wire_api_name),
866            provider_key_ref: sub
867                .codex_provider_key_ref
868                .as_ref()
869                .map(|reference| reference.as_str().to_string()),
870            forward_env: sub.codex_forward_env.clone(),
871            approval_policy: sub.codex_approval_policy.map(codex_approval_policy_name),
872            network_access: sub.codex_network_access,
873            allow_danger_bypass: sub.codex_allow_danger_bypass,
874            permission_profile: None,
875            workspace_owned: None,
876        },
877        Some(other) => return Err(format!("unknown subagents.executor '{other}'")),
878    })
879}
880
881fn valid_placement_identifier(value: &str) -> bool {
882    !value.is_empty()
883        && value.len() <= 128
884        && value
885            .bytes()
886            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
887}
888
889fn valid_placement_requirements(value: &bamboo_config::OperatorPlacementRequirements) -> bool {
890    valid_placement_identifier(&value.trust_zone)
891        && value
892            .workspace_label
893            .as_deref()
894            .is_none_or(valid_placement_identifier)
895        && value
896            .network_zone
897            .as_deref()
898            .is_none_or(valid_placement_identifier)
899        && value.required_tools.len() <= 256
900        && value
901            .required_tools
902            .iter()
903            .all(|tool| valid_placement_identifier(tool))
904}
905
906fn valid_scoped_broker_endpoint(value: &str) -> bool {
907    value.len() <= 2048
908        && url::Url::parse(value).is_ok_and(|url| {
909            url.scheme() == "wss"
910                && url.host_str().is_some()
911                && url.username().is_empty()
912                && url.password().is_none()
913                && url.query().is_none()
914                && url.fragment().is_none()
915        })
916}
917
918fn resolved_broker_token(name: Option<&str>) -> Option<String> {
919    name.filter(|name| !name.is_empty() && name.len() <= 256)
920        .and_then(|name| std::env::var(name).ok())
921        .filter(|token| {
922            (32..=256).contains(&token.len()) && token.bytes().all(|byte| byte.is_ascii_graphic())
923        })
924}
925
926/// Resolve every configured role, including invalid legacy entries. An explicit
927/// scheduled role must never silently fall through to Local.
928fn resolve_schedulable_placements(
929    placements: &[bamboo_config::SchedulablePlacement],
930    nodes: &[bamboo_config::cluster_fabric::Node],
931) -> std::collections::HashMap<String, super::actor_adapter::ResolvedSchedulablePlacement> {
932    let mut out = std::collections::HashMap::new();
933    for p in placements {
934        let token = resolved_broker_token(p.token_env.as_deref());
935        let valid = valid_placement_identifier(&p.pool)
936            && valid_scoped_broker_endpoint(&p.registry_url)
937            && p.token_env.is_some()
938            && token.is_some()
939            && p.ca_cert_file.as_ref().is_some_and(|path| {
940                bamboo_broker::client_config_trusting_cert(std::path::Path::new(path)).is_ok()
941            })
942            && p.broker_parent.as_ref().is_some_and(|parent| {
943                valid_placement_identifier(&parent.parent_mailbox)
944                    && valid_placement_identifier(&parent.parent_role)
945            })
946            && p.placement_requirements
947                .as_ref()
948                .is_some_and(valid_placement_requirements);
949        let resolved = super::actor_adapter::ResolvedSchedulablePlacement {
950            pool: p.pool.clone(),
951            host_label: node_label_for_role(nodes, &p.pool),
952            endpoint: p.registry_url.clone(),
953            token: if valid { token } else { None },
954            ca_cert_file: p.ca_cert_file.as_ref().map(std::path::PathBuf::from),
955            broker_parent: if valid { p.broker_parent.clone() } else { None },
956            requirements: if valid {
957                p.placement_requirements.clone()
958            } else {
959                None
960            },
961        };
962        if out.insert(p.role.clone(), resolved).is_some() {
963            // Ambiguous authority is unavailable, regardless of entry order.
964            if let Some(route) = out.get_mut(&p.role) {
965                route.broker_parent = None;
966            }
967        }
968    }
969    out
970}
971
972/// Friendly display name for a cluster node whose worker serves `role`
973/// (`deploy.default_role`) — the operator `label`, else its ssh host. Used to
974/// stamp the UI placement badge from the node's own metadata.
975fn node_label_for_role(
976    nodes: &[bamboo_config::cluster_fabric::Node],
977    role: &str,
978) -> Option<String> {
979    nodes
980        .iter()
981        .find(|n| n.deploy.default_role.as_deref() == Some(role))
982        .map(node_display_name)
983}
984
985/// Friendly display name for a cluster node whose ssh host matches `endpoint`'s
986/// host — so a `remote_placements` endpoint pointing at a known node shows the
987/// node's label rather than a bare IP.
988fn node_label_for_endpoint(
989    nodes: &[bamboo_config::cluster_fabric::Node],
990    endpoint: &str,
991) -> Option<String> {
992    let host = endpoint
993        .trim()
994        .trim_start_matches("wss://")
995        .trim_start_matches("ws://")
996        .split(['/', ':'])
997        .next()
998        .unwrap_or("");
999    if host.is_empty() {
1000        return None;
1001    }
1002    nodes
1003        .iter()
1004        .find(|n| match &n.placement {
1005            bamboo_config::cluster_fabric::NodePlacement::Ssh(t) => t.host == host,
1006            bamboo_config::cluster_fabric::NodePlacement::Local => false,
1007        })
1008        .map(node_display_name)
1009}
1010
1011fn node_display_name(n: &bamboo_config::cluster_fabric::Node) -> String {
1012    if !n.label.trim().is_empty() {
1013        return n.label.clone();
1014    }
1015    match &n.placement {
1016        bamboo_config::cluster_fabric::NodePlacement::Ssh(t) => t.host.clone(),
1017        bamboo_config::cluster_fabric::NodePlacement::Local => "local".to_string(),
1018    }
1019}
1020
1021/// Resolve pinned remote placements. Broker peer selection is required to run;
1022/// old direct-worker configurations remain visible but unavailable, without
1023/// exposing their credentials or silently rerouting to Local.
1024fn resolve_remote_placements(
1025    placements: &[bamboo_config::RemoteActorPlacement],
1026    nodes: &[bamboo_config::cluster_fabric::Node],
1027) -> std::collections::HashMap<String, super::actor_adapter::ResolvedRemotePlacement> {
1028    let mut out = std::collections::HashMap::new();
1029    for p in placements {
1030        let duplicate_strict = placements
1031            .iter()
1032            .any(|other| other.role == p.role && other.broker_peer.is_some())
1033            && placements
1034                .iter()
1035                .filter(|other| other.role == p.role)
1036                .count()
1037                != 1;
1038        if duplicate_strict {
1039            out.insert(
1040                p.role.clone(),
1041                super::actor_adapter::ResolvedRemotePlacement {
1042                    endpoint: String::new(),
1043                    token: None,
1044                    ca_cert_file: None,
1045                    host_label: Some("remote".into()),
1046                    broker_peer: Some(Err(())),
1047                    requirements: None,
1048                },
1049            );
1050            continue;
1051        }
1052        if let Some(peer) = &p.broker_peer {
1053            let token = resolved_broker_token(p.token_env.as_deref());
1054            let valid = peer.valid()
1055                && valid_scoped_broker_endpoint(&p.endpoint)
1056                && p.token_env.is_some()
1057                && token.is_some()
1058                && p.ca_cert_file.as_ref().is_some_and(|path| {
1059                    bamboo_broker::client_config_trusting_cert(std::path::Path::new(path)).is_ok()
1060                })
1061                && p.placement_requirements
1062                    .as_ref()
1063                    .is_some_and(valid_placement_requirements);
1064            out.insert(
1065                p.role.clone(),
1066                super::actor_adapter::ResolvedRemotePlacement {
1067                    endpoint: p.endpoint.clone(),
1068                    token: if valid { token } else { None },
1069                    ca_cert_file: p.ca_cert_file.as_ref().map(std::path::PathBuf::from),
1070                    host_label: Some("remote".into()),
1071                    broker_peer: Some(if valid { Ok(peer.clone()) } else { Err(()) }),
1072                    requirements: if valid {
1073                        p.placement_requirements.clone()
1074                    } else {
1075                        None
1076                    },
1077                },
1078            );
1079            continue; // Invalid explicit routes remain selected, never Local fallback.
1080        }
1081        out.insert(
1082            p.role.clone(),
1083            super::actor_adapter::ResolvedRemotePlacement {
1084                endpoint: p.endpoint.clone(),
1085                token: None,
1086                ca_cert_file: p.ca_cert_file.as_ref().map(std::path::PathBuf::from),
1087                // Badge from the node's own metadata when the endpoint points at
1088                // a known cluster node; else the endpoint host is used downstream.
1089                host_label: node_label_for_endpoint(nodes, &p.endpoint),
1090                broker_peer: Some(Err(())),
1091                requirements: None,
1092            },
1093        );
1094    }
1095    out
1096}
1097
1098/// Snapshot per-provider credentials from the parent config for actor
1099/// provisioning. `api_key` (plaintext, in-memory only) is `#[serde(skip_serializing)]`
1100/// on every legacy single-instance provider struct — it's hydrated from
1101/// `api_key_encrypted` at load time but deliberately never round-tripped
1102/// through serde, so a `serde_json::to_value` projection of `config.providers`
1103/// sees none of it (#495). Read each typed struct's `api_key` field directly
1104/// instead, mirroring how `provider_instances` below already has to.
1105pub fn extract_provider_credentials(
1106    config: &Config,
1107) -> Vec<bamboo_subagent::provision::ScopedCredential> {
1108    let mut out = Vec::new();
1109
1110    fn push_instance(
1111        out: &mut Vec<bamboo_subagent::provision::ScopedCredential>,
1112        id: &str,
1113        instance: &bamboo_config::ProviderInstanceConfig,
1114    ) {
1115        if !instance.enabled {
1116            return;
1117        }
1118        let api_key = instance.api_key.trim().to_string();
1119        if api_key.is_empty() {
1120            return;
1121        }
1122        out.push(bamboo_subagent::provision::ScopedCredential {
1123            provider: id.to_string(),
1124            api_key,
1125            base_url: instance.base_url.clone(),
1126            provider_type: Some(instance.provider_type.clone()),
1127            credential_ref: instance
1128                .credential_ref
1129                .as_ref()
1130                .map(|reference| reference.as_str().to_string()),
1131        });
1132    }
1133
1134    if !config.provider_instances.is_empty() {
1135        // Native instances are authoritative. Export enabled explicit
1136        // instances only; stale legacy slots must not leak into child workers.
1137        for (id, instance) in &config.provider_instances {
1138            push_instance(&mut out, id, instance);
1139        }
1140
1141        // Narrow #780 compatibility seam: a hybrid default may still name a
1142        // real legacy alias not yet materialized. Mirror the registry's exact
1143        // rule and add only that default, never every legacy credential.
1144        let default_id = config.effective_default_provider();
1145        if !config.provider_instances.contains_key(default_id) {
1146            if let Some((_, instance)) = bamboo_config::synthesize_legacy_instances(config)
1147                .into_iter()
1148                .find(|(id, _)| id == default_id)
1149            {
1150                push_instance(&mut out, default_id, &instance);
1151            }
1152        }
1153        return out;
1154    }
1155
1156    // Legacy single-instance slots: providers.anthropic / openai / gemini /
1157    // bodhi. `copilot` is intentionally omitted — it authenticates via device
1158    // flow and has no `api_key` field to extract.
1159    let mut push_legacy =
1160        |name: &str, api_key: &str, base_url: Option<String>, credential_ref: Option<String>| {
1161            let api_key = api_key.trim().to_string();
1162            if api_key.is_empty() {
1163                return;
1164            }
1165            out.push(bamboo_subagent::provision::ScopedCredential {
1166                provider: name.to_string(),
1167                api_key,
1168                base_url,
1169                provider_type: Some(name.to_string()),
1170                credential_ref,
1171            });
1172        };
1173    if let Some(c) = &config.providers().openai {
1174        push_legacy(
1175            "openai",
1176            &c.api_key,
1177            c.base_url.clone(),
1178            c.credential_ref
1179                .as_ref()
1180                .map(|reference| reference.as_str().to_string()),
1181        );
1182    }
1183    if let Some(c) = &config.providers().anthropic {
1184        push_legacy(
1185            "anthropic",
1186            &c.api_key,
1187            c.base_url.clone(),
1188            c.credential_ref
1189                .as_ref()
1190                .map(|reference| reference.as_str().to_string()),
1191        );
1192    }
1193    if let Some(c) = &config.providers().gemini {
1194        push_legacy(
1195            "gemini",
1196            &c.api_key,
1197            c.base_url.clone(),
1198            c.credential_ref
1199                .as_ref()
1200                .map(|reference| reference.as_str().to_string()),
1201        );
1202    }
1203    if let Some(c) = &config.providers().bodhi {
1204        push_legacy(
1205            "bodhi",
1206            &c.api_key,
1207            c.base_url.clone(),
1208            c.credential_ref
1209                .as_ref()
1210                .map(|reference| reference.as_str().to_string()),
1211        );
1212    }
1213
1214    out
1215}
1216
1217#[cfg(test)]
1218mod codex_runtime_config_tests {
1219    #[test]
1220    fn builtin_local_worker_launch_requires_current_executable_and_exact_arguments() {
1221        let current = std::env::current_exe().unwrap();
1222        let exact = vec!["subagent-worker".to_string()];
1223        assert!(super::builtin_local_worker_launch(&current, &exact));
1224        assert!(super::builtin_local_worker_launch(
1225            &current.canonicalize().unwrap(),
1226            &exact
1227        ));
1228        for args in [
1229            vec![],
1230            vec!["serve".into()],
1231            vec!["subagent-worker".into(), "--extra".into()],
1232        ] {
1233            assert!(!super::builtin_local_worker_launch(&current, &args));
1234        }
1235        let temp = tempfile::tempdir().unwrap();
1236        assert!(!super::builtin_local_worker_launch(
1237            &temp.path().join("missing"),
1238            &exact
1239        ));
1240        let foreign = temp.path().join("foreign-worker");
1241        std::fs::write(
1242            &foreign,
1243            "claimed worker capabilities do not establish executable identity",
1244        )
1245        .unwrap();
1246        assert!(!super::builtin_local_worker_launch(&foreign, &exact));
1247    }
1248
1249    #[tokio::test]
1250    async fn required_context_uses_registered_launch_snapshot_after_live_config_flip() {
1251        let mut candidate = bamboo_agent_core::Session::new("preflight", "model");
1252        candidate.metadata = super::super::config::resolve_runtime_metadata(
1253            &bamboo_llm::Config::default(),
1254            "worker",
1255        );
1256        candidate
1257            .metadata
1258            .insert("subagent_type".into(), "worker".into());
1259        for kind in ["custom", "args", "codex", "remote"] {
1260            let mut config = bamboo_llm::Config::default();
1261            let sub = config.subagents_mut();
1262            sub.broker = Some(bamboo_config::BrokerClientConfig {
1263                endpoint: "ws://127.0.0.1:9998".into(),
1264                token: "fixture".into(),
1265                ..Default::default()
1266            });
1267            match kind {
1268                "custom" => sub.worker_bin = Some("/bin/false".into()),
1269                "args" => sub.worker_args = Some(vec!["subagent-worker".into()]),
1270                "codex" => sub.executor = Some("codex".into()),
1271                _ => sub
1272                    .remote_placements
1273                    .push(bamboo_config::RemoteActorPlacement {
1274                        role: "worker".into(),
1275                        endpoint: "ws://127.0.0.1:9999".into(),
1276                        ..Default::default()
1277                    }),
1278            }
1279            let live = std::sync::Arc::new(tokio::sync::RwLock::new(config.clone()));
1280            let runner = super::build_external_child_runner_with_live_config_and_codex_tokens(
1281                &config,
1282                live.clone(),
1283                None,
1284                None,
1285                None,
1286                None,
1287            );
1288            *live.write().await = bamboo_llm::Config::default();
1289            let error = runner
1290                .validate_required_child_context_route(&candidate)
1291                .await
1292                .unwrap_err();
1293            assert!(
1294                error.contains("required_child_context_unsupported"),
1295                "{kind}"
1296            );
1297        }
1298    }
1299
1300    use super::{
1301        codex_approval_policy_name, codex_auth_mode_name, codex_base_url, codex_mode_name,
1302        codex_sandbox_name, codex_wire_api_name, subagent_executor_spec,
1303    };
1304    use bamboo_config::{
1305        CodexApprovalPolicy, CodexAuthMode, CodexMode, CodexSandbox, CodexWireApi, CredentialRef,
1306    };
1307    use bamboo_llm::Config;
1308    use bamboo_subagent::provision::ExecutorSpec;
1309
1310    #[test]
1311    fn codex_runtime_mapping_keeps_parent_loopback_and_custom_url_unambiguous() {
1312        let mut config = Config::default();
1313        config.server.port = 5700;
1314
1315        assert_eq!(codex_auth_mode_name(CodexAuthMode::Bamboo), "bamboo");
1316        assert_eq!(codex_mode_name(CodexMode::AppServer), "app_server");
1317        assert_eq!(codex_wire_api_name(CodexWireApi::Responses), "responses");
1318        assert_eq!(codex_sandbox_name(CodexSandbox::ReadOnly), "read-only");
1319        assert_eq!(
1320            codex_sandbox_name(CodexSandbox::WorkspaceWrite),
1321            "workspace-write"
1322        );
1323        assert_eq!(
1324            codex_approval_policy_name(CodexApprovalPolicy::OnFailure),
1325            "on-failure"
1326        );
1327        assert_eq!(
1328            codex_base_url(&config, CodexAuthMode::Bamboo, None).as_deref(),
1329            Some("http://127.0.0.1:5700/openai/v1")
1330        );
1331        assert_eq!(
1332            codex_base_url(
1333                &config,
1334                CodexAuthMode::Custom,
1335                Some("https://provider.example/v1".to_string()),
1336            )
1337            .as_deref(),
1338            Some("https://provider.example/v1")
1339        );
1340        assert_eq!(codex_base_url(&config, CodexAuthMode::Inherit, None), None);
1341        assert_eq!(codex_base_url(&config, CodexAuthMode::ApiKey, None), None);
1342    }
1343
1344    #[test]
1345    fn durable_codex_fields_map_without_loss_to_worker_spawn_spec() {
1346        let mut config = Config::default();
1347        let subagents = config.subagents_mut();
1348        subagents.executor = Some("codex".to_string());
1349        subagents.codex_binary = Some("/opt/codex/bin/codex".to_string());
1350        subagents.codex_model = Some("gpt-5.4".to_string());
1351        subagents.codex_mode = Some(CodexMode::AppServer);
1352        subagents.codex_auth_mode = Some(CodexAuthMode::Custom);
1353        subagents.codex_base_url = Some("https://provider.example/v1".to_string());
1354        subagents.codex_wire_api = Some(CodexWireApi::Responses);
1355        subagents.codex_provider_key_ref = Some(
1356            CredentialRef::parse("provider.codex-work.api_key").expect("valid credential ref"),
1357        );
1358        subagents.codex_forward_env = Some(vec!["HTTPS_PROXY".to_string()]);
1359        subagents.codex_sandbox = Some(CodexSandbox::WorkspaceWrite);
1360        subagents.codex_approval_policy = Some(CodexApprovalPolicy::OnRequest);
1361        subagents.codex_network_access = Some(true);
1362        subagents.codex_allow_danger_bypass = Some(false);
1363
1364        let spec = subagent_executor_spec(&config).expect("Codex config maps to executor spec");
1365        let ExecutorSpec::Codex {
1366            binary,
1367            model,
1368            mode,
1369            sandbox,
1370            auth_mode,
1371            base_url,
1372            wire_api,
1373            provider_key_ref,
1374            forward_env,
1375            approval_policy,
1376            network_access,
1377            allow_danger_bypass,
1378            ..
1379        } = spec
1380        else {
1381            panic!("expected Codex executor spec");
1382        };
1383        assert_eq!(binary.as_deref(), Some("/opt/codex/bin/codex"));
1384        assert_eq!(model.as_deref(), Some("gpt-5.4"));
1385        assert_eq!(mode.as_deref(), Some("app_server"));
1386        assert_eq!(sandbox.as_deref(), Some("workspace-write"));
1387        assert_eq!(auth_mode.as_deref(), Some("custom"));
1388        assert_eq!(base_url.as_deref(), Some("https://provider.example/v1"));
1389        assert_eq!(wire_api.as_deref(), Some("responses"));
1390        assert_eq!(
1391            provider_key_ref.as_deref(),
1392            Some("provider.codex-work.api_key")
1393        );
1394        assert_eq!(forward_env, Some(vec!["HTTPS_PROXY".to_string()]));
1395        assert_eq!(approval_policy.as_deref(), Some("on-request"));
1396        assert_eq!(network_access, Some(true));
1397        assert_eq!(allow_danger_bypass, Some(false));
1398    }
1399}
1400
1401#[cfg(test)]
1402mod extract_provider_credentials_tests {
1403    use super::extract_provider_credentials;
1404    use bamboo_config::{
1405        AnthropicConfig, BodhiConfig, Config, OpenAIConfig, ProviderInstanceConfig,
1406    };
1407
1408    fn instance(provider_type: &str, api_key: &str) -> ProviderInstanceConfig {
1409        ProviderInstanceConfig {
1410            provider_type: provider_type.to_string(),
1411            label: None,
1412            api_key: api_key.to_string(),
1413            api_key_encrypted: None,
1414            credential_ref: None,
1415            base_url: None,
1416            model: None,
1417            fast_model: None,
1418            vision_model: None,
1419            reasoning_effort: None,
1420            responses_only_models: Vec::new(),
1421            request_overrides: None,
1422            enabled: true,
1423            extra: Default::default(),
1424        }
1425    }
1426
1427    #[test]
1428    fn no_config_yields_no_credentials() {
1429        let config = Config::default();
1430        assert!(extract_provider_credentials(&config).is_empty());
1431    }
1432
1433    /// #495 — a legacy single-instance provider (`config.providers.anthropic`
1434    /// etc.) must yield its `api_key` even though the field is
1435    /// `#[serde(skip_serializing)]`, because the extraction now reads the
1436    /// typed struct instead of projecting through `serde_json::to_value`.
1437    #[test]
1438    fn legacy_only_config_yields_credential() {
1439        let mut config = Config::default();
1440        config.providers_mut().anthropic = Some(AnthropicConfig {
1441            api_key: "sk-ant-legacy".to_string(),
1442            base_url: Some("https://api.anthropic.com".to_string()),
1443            ..Default::default()
1444        });
1445
1446        let creds = extract_provider_credentials(&config);
1447        assert_eq!(creds.len(), 1);
1448        let c = &creds[0];
1449        assert_eq!(c.provider, "anthropic");
1450        assert_eq!(c.api_key, "sk-ant-legacy");
1451        assert_eq!(c.base_url.as_deref(), Some("https://api.anthropic.com"));
1452        assert_eq!(c.provider_type.as_deref(), Some("anthropic"));
1453    }
1454
1455    /// `bodhi` doesn't derive `Default`, so it's exercised separately —
1456    /// covers the last of the four legacy structs the fix touches
1457    /// (openai/anthropic/gemini already share the `Default`-derive path).
1458    #[test]
1459    fn legacy_bodhi_config_yields_credential() {
1460        let mut config = Config::default();
1461        config.providers_mut().bodhi = Some(BodhiConfig {
1462            api_key: "bhi_sk_legacy".to_string(),
1463            api_key_encrypted: None,
1464            credential_ref: None,
1465            base_url: None,
1466            target_provider: None,
1467            reasoning_effort: None,
1468            extra: Default::default(),
1469        });
1470
1471        let creds = extract_provider_credentials(&config);
1472        assert_eq!(creds.len(), 1);
1473        assert_eq!(creds[0].provider, "bodhi");
1474        assert_eq!(creds[0].api_key, "bhi_sk_legacy");
1475    }
1476
1477    /// A legacy slot with an empty `api_key` (struct present but never
1478    /// configured) must not produce a bogus empty credential.
1479    #[test]
1480    fn legacy_config_with_empty_api_key_is_skipped() {
1481        let mut config = Config::default();
1482        config.providers_mut().openai = Some(OpenAIConfig::default());
1483        assert!(extract_provider_credentials(&config).is_empty());
1484    }
1485
1486    /// Explicit instances are authoritative: unrelated stale legacy slots do
1487    /// not cross the actor provisioning boundary.
1488    #[test]
1489    fn instance_mode_omits_unrelated_legacy_credentials() {
1490        let mut config = Config::default();
1491        config.providers_mut().anthropic = Some(AnthropicConfig {
1492            api_key: "sk-ant-legacy".to_string(),
1493            ..Default::default()
1494        });
1495        let mut openai_work = instance("openai", "sk-oai-work");
1496        openai_work.credential_ref = Some(
1497            bamboo_config::CredentialRef::parse("provider.openai-work.api_key")
1498                .expect("valid provider credential reference"),
1499        );
1500        config
1501            .provider_instances
1502            .insert("openai-work".to_string(), openai_work);
1503        config.default_provider_instance = Some("openai-work".to_string());
1504
1505        let creds = extract_provider_credentials(&config);
1506
1507        assert_eq!(creds.len(), 1);
1508        assert_eq!(creds[0].provider, "openai-work");
1509        assert_eq!(creds[0].api_key, "sk-oai-work");
1510        assert_eq!(creds[0].provider_type.as_deref(), Some("openai"));
1511        assert_eq!(
1512            creds[0].credential_ref.as_deref(),
1513            Some("provider.openai-work.api_key")
1514        );
1515    }
1516
1517    #[test]
1518    fn hybrid_legacy_default_is_the_only_legacy_credential_exported() {
1519        let mut config = Config::default();
1520        config.providers_mut().anthropic = Some(AnthropicConfig {
1521            api_key: "sk-ant-default".to_string(),
1522            ..Default::default()
1523        });
1524        config.providers_mut().openai = Some(OpenAIConfig {
1525            api_key: "sk-oai-stale".to_string(),
1526            ..Default::default()
1527        });
1528        config
1529            .provider_instances
1530            .insert("work".to_string(), instance("openai", "sk-oai-work"));
1531        config.default_provider_instance = Some("anthropic".to_string());
1532
1533        let mut creds = extract_provider_credentials(&config);
1534        creds.sort_by(|a, b| a.provider.cmp(&b.provider));
1535
1536        assert_eq!(creds.len(), 2);
1537        assert_eq!(creds[0].provider, "anthropic");
1538        assert_eq!(creds[0].api_key, "sk-ant-default");
1539        assert_eq!(creds[1].provider, "work");
1540        assert_eq!(creds[1].api_key, "sk-oai-work");
1541        assert!(creds
1542            .iter()
1543            .all(|credential| credential.api_key != "sk-oai-stale"));
1544    }
1545
1546    #[test]
1547    fn hybrid_legacy_provider_fallback_without_explicit_default_remains_exportable() {
1548        let mut config = Config::default();
1549        config.provider = "anthropic".to_string();
1550        config.providers_mut().anthropic = Some(AnthropicConfig {
1551            api_key: "sk-ant-effective-default".to_string(),
1552            ..Default::default()
1553        });
1554        config
1555            .provider_instances
1556            .insert("work".to_string(), instance("openai", "sk-oai-work"));
1557
1558        let mut creds = extract_provider_credentials(&config);
1559        creds.sort_by(|a, b| a.provider.cmp(&b.provider));
1560
1561        assert_eq!(creds.len(), 2);
1562        assert_eq!(creds[0].provider, "anthropic");
1563        assert_eq!(creds[1].provider, "work");
1564    }
1565
1566    #[test]
1567    fn disabled_instance_credential_is_not_exported() {
1568        let mut config = Config::default();
1569        let mut disabled = instance("openai", "sk-disabled");
1570        disabled.enabled = false;
1571        config
1572            .provider_instances
1573            .insert("disabled".to_string(), disabled);
1574
1575        assert!(extract_provider_credentials(&config).is_empty());
1576    }
1577}
1578
1579#[cfg(test)]
1580mod placement_resolver_tests {
1581    use super::{node_display_name, resolve_remote_placements, resolve_schedulable_placements};
1582    use bamboo_config::cluster_fabric::{
1583        DeployProfile, Node, NodePlacement, SshAuth, SshTarget, TrustLevel,
1584    };
1585    use bamboo_config::{RemoteActorPlacement, SchedulablePlacement};
1586
1587    #[test]
1588    fn explicit_broker_route_retains_unavailable_selection_and_redacted_debug() {
1589        let route = bamboo_config::RemoteBrokerPeer {
1590            parent_mailbox: "host".into(),
1591            worker_mailbox: "worker".into(),
1592            parent_role: Some("host".into()),
1593            worker_role: Some("worker".into()),
1594        };
1595        assert!(route.valid());
1596        let mut invalid = route.clone();
1597        invalid.worker_mailbox = "Worker".into();
1598        assert!(!invalid.valid());
1599        invalid.worker_mailbox = "../worker".into();
1600        assert!(!invalid.valid());
1601        let placement = RemoteActorPlacement {
1602            role: "worker".into(),
1603            endpoint: "ws://private.invalid".into(),
1604            token_env: Some("BAMBOO_1431_MISSING_FIXTURE_TOKEN".into()),
1605            ca_cert_file: None,
1606            broker_peer: Some(route),
1607            placement_requirements: None,
1608        };
1609        let parsed: RemoteActorPlacement =
1610            serde_json::from_value(serde_json::to_value(&placement).unwrap()).unwrap();
1611        assert_eq!(parsed, placement);
1612        let resolved = resolve_remote_placements(&[placement], &[]);
1613        let selected = &resolved["worker"];
1614        assert!(matches!(selected.broker_peer, Some(Err(()))));
1615        assert!(selected.token.is_none());
1616        assert_eq!(selected.host_label.as_deref(), Some("remote"));
1617        assert!(!format!("{selected:?}").contains("private.invalid"));
1618        assert!(serde_json::from_value::<bamboo_config::RemoteBrokerPeer>(serde_json::json!({
1619            "parent_mailbox":"host", "worker_mailbox":"worker", "parent_role":null, "worker_role":null,"credential":"secret"
1620        })).is_err());
1621    }
1622
1623    fn ssh_node(id: &str, label: &str, host: &str, default_role: Option<&str>) -> Node {
1624        Node {
1625            id: id.into(),
1626            label: label.into(),
1627            placement: NodePlacement::Ssh(SshTarget {
1628                host: host.into(),
1629                port: 22,
1630                username: "u".into(),
1631                auth: SshAuth::SystemSshConfig,
1632                host_key_fingerprint: None,
1633            }),
1634            trust_level: TrustLevel::default(),
1635            deploy: DeployProfile {
1636                default_role: default_role.map(String::from),
1637                ..Default::default()
1638            },
1639            state: None,
1640            enabled: true,
1641        }
1642    }
1643
1644    #[test]
1645    fn node_display_name_prefers_label_then_ssh_host() {
1646        let n = ssh_node("n1", "mini", "mini.local", None);
1647        assert_eq!(node_display_name(&n), "mini");
1648        let mut unlabeled = n.clone();
1649        unlabeled.label = String::new();
1650        assert_eq!(node_display_name(&unlabeled), "mini.local");
1651    }
1652
1653    #[test]
1654    fn schedulable_placement_takes_host_label_from_node_by_default_role() {
1655        let nodes = vec![ssh_node(
1656            "n1",
1657            "mini",
1658            "mini.local",
1659            Some("mac-mini-monitor"),
1660        )];
1661        let placements = vec![SchedulablePlacement {
1662            role: "mac-mini-monitor".into(),
1663            pool: "mac-mini-monitor".into(),
1664            ..Default::default()
1665        }];
1666        let out = resolve_schedulable_placements(&placements, &nodes);
1667        let r = out.get("mac-mini-monitor").expect("role resolved");
1668        assert_eq!(r.pool, "mac-mini-monitor");
1669        assert_eq!(r.host_label.as_deref(), Some("mini"));
1670    }
1671
1672    #[test]
1673    fn remote_placement_takes_host_label_from_node_by_ssh_host() {
1674        let nodes = vec![ssh_node("n1", "mini", "mini.local", None)];
1675        let placements = vec![RemoteActorPlacement {
1676            role: "explorer".into(),
1677            endpoint: "ws://mini.local:8899".into(),
1678            ..Default::default()
1679        }];
1680        let out = resolve_remote_placements(&placements, &nodes);
1681        assert_eq!(
1682            out.get("explorer").unwrap().host_label.as_deref(),
1683            Some("mini")
1684        );
1685        assert!(matches!(out["explorer"].broker_peer, Some(Err(()))));
1686        assert!(out["explorer"].token.is_none());
1687    }
1688
1689    #[test]
1690    fn no_host_label_when_no_node_matches() {
1691        let nodes = vec![ssh_node("n1", "mini", "mini.local", Some("other-role"))];
1692        let sched = vec![SchedulablePlacement {
1693            role: "x".into(),
1694            pool: "unmatched".into(),
1695            ..Default::default()
1696        }];
1697        assert_eq!(
1698            resolve_schedulable_placements(&sched, &nodes)
1699                .get("x")
1700                .unwrap()
1701                .host_label,
1702            None
1703        );
1704        let remote = vec![RemoteActorPlacement {
1705            role: "y".into(),
1706            endpoint: "ws://other-host:9000".into(),
1707            ..Default::default()
1708        }];
1709        assert_eq!(
1710            resolve_remote_placements(&remote, &nodes)
1711                .get("y")
1712                .unwrap()
1713                .host_label,
1714            None
1715        );
1716    }
1717}