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.provider.clone(),
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.provider.clone(),
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    // Legacy single-instance slots: providers.anthropic / openai / gemini /
637    // bodhi. `copilot` is intentionally omitted — it authenticates via device
638    // flow and has no `api_key` field to extract.
639    let mut push_legacy =
640        |name: &str, api_key: &str, base_url: Option<String>, credential_ref: Option<String>| {
641            let api_key = api_key.trim().to_string();
642            if api_key.is_empty() {
643                return;
644            }
645            out.push(bamboo_subagent::provision::ScopedCredential {
646                provider: name.to_string(),
647                api_key,
648                base_url,
649                provider_type: Some(name.to_string()),
650                credential_ref,
651            });
652        };
653    if let Some(c) = &config.providers().openai {
654        push_legacy(
655            "openai",
656            &c.api_key,
657            c.base_url.clone(),
658            c.credential_ref
659                .as_ref()
660                .map(|reference| reference.as_str().to_string()),
661        );
662    }
663    if let Some(c) = &config.providers().anthropic {
664        push_legacy(
665            "anthropic",
666            &c.api_key,
667            c.base_url.clone(),
668            c.credential_ref
669                .as_ref()
670                .map(|reference| reference.as_str().to_string()),
671        );
672    }
673    if let Some(c) = &config.providers().gemini {
674        push_legacy(
675            "gemini",
676            &c.api_key,
677            c.base_url.clone(),
678            c.credential_ref
679                .as_ref()
680                .map(|reference| reference.as_str().to_string()),
681        );
682    }
683    if let Some(c) = &config.providers().bodhi {
684        push_legacy(
685            "bodhi",
686            &c.api_key,
687            c.base_url.clone(),
688            c.credential_ref
689                .as_ref()
690                .map(|reference| reference.as_str().to_string()),
691        );
692    }
693
694    // Multi-instance providers: provider_instances keyed by instance id; the
695    // child routes by instance id, the worker constructs by provider_type.
696    // Read the typed struct directly — `api_key` is hydrated in memory but
697    // deliberately `skip_serializing`, so a serde projection would miss it.
698    out.extend(config.provider_instances.iter().filter_map(|(id, inst)| {
699        let api_key = inst.api_key.trim().to_string();
700        if api_key.is_empty() {
701            return None;
702        }
703        Some(bamboo_subagent::provision::ScopedCredential {
704            provider: id.clone(),
705            api_key,
706            base_url: inst.base_url.clone(),
707            provider_type: Some(inst.provider_type.clone()),
708            credential_ref: inst
709                .credential_ref
710                .as_ref()
711                .map(|reference| reference.as_str().to_string()),
712        })
713    }));
714
715    out
716}
717
718#[cfg(test)]
719mod codex_runtime_config_tests {
720    use super::{
721        codex_approval_policy_name, codex_auth_mode_name, codex_base_url, codex_mode_name,
722        codex_sandbox_name, codex_wire_api_name, subagent_executor_spec,
723    };
724    use bamboo_config::{
725        CodexApprovalPolicy, CodexAuthMode, CodexMode, CodexSandbox, CodexWireApi, CredentialRef,
726    };
727    use bamboo_llm::Config;
728    use bamboo_subagent::provision::ExecutorSpec;
729
730    #[test]
731    fn codex_runtime_mapping_keeps_parent_loopback_and_custom_url_unambiguous() {
732        let mut config = Config::default();
733        config.server.port = 5700;
734
735        assert_eq!(codex_auth_mode_name(CodexAuthMode::Bamboo), "bamboo");
736        assert_eq!(codex_mode_name(CodexMode::AppServer), "app_server");
737        assert_eq!(codex_wire_api_name(CodexWireApi::Responses), "responses");
738        assert_eq!(codex_sandbox_name(CodexSandbox::ReadOnly), "read-only");
739        assert_eq!(
740            codex_sandbox_name(CodexSandbox::WorkspaceWrite),
741            "workspace-write"
742        );
743        assert_eq!(
744            codex_approval_policy_name(CodexApprovalPolicy::OnFailure),
745            "on-failure"
746        );
747        assert_eq!(
748            codex_base_url(&config, CodexAuthMode::Bamboo, None).as_deref(),
749            Some("http://127.0.0.1:5700/openai/v1")
750        );
751        assert_eq!(
752            codex_base_url(
753                &config,
754                CodexAuthMode::Custom,
755                Some("https://provider.example/v1".to_string()),
756            )
757            .as_deref(),
758            Some("https://provider.example/v1")
759        );
760        assert_eq!(codex_base_url(&config, CodexAuthMode::Inherit, None), None);
761        assert_eq!(codex_base_url(&config, CodexAuthMode::ApiKey, None), None);
762    }
763
764    #[test]
765    fn durable_codex_fields_map_without_loss_to_worker_spawn_spec() {
766        let mut config = Config::default();
767        let subagents = config.subagents_mut();
768        subagents.executor = Some("codex".to_string());
769        subagents.codex_binary = Some("/opt/codex/bin/codex".to_string());
770        subagents.codex_model = Some("gpt-5.4".to_string());
771        subagents.codex_mode = Some(CodexMode::AppServer);
772        subagents.codex_auth_mode = Some(CodexAuthMode::Custom);
773        subagents.codex_base_url = Some("https://provider.example/v1".to_string());
774        subagents.codex_wire_api = Some(CodexWireApi::Responses);
775        subagents.codex_provider_key_ref = Some(
776            CredentialRef::parse("provider.codex-work.api_key").expect("valid credential ref"),
777        );
778        subagents.codex_forward_env = Some(vec!["HTTPS_PROXY".to_string()]);
779        subagents.codex_sandbox = Some(CodexSandbox::WorkspaceWrite);
780        subagents.codex_approval_policy = Some(CodexApprovalPolicy::OnRequest);
781        subagents.codex_network_access = Some(true);
782        subagents.codex_allow_danger_bypass = Some(false);
783
784        let spec = subagent_executor_spec(&config).expect("Codex config maps to executor spec");
785        let ExecutorSpec::Codex {
786            binary,
787            model,
788            mode,
789            sandbox,
790            auth_mode,
791            base_url,
792            wire_api,
793            provider_key_ref,
794            forward_env,
795            approval_policy,
796            network_access,
797            allow_danger_bypass,
798            ..
799        } = spec
800        else {
801            panic!("expected Codex executor spec");
802        };
803        assert_eq!(binary.as_deref(), Some("/opt/codex/bin/codex"));
804        assert_eq!(model.as_deref(), Some("gpt-5.4"));
805        assert_eq!(mode.as_deref(), Some("app_server"));
806        assert_eq!(sandbox.as_deref(), Some("workspace-write"));
807        assert_eq!(auth_mode.as_deref(), Some("custom"));
808        assert_eq!(base_url.as_deref(), Some("https://provider.example/v1"));
809        assert_eq!(wire_api.as_deref(), Some("responses"));
810        assert_eq!(
811            provider_key_ref.as_deref(),
812            Some("provider.codex-work.api_key")
813        );
814        assert_eq!(forward_env, Some(vec!["HTTPS_PROXY".to_string()]));
815        assert_eq!(approval_policy.as_deref(), Some("on-request"));
816        assert_eq!(network_access, Some(true));
817        assert_eq!(allow_danger_bypass, Some(false));
818    }
819}
820
821#[cfg(test)]
822mod extract_provider_credentials_tests {
823    use super::extract_provider_credentials;
824    use bamboo_config::{
825        AnthropicConfig, BodhiConfig, Config, OpenAIConfig, ProviderInstanceConfig,
826    };
827
828    fn instance(provider_type: &str, api_key: &str) -> ProviderInstanceConfig {
829        ProviderInstanceConfig {
830            provider_type: provider_type.to_string(),
831            label: None,
832            api_key: api_key.to_string(),
833            api_key_encrypted: None,
834            credential_ref: None,
835            base_url: None,
836            model: None,
837            fast_model: None,
838            vision_model: None,
839            reasoning_effort: None,
840            responses_only_models: Vec::new(),
841            request_overrides: None,
842            enabled: true,
843            extra: Default::default(),
844        }
845    }
846
847    #[test]
848    fn no_config_yields_no_credentials() {
849        let config = Config::default();
850        assert!(extract_provider_credentials(&config).is_empty());
851    }
852
853    /// #495 — a legacy single-instance provider (`config.providers.anthropic`
854    /// etc.) must yield its `api_key` even though the field is
855    /// `#[serde(skip_serializing)]`, because the extraction now reads the
856    /// typed struct instead of projecting through `serde_json::to_value`.
857    #[test]
858    fn legacy_only_config_yields_credential() {
859        let mut config = Config::default();
860        config.providers_mut().anthropic = Some(AnthropicConfig {
861            api_key: "sk-ant-legacy".to_string(),
862            base_url: Some("https://api.anthropic.com".to_string()),
863            ..Default::default()
864        });
865
866        let creds = extract_provider_credentials(&config);
867        assert_eq!(creds.len(), 1);
868        let c = &creds[0];
869        assert_eq!(c.provider, "anthropic");
870        assert_eq!(c.api_key, "sk-ant-legacy");
871        assert_eq!(c.base_url.as_deref(), Some("https://api.anthropic.com"));
872        assert_eq!(c.provider_type.as_deref(), Some("anthropic"));
873    }
874
875    /// `bodhi` doesn't derive `Default`, so it's exercised separately —
876    /// covers the last of the four legacy structs the fix touches
877    /// (openai/anthropic/gemini already share the `Default`-derive path).
878    #[test]
879    fn legacy_bodhi_config_yields_credential() {
880        let mut config = Config::default();
881        config.providers_mut().bodhi = Some(BodhiConfig {
882            api_key: "bhi_sk_legacy".to_string(),
883            api_key_encrypted: None,
884            credential_ref: None,
885            base_url: None,
886            target_provider: None,
887            reasoning_effort: None,
888            extra: Default::default(),
889        });
890
891        let creds = extract_provider_credentials(&config);
892        assert_eq!(creds.len(), 1);
893        assert_eq!(creds[0].provider, "bodhi");
894        assert_eq!(creds[0].api_key, "bhi_sk_legacy");
895    }
896
897    /// A legacy slot with an empty `api_key` (struct present but never
898    /// configured) must not produce a bogus empty credential.
899    #[test]
900    fn legacy_config_with_empty_api_key_is_skipped() {
901        let mut config = Config::default();
902        config.providers_mut().openai = Some(OpenAIConfig::default());
903        assert!(extract_provider_credentials(&config).is_empty());
904    }
905
906    /// Legacy + `provider_instances` coexisting: both must surface, with no
907    /// duplication/clobbering between the two sources.
908    #[test]
909    fn legacy_and_instances_both_present_no_duplicates() {
910        let mut config = Config::default();
911        config.providers_mut().anthropic = Some(AnthropicConfig {
912            api_key: "sk-ant-legacy".to_string(),
913            ..Default::default()
914        });
915        let mut openai_work = instance("openai", "sk-oai-work");
916        openai_work.credential_ref = Some(
917            bamboo_config::CredentialRef::parse("provider.openai-work.api_key")
918                .expect("valid provider credential reference"),
919        );
920        config
921            .provider_instances
922            .insert("openai-work".to_string(), openai_work);
923
924        let mut creds = extract_provider_credentials(&config);
925        creds.sort_by(|a, b| a.provider.cmp(&b.provider));
926
927        assert_eq!(creds.len(), 2);
928        assert_eq!(creds[0].provider, "anthropic");
929        assert_eq!(creds[0].api_key, "sk-ant-legacy");
930        assert_eq!(creds[1].provider, "openai-work");
931        assert_eq!(creds[1].api_key, "sk-oai-work");
932        assert_eq!(creds[1].provider_type.as_deref(), Some("openai"));
933        assert_eq!(
934            creds[1].credential_ref.as_deref(),
935            Some("provider.openai-work.api_key")
936        );
937    }
938}
939
940#[cfg(test)]
941mod placement_resolver_tests {
942    use super::{node_display_name, resolve_remote_placements, resolve_schedulable_placements};
943    use bamboo_config::cluster_fabric::{
944        DeployProfile, Node, NodePlacement, SshAuth, SshTarget, TrustLevel,
945    };
946    use bamboo_config::{RemoteActorPlacement, SchedulablePlacement};
947
948    fn ssh_node(id: &str, label: &str, host: &str, default_role: Option<&str>) -> Node {
949        Node {
950            id: id.into(),
951            label: label.into(),
952            placement: NodePlacement::Ssh(SshTarget {
953                host: host.into(),
954                port: 22,
955                username: "u".into(),
956                auth: SshAuth::SystemSshConfig,
957                host_key_fingerprint: None,
958            }),
959            trust_level: TrustLevel::default(),
960            deploy: DeployProfile {
961                default_role: default_role.map(String::from),
962                ..Default::default()
963            },
964            state: None,
965            enabled: true,
966        }
967    }
968
969    #[test]
970    fn node_display_name_prefers_label_then_ssh_host() {
971        let n = ssh_node("n1", "mini", "mini.local", None);
972        assert_eq!(node_display_name(&n), "mini");
973        let mut unlabeled = n.clone();
974        unlabeled.label = String::new();
975        assert_eq!(node_display_name(&unlabeled), "mini.local");
976    }
977
978    #[test]
979    fn schedulable_placement_takes_host_label_from_node_by_default_role() {
980        let nodes = vec![ssh_node(
981            "n1",
982            "mini",
983            "mini.local",
984            Some("mac-mini-monitor"),
985        )];
986        let placements = vec![SchedulablePlacement {
987            role: "mac-mini-monitor".into(),
988            pool: "mac-mini-monitor".into(),
989            ..Default::default()
990        }];
991        let out = resolve_schedulable_placements(&placements, &nodes);
992        let r = out.get("mac-mini-monitor").expect("role resolved");
993        assert_eq!(r.pool, "mac-mini-monitor");
994        assert_eq!(r.host_label.as_deref(), Some("mini"));
995    }
996
997    #[test]
998    fn remote_placement_takes_host_label_from_node_by_ssh_host() {
999        let nodes = vec![ssh_node("n1", "mini", "mini.local", None)];
1000        let placements = vec![RemoteActorPlacement {
1001            role: "explorer".into(),
1002            endpoint: "ws://mini.local:8899".into(),
1003            ..Default::default()
1004        }];
1005        let out = resolve_remote_placements(&placements, &nodes);
1006        assert_eq!(
1007            out.get("explorer").unwrap().host_label.as_deref(),
1008            Some("mini")
1009        );
1010    }
1011
1012    #[test]
1013    fn no_host_label_when_no_node_matches() {
1014        let nodes = vec![ssh_node("n1", "mini", "mini.local", Some("other-role"))];
1015        let sched = vec![SchedulablePlacement {
1016            role: "x".into(),
1017            pool: "unmatched".into(),
1018            ..Default::default()
1019        }];
1020        assert_eq!(
1021            resolve_schedulable_placements(&sched, &nodes)
1022                .get("x")
1023                .unwrap()
1024                .host_label,
1025            None
1026        );
1027        let remote = vec![RemoteActorPlacement {
1028            role: "y".into(),
1029            endpoint: "ws://other-host:9000".into(),
1030            ..Default::default()
1031        }];
1032        assert_eq!(
1033            resolve_remote_placements(&remote, &nodes)
1034                .get("y")
1035                .unwrap()
1036                .host_label,
1037            None
1038        );
1039    }
1040}