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