Skip to main content

bamboo_engine/external_agents/
runtime.rs

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