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
15fn codex_auth_mode_name(mode: bamboo_config::CodexAuthMode) -> String {
16    match mode {
17        bamboo_config::CodexAuthMode::Inherit => "inherit",
18        bamboo_config::CodexAuthMode::ApiKey => "api_key",
19        bamboo_config::CodexAuthMode::Custom => "custom",
20        bamboo_config::CodexAuthMode::Bamboo => "bamboo",
21    }
22    .to_string()
23}
24
25fn codex_wire_api_name(wire_api: bamboo_config::CodexWireApi) -> String {
26    match wire_api {
27        bamboo_config::CodexWireApi::Responses => "responses",
28    }
29    .to_string()
30}
31
32fn codex_mode_name(mode: bamboo_config::CodexMode) -> String {
33    match mode {
34        bamboo_config::CodexMode::Exec => "exec",
35        bamboo_config::CodexMode::AppServer => "app_server",
36    }
37    .to_string()
38}
39
40fn codex_sandbox_name(sandbox: bamboo_config::CodexSandbox) -> String {
41    match sandbox {
42        bamboo_config::CodexSandbox::ReadOnly => "read-only",
43        bamboo_config::CodexSandbox::WorkspaceWrite => "workspace-write",
44        bamboo_config::CodexSandbox::DangerFullAccess => "danger-full-access",
45    }
46    .to_string()
47}
48
49fn codex_approval_policy_name(policy: bamboo_config::CodexApprovalPolicy) -> String {
50    match policy {
51        bamboo_config::CodexApprovalPolicy::Never => "never",
52        bamboo_config::CodexApprovalPolicy::OnFailure => "on-failure",
53        bamboo_config::CodexApprovalPolicy::OnRequest => "on-request",
54    }
55    .to_string()
56}
57
58fn codex_base_url(
59    config: &Config,
60    mode: bamboo_config::CodexAuthMode,
61    custom: Option<String>,
62) -> Option<String> {
63    match mode {
64        bamboo_config::CodexAuthMode::Custom => custom,
65        bamboo_config::CodexAuthMode::Bamboo => {
66            let scheme = if config.server.tls.is_some() {
67                "https"
68            } else {
69                "http"
70            };
71            Some(format!(
72                "{scheme}://127.0.0.1:{}/openai/v1",
73                config.server.port
74            ))
75        }
76        bamboo_config::CodexAuthMode::Inherit | bamboo_config::CodexAuthMode::ApiKey => None,
77    }
78}
79
80/// Composite router that delegates to the first matching external child runner.
81pub struct CompositeExternalChildRunner {
82    runners: Vec<Arc<dyn ExternalChildRunner>>,
83}
84
85impl CompositeExternalChildRunner {
86    pub fn new(runners: Vec<Arc<dyn ExternalChildRunner>>) -> Self {
87        Self { runners }
88    }
89}
90
91#[async_trait]
92impl ExternalChildRunner for CompositeExternalChildRunner {
93    async fn should_handle(&self, session: &bamboo_agent_core::Session) -> bool {
94        for runner in &self.runners {
95            if runner.should_handle(session).await {
96                return true;
97            }
98        }
99        false
100    }
101
102    async fn execute_external_child(
103        &self,
104        session: &mut bamboo_agent_core::Session,
105        job: &SpawnJob,
106        event_tx: mpsc::Sender<AgentEvent>,
107        cancel_token: CancellationToken,
108    ) -> crate::runtime::runner::Result<()> {
109        for runner in &self.runners {
110            if runner.should_handle(session).await {
111                return runner
112                    .execute_external_child(session, job, event_tx, cancel_token)
113                    .await;
114            }
115        }
116        Err(AgentError::LLM(
117            "No matching external child runner found for session metadata".to_string(),
118        ))
119    }
120
121    /// #68: fan the per-run escalation bridge out to every inner runner. The
122    /// composite is what `build_external_child_runner` returns and what the
123    /// worker retains, so without this forward the bind would hit the trait's
124    /// no-op default and the wrapped `ActorChildRunner`s would never see it.
125    fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
126        for runner in &self.runners {
127            runner.set_escalation_bridge(bridge.clone());
128        }
129    }
130
131    fn set_session_inbox_runtime(&self, binding: Option<SessionInboxRuntimeBinding>) {
132        for runner in &self.runners {
133            runner.set_session_inbox_runtime(binding.clone());
134        }
135    }
136}
137
138/// Build the child runner from the application config.
139///
140/// Sub-agents always run as actors (the in-process runtime was removed), so the
141/// built-in **local actor** worker is always part of the composite — its worker
142/// binary, arguments, and discovery dir are all derived; no expert tables
143/// needed. Expert `externalAgents` profiles add extra routers so
144/// `external.agent_id` metadata can pin specific roles to other agents. Returns
145/// a composite router that delegates to the first matching runner.
146pub fn build_external_child_runner(config: &Config) -> Arc<dyn ExternalChildRunner> {
147    build_external_child_runner_with_registry(config, None)
148}
149
150/// Build the child runner with an AppState-scoped durable approval registry.
151pub fn build_external_child_runner_with_registry(
152    config: &Config,
153    approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
154) -> Arc<dyn ExternalChildRunner> {
155    build_external_child_runner_with_registry_and_reviewer(config, approval_registry, None, None)
156}
157
158/// Build the child runner with durable approval state and an optional
159/// parent-agent model reviewer for forced-ask requests.
160pub fn build_external_child_runner_with_registry_and_reviewer(
161    config: &Config,
162    approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
163    approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
164    permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
165) -> Arc<dyn ExternalChildRunner> {
166    build_external_child_runner_with_codex_tokens(
167        config,
168        approval_registry,
169        approval_reviewer,
170        permission_config,
171        None,
172    )
173}
174
175/// Full server wiring, including the process-ephemeral Codex per-run token
176/// authority. Non-server callers keep using the compatibility wrapper above.
177pub fn build_external_child_runner_with_codex_tokens(
178    config: &Config,
179    approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
180    approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
181    permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
182    codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
183) -> Arc<dyn ExternalChildRunner> {
184    let agents = parse_external_agents(config);
185
186    let mut runners: Vec<Arc<dyn ExternalChildRunner>> = Vec::new();
187
188    // The built-in local actor worker is the default runtime for every
189    // sub-agent. Always build it; a build failure here is logged and leaves the
190    // composite without a default handler (dispatch then errors clearly).
191    match build_local_actor_runner(
192        config,
193        approval_registry.clone(),
194        approval_reviewer.clone(),
195        permission_config.clone(),
196        codex_run_tokens.clone(),
197    ) {
198        Ok(runner) => runners.push(runner),
199        Err(e) => tracing::error!("local actor sub-agent runner unavailable: {e}"),
200    }
201
202    for (_agent_id, profile) in agents {
203        // Actor protocol: spawn a local worker binary over the bamboo-subagent WS protocol.
204        if matches!(profile.protocol, ExternalAgentProtocol::Actor) {
205            let Some(worker_bin) = profile.worker_bin.as_ref() else {
206                tracing::error!(
207                    "Actor agent profile {} has no worker_bin; skipping",
208                    profile.agent_id
209                );
210                continue;
211            };
212            // #217: default under the persistent data-dir subagents home
213            // instead of `env::temp_dir()`, so fabric discovery state
214            // survives reboots and stays inside the tenant's data dir.
215            let fabric_dir = profile
216                .fabric_dir
217                .clone()
218                .map(std::path::PathBuf::from)
219                .unwrap_or_else(bamboo_config::paths::subagents_dir);
220            let executor = match profile.executor.as_deref() {
221                Some("echo") => bamboo_subagent::provision::ExecutorSpec::Echo,
222                Some("bamboo_runtime") | None => {
223                    bamboo_subagent::provision::ExecutorSpec::BambooRuntime
224                }
225                // #443: binary/model/permission_mode/isolation/env-forward
226                // are plumbed from the profile's `claude_code_*` fields.
227                Some("claude_code") => bamboo_subagent::provision::ExecutorSpec::ClaudeCode {
228                    binary: profile.claude_code_binary.clone(),
229                    model: profile.claude_code_model.clone(),
230                    permission_mode: profile.claude_code_permission_mode.clone(),
231                    inherit_user_config: profile.claude_code_inherit_user_config,
232                    forward_env: profile.claude_code_forward_env.clone(),
233                },
234                Some("codex") => bamboo_subagent::provision::ExecutorSpec::Codex {
235                    binary: profile.codex_binary.clone(),
236                    model: profile.codex_model.clone(),
237                    mode: profile.codex_mode.map(codex_mode_name),
238                    sandbox: profile.codex_sandbox.map(codex_sandbox_name),
239                    inherit_user_config: None,
240                    auth_mode: Some(codex_auth_mode_name(
241                        profile.codex_auth_mode.unwrap_or_default(),
242                    )),
243                    base_url: codex_base_url(
244                        config,
245                        profile.codex_auth_mode.unwrap_or_default(),
246                        profile.codex_base_url.clone(),
247                    ),
248                    wire_api: profile.codex_wire_api.map(codex_wire_api_name),
249                    provider_key_ref: profile
250                        .codex_provider_key_ref
251                        .as_ref()
252                        .map(|reference| reference.as_str().to_string()),
253                    forward_env: profile.codex_forward_env.clone(),
254                    approval_policy: profile
255                        .codex_approval_policy
256                        .map(codex_approval_policy_name),
257                    network_access: profile.codex_network_access,
258                    allow_danger_bypass: profile.codex_allow_danger_bypass,
259                    permission_profile: Some(profile.permission_profile.clone()),
260                    workspace_owned: None,
261                },
262                Some(other) => {
263                    tracing::error!(
264                        "Actor agent profile {} has unknown executor '{}'; skipping",
265                        profile.agent_id,
266                        other
267                    );
268                    continue;
269                }
270            };
271            let mut runner = ActorChildRunner::new(
272                profile.agent_id.clone(),
273                std::path::PathBuf::from(worker_bin),
274                profile.worker_args.clone(),
275                fabric_dir,
276                executor,
277                extract_provider_credentials(config),
278                config.effective_default_provider().to_string(),
279                config
280                    .subagents()
281                    .max_concurrent
282                    .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS),
283            );
284            if let Some(registry) = approval_registry.clone() {
285                runner = runner.with_approval_registry(registry);
286            }
287            if let Some(reviewer) = approval_reviewer.clone() {
288                runner = runner.with_approval_reviewer(reviewer);
289            }
290            if let Some(config) = permission_config.clone() {
291                runner = runner.with_permission_config(config);
292            }
293            runner = runner.with_codex_run_tokens(codex_run_tokens.clone());
294            runners.push(Arc::new(runner));
295            continue;
296        }
297
298        if !matches!(profile.protocol, ExternalAgentProtocol::A2aJsonRpc) {
299            tracing::warn!(
300                "External agent profile {} uses unsupported protocol {:?}",
301                profile.agent_id,
302                profile.protocol
303            );
304            continue;
305        }
306
307        let auth_token = match profile.auth_ref.as_ref() {
308            Some(ref_name) => match std::env::var(ref_name) {
309                Ok(token) => Some(token),
310                Err(_) => {
311                    tracing::error!(
312                        "External agent profile {} auth_ref env var {} is not set",
313                        profile.agent_id,
314                        ref_name
315                    );
316                    continue;
317                }
318            },
319            None => None,
320        };
321
322        let client_config = match A2AExternalChildRunner::build_client_config(&profile, auth_token)
323        {
324            Ok(cfg) => cfg,
325            Err(e) => {
326                tracing::error!(
327                    "Failed to build A2A client config for profile {}: {}",
328                    profile.agent_id,
329                    e
330                );
331                continue;
332            }
333        };
334
335        let client = match A2AJsonRpcClient::new(client_config) {
336            Ok(c) => c,
337            Err(e) => {
338                tracing::error!(
339                    "Failed to create A2A JSON-RPC client for profile {}: {}",
340                    profile.agent_id,
341                    e
342                );
343                continue;
344            }
345        };
346
347        runners.push(Arc::new(A2AExternalChildRunner::new(client, profile)));
348    }
349
350    Arc::new(CompositeExternalChildRunner::new(runners))
351}
352
353/// Build the built-in local actor runner from the typed `subagents`
354/// config. Everything is derived: worker = the current bamboo executable +
355/// `subagent-worker`, fabric = per-user temp dir — unless expert fields
356/// override them.
357fn build_local_actor_runner(
358    config: &Config,
359    approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
360    approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
361    permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
362    codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
363) -> Result<Arc<dyn ExternalChildRunner>, String> {
364    let sub = config.subagents();
365
366    let (worker_bin, worker_args) = match &sub.worker_bin {
367        Some(custom) => (
368            std::path::PathBuf::from(custom),
369            sub.worker_args.clone().unwrap_or_default(),
370        ),
371        None => (
372            std::env::current_exe().map_err(|e| format!("cannot locate own executable: {e}"))?,
373            sub.worker_args
374                .clone()
375                .unwrap_or_else(|| vec!["subagent-worker".to_string()]),
376        ),
377    };
378
379    // #217: default under the persistent data-dir subagents home instead of
380    // `env::temp_dir()` (mirrors the `build_external_child_runner` arm above).
381    let fabric_dir = sub
382        .fabric_dir
383        .clone()
384        .map(std::path::PathBuf::from)
385        .unwrap_or_else(bamboo_config::paths::subagents_dir);
386
387    let executor = subagent_executor_spec(config)?;
388
389    let mut runner = ActorChildRunner::new(
390        super::config::LOCAL_ACTOR_AGENT_ID.to_string(),
391        worker_bin,
392        worker_args,
393        fabric_dir,
394        executor,
395        extract_provider_credentials(config),
396        config.effective_default_provider().to_string(),
397        sub.max_concurrent
398            .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS),
399    )
400    .with_remote_placements(resolve_remote_placements(
401        &sub.remote_placements,
402        &config.cluster_fabric.nodes,
403    ))
404    .with_schedulable_placements(resolve_schedulable_placements(
405        &sub.schedulable_placements,
406        &config.cluster_fabric.nodes,
407    ))
408    .with_bus(sub.broker.as_ref().map(|b| bamboo_subagent::BusEndpoint {
409        endpoint: b.endpoint.clone(),
410        token: b.token.clone(),
411    }))
412    .with_codex_run_tokens(codex_run_tokens);
413    if let Some(registry) = approval_registry {
414        runner = runner.with_approval_registry(registry);
415    }
416    if let Some(reviewer) = approval_reviewer {
417        runner = runner.with_approval_reviewer(reviewer);
418    }
419    if let Some(config) = permission_config {
420        runner = runner.with_permission_config(config);
421    }
422    Ok(Arc::new(runner))
423}
424
425/// Convert the durable typed `subagents` section into the exact worker
426/// provisioning executor. This is deliberately independent of actor launch so
427/// the settings-to-spawn contract can be tested directly.
428fn subagent_executor_spec(
429    config: &Config,
430) -> Result<bamboo_subagent::provision::ExecutorSpec, String> {
431    let sub = config.subagents();
432    Ok(match sub.executor.as_deref() {
433        Some("echo") => bamboo_subagent::provision::ExecutorSpec::Echo,
434        Some("bamboo_runtime") | None => bamboo_subagent::provision::ExecutorSpec::BambooRuntime,
435        Some("claude_code") => bamboo_subagent::provision::ExecutorSpec::ClaudeCode {
436            binary: sub.claude_code_binary.clone(),
437            model: sub.claude_code_model.clone(),
438            permission_mode: sub.claude_code_permission_mode.clone(),
439            inherit_user_config: sub.claude_code_inherit_user_config,
440            forward_env: sub.claude_code_forward_env.clone(),
441        },
442        Some("codex") => bamboo_subagent::provision::ExecutorSpec::Codex {
443            binary: sub.codex_binary.clone(),
444            model: sub.codex_model.clone(),
445            mode: sub.codex_mode.map(codex_mode_name),
446            sandbox: sub.codex_sandbox.map(codex_sandbox_name),
447            inherit_user_config: None,
448            auth_mode: Some(codex_auth_mode_name(
449                sub.codex_auth_mode.unwrap_or_default(),
450            )),
451            base_url: codex_base_url(
452                config,
453                sub.codex_auth_mode.unwrap_or_default(),
454                sub.codex_base_url.clone(),
455            ),
456            wire_api: sub.codex_wire_api.map(codex_wire_api_name),
457            provider_key_ref: sub
458                .codex_provider_key_ref
459                .as_ref()
460                .map(|reference| reference.as_str().to_string()),
461            forward_env: sub.codex_forward_env.clone(),
462            approval_policy: sub.codex_approval_policy.map(codex_approval_policy_name),
463            network_access: sub.codex_network_access,
464            allow_danger_bypass: sub.codex_allow_danger_bypass,
465            permission_profile: None,
466            workspace_owned: None,
467        },
468        Some(other) => return Err(format!("unknown subagents.executor '{other}'")),
469    })
470}
471
472/// Resolve config `schedulable_placements` into runner-ready handles (#181, P2b),
473/// keyed by role. Mirrors `resolve_remote_placements`: the bearer is read from
474/// `token_env` HERE (the raw token never rides the config) and is used for BOTH
475/// the registry query and the chosen worker's connect. If `token_env` is `Some`
476/// but the env var is UNSET, log an error and SKIP that placement so a misconfig
477/// fails SAFE to the local path rather than querying/connecting with no bearer. A
478/// placement with no `token_env` is tokenless (trusted/loopback link only).
479/// Duplicate roles: last one wins.
480fn resolve_schedulable_placements(
481    placements: &[bamboo_config::SchedulablePlacement],
482    nodes: &[bamboo_config::cluster_fabric::Node],
483) -> std::collections::HashMap<String, super::actor_adapter::ResolvedSchedulablePlacement> {
484    // Phase 3: a pool is just a bus role. The runner picks a live connected worker
485    // of that role via the bus presence query — no registry url / token / cert.
486    placements
487        .iter()
488        .map(|p| {
489            (
490                p.role.clone(),
491                super::actor_adapter::ResolvedSchedulablePlacement {
492                    pool: p.pool.clone(),
493                    // The badge shows the cluster node's own metadata: a node
494                    // deployed to serve this pool (its `deploy.default_role`).
495                    host_label: node_label_for_role(nodes, &p.pool),
496                },
497            )
498        })
499        .collect()
500}
501
502/// Friendly display name for a cluster node whose worker serves `role`
503/// (`deploy.default_role`) — the operator `label`, else its ssh host. Used to
504/// stamp the UI placement badge from the node's own metadata.
505fn node_label_for_role(
506    nodes: &[bamboo_config::cluster_fabric::Node],
507    role: &str,
508) -> Option<String> {
509    nodes
510        .iter()
511        .find(|n| n.deploy.default_role.as_deref() == Some(role))
512        .map(node_display_name)
513}
514
515/// Friendly display name for a cluster node whose ssh host matches `endpoint`'s
516/// host — so a `remote_placements` endpoint pointing at a known node shows the
517/// node's label rather than a bare IP.
518fn node_label_for_endpoint(
519    nodes: &[bamboo_config::cluster_fabric::Node],
520    endpoint: &str,
521) -> Option<String> {
522    let host = endpoint
523        .trim()
524        .trim_start_matches("wss://")
525        .trim_start_matches("ws://")
526        .split(['/', ':'])
527        .next()
528        .unwrap_or("");
529    if host.is_empty() {
530        return None;
531    }
532    nodes
533        .iter()
534        .find(|n| match &n.placement {
535            bamboo_config::cluster_fabric::NodePlacement::Ssh(t) => t.host == host,
536            bamboo_config::cluster_fabric::NodePlacement::Local => false,
537        })
538        .map(node_display_name)
539}
540
541fn node_display_name(n: &bamboo_config::cluster_fabric::Node) -> String {
542    if !n.label.trim().is_empty() {
543        return n.label.clone();
544    }
545    match &n.placement {
546        bamboo_config::cluster_fabric::NodePlacement::Ssh(t) => t.host.clone(),
547        bamboo_config::cluster_fabric::NodePlacement::Local => "local".to_string(),
548    }
549}
550
551/// Resolve config `remote_placements` into runner-ready handles (#193), keyed by
552/// role. The bearer is read from `token_env` HERE (mirroring the A2A `auth_ref`
553/// handling at ~runtime.rs:142): if the env var is set use it; if `token_env` is
554/// `Some` but the var is UNSET, log an error and SKIP that placement so a
555/// misconfig fails SAFE to the local path rather than connecting to a remote
556/// worker with no bearer. A placement with no `token_env` connects tokenless
557/// (trusted/loopback link only). Duplicate roles: last one wins.
558/// Heuristic: does this endpoint reach off-box (so a missing bearer is a real
559/// exposure)? `wss://` is always public-grade; for `ws://` we flag any host that
560/// is not loopback/localhost.
561fn endpoint_looks_public(endpoint: &str) -> bool {
562    if endpoint.starts_with("wss://") {
563        return true;
564    }
565    let host = endpoint
566        .strip_prefix("ws://")
567        .unwrap_or(endpoint)
568        .split(['/', ':'])
569        .next()
570        .unwrap_or("");
571    !(host == "localhost" || host == "127.0.0.1" || host == "::1" || host.is_empty())
572}
573
574fn resolve_remote_placements(
575    placements: &[bamboo_config::RemoteActorPlacement],
576    nodes: &[bamboo_config::cluster_fabric::Node],
577) -> std::collections::HashMap<String, super::actor_adapter::ResolvedRemotePlacement> {
578    let mut out = std::collections::HashMap::new();
579    for p in placements {
580        let token = match p.token_env.as_deref() {
581            Some(env_var) => match std::env::var(env_var) {
582                Ok(token) => Some(token),
583                Err(_) => {
584                    tracing::error!(
585                        "remote placement for role '{}' token_env '{}' is not set; \
586                         skipping (role falls back to local, NOT unauthenticated remote)",
587                        p.role,
588                        env_var
589                    );
590                    continue;
591                }
592            },
593            None => {
594                // A tokenless placement is only safe on a trusted link. Warn if
595                // it targets what looks like a public endpoint (wss:// or a
596                // non-loopback host) so an operator footgun is visible in logs.
597                if endpoint_looks_public(&p.endpoint) {
598                    tracing::warn!(
599                        "remote placement for role '{}' has no token_env but targets a \
600                         public-looking endpoint '{}'; work will be dispatched with NO bearer. \
601                         Set token_env (and use wss://) for any non-loopback worker.",
602                        p.role,
603                        p.endpoint
604                    );
605                }
606                None
607            }
608        };
609        out.insert(
610            p.role.clone(),
611            super::actor_adapter::ResolvedRemotePlacement {
612                endpoint: p.endpoint.clone(),
613                token,
614                ca_cert_file: p.ca_cert_file.as_ref().map(std::path::PathBuf::from),
615                // Badge from the node's own metadata when the endpoint points at
616                // a known cluster node; else the endpoint host is used downstream.
617                host_label: node_label_for_endpoint(nodes, &p.endpoint),
618            },
619        );
620    }
621    out
622}
623
624/// Snapshot per-provider credentials from the parent config for actor
625/// provisioning. `api_key` (plaintext, in-memory only) is `#[serde(skip_serializing)]`
626/// on every legacy single-instance provider struct — it's hydrated from
627/// `api_key_encrypted` at load time but deliberately never round-tripped
628/// through serde, so a `serde_json::to_value` projection of `config.providers`
629/// sees none of it (#495). Read each typed struct's `api_key` field directly
630/// instead, mirroring how `provider_instances` below already has to.
631pub fn extract_provider_credentials(
632    config: &Config,
633) -> Vec<bamboo_subagent::provision::ScopedCredential> {
634    let mut out = Vec::new();
635
636    fn push_instance(
637        out: &mut Vec<bamboo_subagent::provision::ScopedCredential>,
638        id: &str,
639        instance: &bamboo_config::ProviderInstanceConfig,
640    ) {
641        if !instance.enabled {
642            return;
643        }
644        let api_key = instance.api_key.trim().to_string();
645        if api_key.is_empty() {
646            return;
647        }
648        out.push(bamboo_subagent::provision::ScopedCredential {
649            provider: id.to_string(),
650            api_key,
651            base_url: instance.base_url.clone(),
652            provider_type: Some(instance.provider_type.clone()),
653            credential_ref: instance
654                .credential_ref
655                .as_ref()
656                .map(|reference| reference.as_str().to_string()),
657        });
658    }
659
660    if !config.provider_instances.is_empty() {
661        // Native instances are authoritative. Export enabled explicit
662        // instances only; stale legacy slots must not leak into child workers.
663        for (id, instance) in &config.provider_instances {
664            push_instance(&mut out, id, instance);
665        }
666
667        // Narrow #780 compatibility seam: a hybrid default may still name a
668        // real legacy alias not yet materialized. Mirror the registry's exact
669        // rule and add only that default, never every legacy credential.
670        let default_id = config.effective_default_provider();
671        if !config.provider_instances.contains_key(default_id) {
672            if let Some((_, instance)) = bamboo_config::synthesize_legacy_instances(config)
673                .into_iter()
674                .find(|(id, _)| id == default_id)
675            {
676                push_instance(&mut out, default_id, &instance);
677            }
678        }
679        return out;
680    }
681
682    // Legacy single-instance slots: providers.anthropic / openai / gemini /
683    // bodhi. `copilot` is intentionally omitted — it authenticates via device
684    // flow and has no `api_key` field to extract.
685    let mut push_legacy =
686        |name: &str, api_key: &str, base_url: Option<String>, credential_ref: Option<String>| {
687            let api_key = api_key.trim().to_string();
688            if api_key.is_empty() {
689                return;
690            }
691            out.push(bamboo_subagent::provision::ScopedCredential {
692                provider: name.to_string(),
693                api_key,
694                base_url,
695                provider_type: Some(name.to_string()),
696                credential_ref,
697            });
698        };
699    if let Some(c) = &config.providers().openai {
700        push_legacy(
701            "openai",
702            &c.api_key,
703            c.base_url.clone(),
704            c.credential_ref
705                .as_ref()
706                .map(|reference| reference.as_str().to_string()),
707        );
708    }
709    if let Some(c) = &config.providers().anthropic {
710        push_legacy(
711            "anthropic",
712            &c.api_key,
713            c.base_url.clone(),
714            c.credential_ref
715                .as_ref()
716                .map(|reference| reference.as_str().to_string()),
717        );
718    }
719    if let Some(c) = &config.providers().gemini {
720        push_legacy(
721            "gemini",
722            &c.api_key,
723            c.base_url.clone(),
724            c.credential_ref
725                .as_ref()
726                .map(|reference| reference.as_str().to_string()),
727        );
728    }
729    if let Some(c) = &config.providers().bodhi {
730        push_legacy(
731            "bodhi",
732            &c.api_key,
733            c.base_url.clone(),
734            c.credential_ref
735                .as_ref()
736                .map(|reference| reference.as_str().to_string()),
737        );
738    }
739
740    out
741}
742
743#[cfg(test)]
744mod codex_runtime_config_tests {
745    use super::{
746        codex_approval_policy_name, codex_auth_mode_name, codex_base_url, codex_mode_name,
747        codex_sandbox_name, codex_wire_api_name, subagent_executor_spec,
748    };
749    use bamboo_config::{
750        CodexApprovalPolicy, CodexAuthMode, CodexMode, CodexSandbox, CodexWireApi, CredentialRef,
751    };
752    use bamboo_llm::Config;
753    use bamboo_subagent::provision::ExecutorSpec;
754
755    #[test]
756    fn codex_runtime_mapping_keeps_parent_loopback_and_custom_url_unambiguous() {
757        let mut config = Config::default();
758        config.server.port = 5700;
759
760        assert_eq!(codex_auth_mode_name(CodexAuthMode::Bamboo), "bamboo");
761        assert_eq!(codex_mode_name(CodexMode::AppServer), "app_server");
762        assert_eq!(codex_wire_api_name(CodexWireApi::Responses), "responses");
763        assert_eq!(codex_sandbox_name(CodexSandbox::ReadOnly), "read-only");
764        assert_eq!(
765            codex_sandbox_name(CodexSandbox::WorkspaceWrite),
766            "workspace-write"
767        );
768        assert_eq!(
769            codex_approval_policy_name(CodexApprovalPolicy::OnFailure),
770            "on-failure"
771        );
772        assert_eq!(
773            codex_base_url(&config, CodexAuthMode::Bamboo, None).as_deref(),
774            Some("http://127.0.0.1:5700/openai/v1")
775        );
776        assert_eq!(
777            codex_base_url(
778                &config,
779                CodexAuthMode::Custom,
780                Some("https://provider.example/v1".to_string()),
781            )
782            .as_deref(),
783            Some("https://provider.example/v1")
784        );
785        assert_eq!(codex_base_url(&config, CodexAuthMode::Inherit, None), None);
786        assert_eq!(codex_base_url(&config, CodexAuthMode::ApiKey, None), None);
787    }
788
789    #[test]
790    fn durable_codex_fields_map_without_loss_to_worker_spawn_spec() {
791        let mut config = Config::default();
792        let subagents = config.subagents_mut();
793        subagents.executor = Some("codex".to_string());
794        subagents.codex_binary = Some("/opt/codex/bin/codex".to_string());
795        subagents.codex_model = Some("gpt-5.4".to_string());
796        subagents.codex_mode = Some(CodexMode::AppServer);
797        subagents.codex_auth_mode = Some(CodexAuthMode::Custom);
798        subagents.codex_base_url = Some("https://provider.example/v1".to_string());
799        subagents.codex_wire_api = Some(CodexWireApi::Responses);
800        subagents.codex_provider_key_ref = Some(
801            CredentialRef::parse("provider.codex-work.api_key").expect("valid credential ref"),
802        );
803        subagents.codex_forward_env = Some(vec!["HTTPS_PROXY".to_string()]);
804        subagents.codex_sandbox = Some(CodexSandbox::WorkspaceWrite);
805        subagents.codex_approval_policy = Some(CodexApprovalPolicy::OnRequest);
806        subagents.codex_network_access = Some(true);
807        subagents.codex_allow_danger_bypass = Some(false);
808
809        let spec = subagent_executor_spec(&config).expect("Codex config maps to executor spec");
810        let ExecutorSpec::Codex {
811            binary,
812            model,
813            mode,
814            sandbox,
815            auth_mode,
816            base_url,
817            wire_api,
818            provider_key_ref,
819            forward_env,
820            approval_policy,
821            network_access,
822            allow_danger_bypass,
823            ..
824        } = spec
825        else {
826            panic!("expected Codex executor spec");
827        };
828        assert_eq!(binary.as_deref(), Some("/opt/codex/bin/codex"));
829        assert_eq!(model.as_deref(), Some("gpt-5.4"));
830        assert_eq!(mode.as_deref(), Some("app_server"));
831        assert_eq!(sandbox.as_deref(), Some("workspace-write"));
832        assert_eq!(auth_mode.as_deref(), Some("custom"));
833        assert_eq!(base_url.as_deref(), Some("https://provider.example/v1"));
834        assert_eq!(wire_api.as_deref(), Some("responses"));
835        assert_eq!(
836            provider_key_ref.as_deref(),
837            Some("provider.codex-work.api_key")
838        );
839        assert_eq!(forward_env, Some(vec!["HTTPS_PROXY".to_string()]));
840        assert_eq!(approval_policy.as_deref(), Some("on-request"));
841        assert_eq!(network_access, Some(true));
842        assert_eq!(allow_danger_bypass, Some(false));
843    }
844}
845
846#[cfg(test)]
847mod extract_provider_credentials_tests {
848    use super::extract_provider_credentials;
849    use bamboo_config::{
850        AnthropicConfig, BodhiConfig, Config, OpenAIConfig, ProviderInstanceConfig,
851    };
852
853    fn instance(provider_type: &str, api_key: &str) -> ProviderInstanceConfig {
854        ProviderInstanceConfig {
855            provider_type: provider_type.to_string(),
856            label: None,
857            api_key: api_key.to_string(),
858            api_key_encrypted: None,
859            credential_ref: None,
860            base_url: None,
861            model: None,
862            fast_model: None,
863            vision_model: None,
864            reasoning_effort: None,
865            responses_only_models: Vec::new(),
866            request_overrides: None,
867            enabled: true,
868            extra: Default::default(),
869        }
870    }
871
872    #[test]
873    fn no_config_yields_no_credentials() {
874        let config = Config::default();
875        assert!(extract_provider_credentials(&config).is_empty());
876    }
877
878    /// #495 — a legacy single-instance provider (`config.providers.anthropic`
879    /// etc.) must yield its `api_key` even though the field is
880    /// `#[serde(skip_serializing)]`, because the extraction now reads the
881    /// typed struct instead of projecting through `serde_json::to_value`.
882    #[test]
883    fn legacy_only_config_yields_credential() {
884        let mut config = Config::default();
885        config.providers_mut().anthropic = Some(AnthropicConfig {
886            api_key: "sk-ant-legacy".to_string(),
887            base_url: Some("https://api.anthropic.com".to_string()),
888            ..Default::default()
889        });
890
891        let creds = extract_provider_credentials(&config);
892        assert_eq!(creds.len(), 1);
893        let c = &creds[0];
894        assert_eq!(c.provider, "anthropic");
895        assert_eq!(c.api_key, "sk-ant-legacy");
896        assert_eq!(c.base_url.as_deref(), Some("https://api.anthropic.com"));
897        assert_eq!(c.provider_type.as_deref(), Some("anthropic"));
898    }
899
900    /// `bodhi` doesn't derive `Default`, so it's exercised separately —
901    /// covers the last of the four legacy structs the fix touches
902    /// (openai/anthropic/gemini already share the `Default`-derive path).
903    #[test]
904    fn legacy_bodhi_config_yields_credential() {
905        let mut config = Config::default();
906        config.providers_mut().bodhi = Some(BodhiConfig {
907            api_key: "bhi_sk_legacy".to_string(),
908            api_key_encrypted: None,
909            credential_ref: None,
910            base_url: None,
911            target_provider: None,
912            reasoning_effort: None,
913            extra: Default::default(),
914        });
915
916        let creds = extract_provider_credentials(&config);
917        assert_eq!(creds.len(), 1);
918        assert_eq!(creds[0].provider, "bodhi");
919        assert_eq!(creds[0].api_key, "bhi_sk_legacy");
920    }
921
922    /// A legacy slot with an empty `api_key` (struct present but never
923    /// configured) must not produce a bogus empty credential.
924    #[test]
925    fn legacy_config_with_empty_api_key_is_skipped() {
926        let mut config = Config::default();
927        config.providers_mut().openai = Some(OpenAIConfig::default());
928        assert!(extract_provider_credentials(&config).is_empty());
929    }
930
931    /// Explicit instances are authoritative: unrelated stale legacy slots do
932    /// not cross the actor provisioning boundary.
933    #[test]
934    fn instance_mode_omits_unrelated_legacy_credentials() {
935        let mut config = Config::default();
936        config.providers_mut().anthropic = Some(AnthropicConfig {
937            api_key: "sk-ant-legacy".to_string(),
938            ..Default::default()
939        });
940        let mut openai_work = instance("openai", "sk-oai-work");
941        openai_work.credential_ref = Some(
942            bamboo_config::CredentialRef::parse("provider.openai-work.api_key")
943                .expect("valid provider credential reference"),
944        );
945        config
946            .provider_instances
947            .insert("openai-work".to_string(), openai_work);
948        config.default_provider_instance = Some("openai-work".to_string());
949
950        let creds = extract_provider_credentials(&config);
951
952        assert_eq!(creds.len(), 1);
953        assert_eq!(creds[0].provider, "openai-work");
954        assert_eq!(creds[0].api_key, "sk-oai-work");
955        assert_eq!(creds[0].provider_type.as_deref(), Some("openai"));
956        assert_eq!(
957            creds[0].credential_ref.as_deref(),
958            Some("provider.openai-work.api_key")
959        );
960    }
961
962    #[test]
963    fn hybrid_legacy_default_is_the_only_legacy_credential_exported() {
964        let mut config = Config::default();
965        config.providers_mut().anthropic = Some(AnthropicConfig {
966            api_key: "sk-ant-default".to_string(),
967            ..Default::default()
968        });
969        config.providers_mut().openai = Some(OpenAIConfig {
970            api_key: "sk-oai-stale".to_string(),
971            ..Default::default()
972        });
973        config
974            .provider_instances
975            .insert("work".to_string(), instance("openai", "sk-oai-work"));
976        config.default_provider_instance = Some("anthropic".to_string());
977
978        let mut creds = extract_provider_credentials(&config);
979        creds.sort_by(|a, b| a.provider.cmp(&b.provider));
980
981        assert_eq!(creds.len(), 2);
982        assert_eq!(creds[0].provider, "anthropic");
983        assert_eq!(creds[0].api_key, "sk-ant-default");
984        assert_eq!(creds[1].provider, "work");
985        assert_eq!(creds[1].api_key, "sk-oai-work");
986        assert!(creds
987            .iter()
988            .all(|credential| credential.api_key != "sk-oai-stale"));
989    }
990
991    #[test]
992    fn hybrid_legacy_provider_fallback_without_explicit_default_remains_exportable() {
993        let mut config = Config::default();
994        config.provider = "anthropic".to_string();
995        config.providers_mut().anthropic = Some(AnthropicConfig {
996            api_key: "sk-ant-effective-default".to_string(),
997            ..Default::default()
998        });
999        config
1000            .provider_instances
1001            .insert("work".to_string(), instance("openai", "sk-oai-work"));
1002
1003        let mut creds = extract_provider_credentials(&config);
1004        creds.sort_by(|a, b| a.provider.cmp(&b.provider));
1005
1006        assert_eq!(creds.len(), 2);
1007        assert_eq!(creds[0].provider, "anthropic");
1008        assert_eq!(creds[1].provider, "work");
1009    }
1010
1011    #[test]
1012    fn disabled_instance_credential_is_not_exported() {
1013        let mut config = Config::default();
1014        let mut disabled = instance("openai", "sk-disabled");
1015        disabled.enabled = false;
1016        config
1017            .provider_instances
1018            .insert("disabled".to_string(), disabled);
1019
1020        assert!(extract_provider_credentials(&config).is_empty());
1021    }
1022}
1023
1024#[cfg(test)]
1025mod placement_resolver_tests {
1026    use super::{node_display_name, resolve_remote_placements, resolve_schedulable_placements};
1027    use bamboo_config::cluster_fabric::{
1028        DeployProfile, Node, NodePlacement, SshAuth, SshTarget, TrustLevel,
1029    };
1030    use bamboo_config::{RemoteActorPlacement, SchedulablePlacement};
1031
1032    fn ssh_node(id: &str, label: &str, host: &str, default_role: Option<&str>) -> Node {
1033        Node {
1034            id: id.into(),
1035            label: label.into(),
1036            placement: NodePlacement::Ssh(SshTarget {
1037                host: host.into(),
1038                port: 22,
1039                username: "u".into(),
1040                auth: SshAuth::SystemSshConfig,
1041                host_key_fingerprint: None,
1042            }),
1043            trust_level: TrustLevel::default(),
1044            deploy: DeployProfile {
1045                default_role: default_role.map(String::from),
1046                ..Default::default()
1047            },
1048            state: None,
1049            enabled: true,
1050        }
1051    }
1052
1053    #[test]
1054    fn node_display_name_prefers_label_then_ssh_host() {
1055        let n = ssh_node("n1", "mini", "mini.local", None);
1056        assert_eq!(node_display_name(&n), "mini");
1057        let mut unlabeled = n.clone();
1058        unlabeled.label = String::new();
1059        assert_eq!(node_display_name(&unlabeled), "mini.local");
1060    }
1061
1062    #[test]
1063    fn schedulable_placement_takes_host_label_from_node_by_default_role() {
1064        let nodes = vec![ssh_node(
1065            "n1",
1066            "mini",
1067            "mini.local",
1068            Some("mac-mini-monitor"),
1069        )];
1070        let placements = vec![SchedulablePlacement {
1071            role: "mac-mini-monitor".into(),
1072            pool: "mac-mini-monitor".into(),
1073            ..Default::default()
1074        }];
1075        let out = resolve_schedulable_placements(&placements, &nodes);
1076        let r = out.get("mac-mini-monitor").expect("role resolved");
1077        assert_eq!(r.pool, "mac-mini-monitor");
1078        assert_eq!(r.host_label.as_deref(), Some("mini"));
1079    }
1080
1081    #[test]
1082    fn remote_placement_takes_host_label_from_node_by_ssh_host() {
1083        let nodes = vec![ssh_node("n1", "mini", "mini.local", None)];
1084        let placements = vec![RemoteActorPlacement {
1085            role: "explorer".into(),
1086            endpoint: "ws://mini.local:8899".into(),
1087            ..Default::default()
1088        }];
1089        let out = resolve_remote_placements(&placements, &nodes);
1090        assert_eq!(
1091            out.get("explorer").unwrap().host_label.as_deref(),
1092            Some("mini")
1093        );
1094    }
1095
1096    #[test]
1097    fn no_host_label_when_no_node_matches() {
1098        let nodes = vec![ssh_node("n1", "mini", "mini.local", Some("other-role"))];
1099        let sched = vec![SchedulablePlacement {
1100            role: "x".into(),
1101            pool: "unmatched".into(),
1102            ..Default::default()
1103        }];
1104        assert_eq!(
1105            resolve_schedulable_placements(&sched, &nodes)
1106                .get("x")
1107                .unwrap()
1108                .host_label,
1109            None
1110        );
1111        let remote = vec![RemoteActorPlacement {
1112            role: "y".into(),
1113            endpoint: "ws://other-host:9000".into(),
1114            ..Default::default()
1115        }];
1116        assert_eq!(
1117            resolve_remote_placements(&remote, &nodes)
1118                .get("y")
1119                .unwrap()
1120                .host_label,
1121            None
1122        );
1123    }
1124}