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