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;
13use super::config::{parse_external_agents, ExternalAgentProtocol};
14
15/// Composite router that delegates to the first matching external child runner.
16pub struct CompositeExternalChildRunner {
17    runners: Vec<Arc<dyn ExternalChildRunner>>,
18}
19
20impl CompositeExternalChildRunner {
21    pub fn new(runners: Vec<Arc<dyn ExternalChildRunner>>) -> Self {
22        Self { runners }
23    }
24}
25
26#[async_trait]
27impl ExternalChildRunner for CompositeExternalChildRunner {
28    async fn should_handle(&self, session: &bamboo_agent_core::Session) -> bool {
29        for runner in &self.runners {
30            if runner.should_handle(session).await {
31                return true;
32            }
33        }
34        false
35    }
36
37    async fn execute_external_child(
38        &self,
39        session: &mut bamboo_agent_core::Session,
40        job: &SpawnJob,
41        event_tx: mpsc::Sender<AgentEvent>,
42        cancel_token: CancellationToken,
43    ) -> crate::runtime::runner::Result<()> {
44        for runner in &self.runners {
45            if runner.should_handle(session).await {
46                return runner
47                    .execute_external_child(session, job, event_tx, cancel_token)
48                    .await;
49            }
50        }
51        Err(AgentError::LLM(
52            "No matching external child runner found for session metadata".to_string(),
53        ))
54    }
55
56    /// #68: fan the per-run escalation bridge out to every inner runner. The
57    /// composite is what `build_external_child_runner` returns and what the
58    /// worker retains, so without this forward the bind would hit the trait's
59    /// no-op default and the wrapped `ActorChildRunner`s would never see it.
60    fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
61        for runner in &self.runners {
62            runner.set_escalation_bridge(bridge.clone());
63        }
64    }
65}
66
67/// Build the child runner from the application config.
68///
69/// Sub-agents always run as actors (the in-process runtime was removed), so the
70/// built-in **local actor** worker is always part of the composite — its worker
71/// binary, arguments, and discovery dir are all derived; no expert tables
72/// needed. Expert `externalAgents` profiles add extra routers so
73/// `external.agent_id` metadata can pin specific roles to other agents. Returns
74/// a composite router that delegates to the first matching runner.
75pub fn build_external_child_runner(config: &Config) -> Arc<dyn ExternalChildRunner> {
76    let agents = parse_external_agents(config);
77
78    let mut runners: Vec<Arc<dyn ExternalChildRunner>> = Vec::new();
79
80    // The built-in local actor worker is the default runtime for every
81    // sub-agent. Always build it; a build failure here is logged and leaves the
82    // composite without a default handler (dispatch then errors clearly).
83    match build_local_actor_runner(config) {
84        Ok(runner) => runners.push(runner),
85        Err(e) => tracing::error!("local actor sub-agent runner unavailable: {e}"),
86    }
87
88    for (_agent_id, profile) in agents {
89        // Actor protocol: spawn a local worker binary over the bamboo-subagent WS protocol.
90        if matches!(profile.protocol, ExternalAgentProtocol::Actor) {
91            let Some(worker_bin) = profile.worker_bin.as_ref() else {
92                tracing::error!(
93                    "Actor agent profile {} has no worker_bin; skipping",
94                    profile.agent_id
95                );
96                continue;
97            };
98            // #217: default under the persistent data-dir subagents home
99            // instead of `env::temp_dir()`, so fabric discovery state
100            // survives reboots and stays inside the tenant's data dir.
101            let fabric_dir = profile
102                .fabric_dir
103                .clone()
104                .map(std::path::PathBuf::from)
105                .unwrap_or_else(bamboo_config::paths::subagents_dir);
106            let executor = match profile.executor.as_deref() {
107                Some("echo") => bamboo_subagent::provision::ExecutorSpec::Echo,
108                Some("bamboo_runtime") | None => {
109                    bamboo_subagent::provision::ExecutorSpec::BambooRuntime
110                }
111                // #443: binary/model/permission_mode/isolation/env-forward
112                // are plumbed from the profile's `claude_code_*` fields.
113                Some("claude_code") => bamboo_subagent::provision::ExecutorSpec::ClaudeCode {
114                    binary: profile.claude_code_binary.clone(),
115                    model: profile.claude_code_model.clone(),
116                    permission_mode: profile.claude_code_permission_mode.clone(),
117                    inherit_user_config: profile.claude_code_inherit_user_config,
118                    forward_env: profile.claude_code_forward_env.clone(),
119                },
120                Some(other) => {
121                    tracing::error!(
122                        "Actor agent profile {} has unknown executor '{}'; skipping",
123                        profile.agent_id,
124                        other
125                    );
126                    continue;
127                }
128            };
129            runners.push(Arc::new(ActorChildRunner::new(
130                profile.agent_id.clone(),
131                std::path::PathBuf::from(worker_bin),
132                profile.worker_args.clone(),
133                fabric_dir,
134                executor,
135                extract_provider_credentials(config),
136                config.provider.clone(),
137                config
138                    .subagents
139                    .max_concurrent
140                    .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS),
141            )));
142            continue;
143        }
144
145        if !matches!(profile.protocol, ExternalAgentProtocol::A2aJsonRpc) {
146            tracing::warn!(
147                "External agent profile {} uses unsupported protocol {:?}",
148                profile.agent_id,
149                profile.protocol
150            );
151            continue;
152        }
153
154        let auth_token = match profile.auth_ref.as_ref() {
155            Some(ref_name) => match std::env::var(ref_name) {
156                Ok(token) => Some(token),
157                Err(_) => {
158                    tracing::error!(
159                        "External agent profile {} auth_ref env var {} is not set",
160                        profile.agent_id,
161                        ref_name
162                    );
163                    continue;
164                }
165            },
166            None => None,
167        };
168
169        let client_config = match A2AExternalChildRunner::build_client_config(&profile, auth_token)
170        {
171            Ok(cfg) => cfg,
172            Err(e) => {
173                tracing::error!(
174                    "Failed to build A2A client config for profile {}: {}",
175                    profile.agent_id,
176                    e
177                );
178                continue;
179            }
180        };
181
182        let client = match A2AJsonRpcClient::new(client_config) {
183            Ok(c) => c,
184            Err(e) => {
185                tracing::error!(
186                    "Failed to create A2A JSON-RPC client for profile {}: {}",
187                    profile.agent_id,
188                    e
189                );
190                continue;
191            }
192        };
193
194        runners.push(Arc::new(A2AExternalChildRunner::new(client, profile)));
195    }
196
197    Arc::new(CompositeExternalChildRunner::new(runners))
198}
199
200/// Build the built-in local actor runner from the typed `subagents`
201/// config. Everything is derived: worker = the current bamboo executable +
202/// `subagent-worker`, fabric = per-user temp dir — unless expert fields
203/// override them.
204fn build_local_actor_runner(config: &Config) -> Result<Arc<dyn ExternalChildRunner>, String> {
205    let sub = &config.subagents;
206
207    let (worker_bin, worker_args) = match &sub.worker_bin {
208        Some(custom) => (
209            std::path::PathBuf::from(custom),
210            sub.worker_args.clone().unwrap_or_default(),
211        ),
212        None => (
213            std::env::current_exe().map_err(|e| format!("cannot locate own executable: {e}"))?,
214            sub.worker_args
215                .clone()
216                .unwrap_or_else(|| vec!["subagent-worker".to_string()]),
217        ),
218    };
219
220    // #217: default under the persistent data-dir subagents home instead of
221    // `env::temp_dir()` (mirrors the `build_external_child_runner` arm above).
222    let fabric_dir = sub
223        .fabric_dir
224        .clone()
225        .map(std::path::PathBuf::from)
226        .unwrap_or_else(bamboo_config::paths::subagents_dir);
227
228    let executor = match sub.executor.as_deref() {
229        Some("echo") => bamboo_subagent::provision::ExecutorSpec::Echo,
230        Some("bamboo_runtime") | None => bamboo_subagent::provision::ExecutorSpec::BambooRuntime,
231        // #443: binary/model/permission_mode/isolation/env-forward are
232        // plumbed from `subagents.claude_code_*` — mirrors the matching arm
233        // in `build_external_child_runner` above.
234        Some("claude_code") => bamboo_subagent::provision::ExecutorSpec::ClaudeCode {
235            binary: sub.claude_code_binary.clone(),
236            model: sub.claude_code_model.clone(),
237            permission_mode: sub.claude_code_permission_mode.clone(),
238            inherit_user_config: sub.claude_code_inherit_user_config,
239            forward_env: sub.claude_code_forward_env.clone(),
240        },
241        Some(other) => return Err(format!("unknown subagents.executor '{other}'")),
242    };
243
244    Ok(Arc::new(
245        ActorChildRunner::new(
246            super::config::LOCAL_ACTOR_AGENT_ID.to_string(),
247            worker_bin,
248            worker_args,
249            fabric_dir,
250            executor,
251            extract_provider_credentials(config),
252            config.provider.clone(),
253            sub.max_concurrent
254                .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS),
255        )
256        .with_remote_placements(resolve_remote_placements(
257            &sub.remote_placements,
258            &config.cluster_fabric.nodes,
259        ))
260        .with_schedulable_placements(resolve_schedulable_placements(
261            &sub.schedulable_placements,
262            &config.cluster_fabric.nodes,
263        ))
264        .with_bus(sub.broker.as_ref().map(|b| bamboo_subagent::BusEndpoint {
265            endpoint: b.endpoint.clone(),
266            token: b.token.clone(),
267        })),
268    ))
269}
270
271/// Resolve config `schedulable_placements` into runner-ready handles (#181, P2b),
272/// keyed by role. Mirrors `resolve_remote_placements`: the bearer is read from
273/// `token_env` HERE (the raw token never rides the config) and is used for BOTH
274/// the registry query and the chosen worker's connect. If `token_env` is `Some`
275/// but the env var is UNSET, log an error and SKIP that placement so a misconfig
276/// fails SAFE to the local path rather than querying/connecting with no bearer. A
277/// placement with no `token_env` is tokenless (trusted/loopback link only).
278/// Duplicate roles: last one wins.
279fn resolve_schedulable_placements(
280    placements: &[bamboo_config::SchedulablePlacement],
281    nodes: &[bamboo_config::cluster_fabric::Node],
282) -> std::collections::HashMap<String, super::actor_adapter::ResolvedSchedulablePlacement> {
283    // Phase 3: a pool is just a bus role. The runner picks a live connected worker
284    // of that role via the bus presence query — no registry url / token / cert.
285    placements
286        .iter()
287        .map(|p| {
288            (
289                p.role.clone(),
290                super::actor_adapter::ResolvedSchedulablePlacement {
291                    pool: p.pool.clone(),
292                    // The badge shows the cluster node's own metadata: a node
293                    // deployed to serve this pool (its `deploy.default_role`).
294                    host_label: node_label_for_role(nodes, &p.pool),
295                },
296            )
297        })
298        .collect()
299}
300
301/// Friendly display name for a cluster node whose worker serves `role`
302/// (`deploy.default_role`) — the operator `label`, else its ssh host. Used to
303/// stamp the UI placement badge from the node's own metadata.
304fn node_label_for_role(
305    nodes: &[bamboo_config::cluster_fabric::Node],
306    role: &str,
307) -> Option<String> {
308    nodes
309        .iter()
310        .find(|n| n.deploy.default_role.as_deref() == Some(role))
311        .map(node_display_name)
312}
313
314/// Friendly display name for a cluster node whose ssh host matches `endpoint`'s
315/// host — so a `remote_placements` endpoint pointing at a known node shows the
316/// node's label rather than a bare IP.
317fn node_label_for_endpoint(
318    nodes: &[bamboo_config::cluster_fabric::Node],
319    endpoint: &str,
320) -> Option<String> {
321    let host = endpoint
322        .trim()
323        .trim_start_matches("wss://")
324        .trim_start_matches("ws://")
325        .split(['/', ':'])
326        .next()
327        .unwrap_or("");
328    if host.is_empty() {
329        return None;
330    }
331    nodes
332        .iter()
333        .find(|n| match &n.placement {
334            bamboo_config::cluster_fabric::NodePlacement::Ssh(t) => t.host == host,
335            bamboo_config::cluster_fabric::NodePlacement::Local => false,
336        })
337        .map(node_display_name)
338}
339
340fn node_display_name(n: &bamboo_config::cluster_fabric::Node) -> String {
341    if !n.label.trim().is_empty() {
342        return n.label.clone();
343    }
344    match &n.placement {
345        bamboo_config::cluster_fabric::NodePlacement::Ssh(t) => t.host.clone(),
346        bamboo_config::cluster_fabric::NodePlacement::Local => "local".to_string(),
347    }
348}
349
350/// Resolve config `remote_placements` into runner-ready handles (#193), keyed by
351/// role. The bearer is read from `token_env` HERE (mirroring the A2A `auth_ref`
352/// handling at ~runtime.rs:142): if the env var is set use it; if `token_env` is
353/// `Some` but the var is UNSET, log an error and SKIP that placement so a
354/// misconfig fails SAFE to the local path rather than connecting to a remote
355/// worker with no bearer. A placement with no `token_env` connects tokenless
356/// (trusted/loopback link only). Duplicate roles: last one wins.
357/// Heuristic: does this endpoint reach off-box (so a missing bearer is a real
358/// exposure)? `wss://` is always public-grade; for `ws://` we flag any host that
359/// is not loopback/localhost.
360fn endpoint_looks_public(endpoint: &str) -> bool {
361    if endpoint.starts_with("wss://") {
362        return true;
363    }
364    let host = endpoint
365        .strip_prefix("ws://")
366        .unwrap_or(endpoint)
367        .split(['/', ':'])
368        .next()
369        .unwrap_or("");
370    !(host == "localhost" || host == "127.0.0.1" || host == "::1" || host.is_empty())
371}
372
373fn resolve_remote_placements(
374    placements: &[bamboo_config::RemoteActorPlacement],
375    nodes: &[bamboo_config::cluster_fabric::Node],
376) -> std::collections::HashMap<String, super::actor_adapter::ResolvedRemotePlacement> {
377    let mut out = std::collections::HashMap::new();
378    for p in placements {
379        let token = match p.token_env.as_deref() {
380            Some(env_var) => match std::env::var(env_var) {
381                Ok(token) => Some(token),
382                Err(_) => {
383                    tracing::error!(
384                        "remote placement for role '{}' token_env '{}' is not set; \
385                         skipping (role falls back to local, NOT unauthenticated remote)",
386                        p.role,
387                        env_var
388                    );
389                    continue;
390                }
391            },
392            None => {
393                // A tokenless placement is only safe on a trusted link. Warn if
394                // it targets what looks like a public endpoint (wss:// or a
395                // non-loopback host) so an operator footgun is visible in logs.
396                if endpoint_looks_public(&p.endpoint) {
397                    tracing::warn!(
398                        "remote placement for role '{}' has no token_env but targets a \
399                         public-looking endpoint '{}'; work will be dispatched with NO bearer. \
400                         Set token_env (and use wss://) for any non-loopback worker.",
401                        p.role,
402                        p.endpoint
403                    );
404                }
405                None
406            }
407        };
408        out.insert(
409            p.role.clone(),
410            super::actor_adapter::ResolvedRemotePlacement {
411                endpoint: p.endpoint.clone(),
412                token,
413                ca_cert_file: p.ca_cert_file.as_ref().map(std::path::PathBuf::from),
414                // Badge from the node's own metadata when the endpoint points at
415                // a known cluster node; else the endpoint host is used downstream.
416                host_label: node_label_for_endpoint(nodes, &p.endpoint),
417            },
418        );
419    }
420    out
421}
422
423/// Snapshot per-provider credentials from the parent config for actor
424/// provisioning. `api_key` (plaintext, in-memory only) is `#[serde(skip_serializing)]`
425/// on every legacy single-instance provider struct — it's hydrated from
426/// `api_key_encrypted` at load time but deliberately never round-tripped
427/// through serde, so a `serde_json::to_value` projection of `config.providers`
428/// sees none of it (#495). Read each typed struct's `api_key` field directly
429/// instead, mirroring how `provider_instances` below already has to.
430pub fn extract_provider_credentials(
431    config: &Config,
432) -> Vec<bamboo_subagent::provision::ScopedCredential> {
433    let mut out = Vec::new();
434
435    // Legacy single-instance slots: providers.anthropic / openai / gemini /
436    // bodhi. `copilot` is intentionally omitted — it authenticates via device
437    // flow and has no `api_key` field to extract.
438    let mut push_legacy = |name: &str, api_key: &str, base_url: Option<String>| {
439        let api_key = api_key.trim().to_string();
440        if api_key.is_empty() {
441            return;
442        }
443        out.push(bamboo_subagent::provision::ScopedCredential {
444            provider: name.to_string(),
445            api_key,
446            base_url,
447            provider_type: Some(name.to_string()),
448        });
449    };
450    if let Some(c) = &config.providers.openai {
451        push_legacy("openai", &c.api_key, c.base_url.clone());
452    }
453    if let Some(c) = &config.providers.anthropic {
454        push_legacy("anthropic", &c.api_key, c.base_url.clone());
455    }
456    if let Some(c) = &config.providers.gemini {
457        push_legacy("gemini", &c.api_key, c.base_url.clone());
458    }
459    if let Some(c) = &config.providers.bodhi {
460        push_legacy("bodhi", &c.api_key, c.base_url.clone());
461    }
462
463    // Multi-instance providers: provider_instances keyed by instance id; the
464    // child routes by instance id, the worker constructs by provider_type.
465    // Read the typed struct directly — `api_key` is hydrated in memory but
466    // deliberately `skip_serializing`, so a serde projection would miss it.
467    out.extend(config.provider_instances.iter().filter_map(|(id, inst)| {
468        let api_key = inst.api_key.trim().to_string();
469        if api_key.is_empty() {
470            return None;
471        }
472        Some(bamboo_subagent::provision::ScopedCredential {
473            provider: id.clone(),
474            api_key,
475            base_url: inst.base_url.clone(),
476            provider_type: Some(inst.provider_type.clone()),
477        })
478    }));
479
480    out
481}
482
483#[cfg(test)]
484mod extract_provider_credentials_tests {
485    use super::extract_provider_credentials;
486    use bamboo_config::{
487        AnthropicConfig, BodhiConfig, Config, OpenAIConfig, ProviderInstanceConfig,
488    };
489
490    fn instance(provider_type: &str, api_key: &str) -> ProviderInstanceConfig {
491        ProviderInstanceConfig {
492            provider_type: provider_type.to_string(),
493            label: None,
494            api_key: api_key.to_string(),
495            api_key_encrypted: None,
496            base_url: None,
497            model: None,
498            fast_model: None,
499            vision_model: None,
500            reasoning_effort: None,
501            responses_only_models: Vec::new(),
502            request_overrides: None,
503            enabled: true,
504            extra: Default::default(),
505        }
506    }
507
508    #[test]
509    fn no_config_yields_no_credentials() {
510        let config = Config::default();
511        assert!(extract_provider_credentials(&config).is_empty());
512    }
513
514    /// #495 — a legacy single-instance provider (`config.providers.anthropic`
515    /// etc.) must yield its `api_key` even though the field is
516    /// `#[serde(skip_serializing)]`, because the extraction now reads the
517    /// typed struct instead of projecting through `serde_json::to_value`.
518    #[test]
519    fn legacy_only_config_yields_credential() {
520        let mut config = Config::default();
521        config.providers.anthropic = Some(AnthropicConfig {
522            api_key: "sk-ant-legacy".to_string(),
523            base_url: Some("https://api.anthropic.com".to_string()),
524            ..Default::default()
525        });
526
527        let creds = extract_provider_credentials(&config);
528        assert_eq!(creds.len(), 1);
529        let c = &creds[0];
530        assert_eq!(c.provider, "anthropic");
531        assert_eq!(c.api_key, "sk-ant-legacy");
532        assert_eq!(c.base_url.as_deref(), Some("https://api.anthropic.com"));
533        assert_eq!(c.provider_type.as_deref(), Some("anthropic"));
534    }
535
536    /// `bodhi` doesn't derive `Default`, so it's exercised separately —
537    /// covers the last of the four legacy structs the fix touches
538    /// (openai/anthropic/gemini already share the `Default`-derive path).
539    #[test]
540    fn legacy_bodhi_config_yields_credential() {
541        let mut config = Config::default();
542        config.providers.bodhi = Some(BodhiConfig {
543            api_key: "bhi_sk_legacy".to_string(),
544            api_key_encrypted: None,
545            base_url: None,
546            target_provider: None,
547            reasoning_effort: None,
548            extra: Default::default(),
549        });
550
551        let creds = extract_provider_credentials(&config);
552        assert_eq!(creds.len(), 1);
553        assert_eq!(creds[0].provider, "bodhi");
554        assert_eq!(creds[0].api_key, "bhi_sk_legacy");
555    }
556
557    /// A legacy slot with an empty `api_key` (struct present but never
558    /// configured) must not produce a bogus empty credential.
559    #[test]
560    fn legacy_config_with_empty_api_key_is_skipped() {
561        let mut config = Config::default();
562        config.providers.openai = Some(OpenAIConfig::default());
563        assert!(extract_provider_credentials(&config).is_empty());
564    }
565
566    /// Legacy + `provider_instances` coexisting: both must surface, with no
567    /// duplication/clobbering between the two sources.
568    #[test]
569    fn legacy_and_instances_both_present_no_duplicates() {
570        let mut config = Config::default();
571        config.providers.anthropic = Some(AnthropicConfig {
572            api_key: "sk-ant-legacy".to_string(),
573            ..Default::default()
574        });
575        config
576            .provider_instances
577            .insert("openai-work".to_string(), instance("openai", "sk-oai-work"));
578
579        let mut creds = extract_provider_credentials(&config);
580        creds.sort_by(|a, b| a.provider.cmp(&b.provider));
581
582        assert_eq!(creds.len(), 2);
583        assert_eq!(creds[0].provider, "anthropic");
584        assert_eq!(creds[0].api_key, "sk-ant-legacy");
585        assert_eq!(creds[1].provider, "openai-work");
586        assert_eq!(creds[1].api_key, "sk-oai-work");
587        assert_eq!(creds[1].provider_type.as_deref(), Some("openai"));
588    }
589}
590
591#[cfg(test)]
592mod placement_resolver_tests {
593    use super::{node_display_name, resolve_remote_placements, resolve_schedulable_placements};
594    use bamboo_config::cluster_fabric::{
595        DeployProfile, Node, NodePlacement, SshAuth, SshTarget, TrustLevel,
596    };
597    use bamboo_config::{RemoteActorPlacement, SchedulablePlacement};
598
599    fn ssh_node(id: &str, label: &str, host: &str, default_role: Option<&str>) -> Node {
600        Node {
601            id: id.into(),
602            label: label.into(),
603            placement: NodePlacement::Ssh(SshTarget {
604                host: host.into(),
605                port: 22,
606                username: "u".into(),
607                auth: SshAuth::SystemSshConfig,
608                host_key_fingerprint: None,
609            }),
610            trust_level: TrustLevel::default(),
611            deploy: DeployProfile {
612                default_role: default_role.map(String::from),
613                ..Default::default()
614            },
615            state: None,
616            enabled: true,
617        }
618    }
619
620    #[test]
621    fn node_display_name_prefers_label_then_ssh_host() {
622        let n = ssh_node("n1", "mini", "mini.local", None);
623        assert_eq!(node_display_name(&n), "mini");
624        let mut unlabeled = n.clone();
625        unlabeled.label = String::new();
626        assert_eq!(node_display_name(&unlabeled), "mini.local");
627    }
628
629    #[test]
630    fn schedulable_placement_takes_host_label_from_node_by_default_role() {
631        let nodes = vec![ssh_node(
632            "n1",
633            "mini",
634            "mini.local",
635            Some("mac-mini-monitor"),
636        )];
637        let placements = vec![SchedulablePlacement {
638            role: "mac-mini-monitor".into(),
639            pool: "mac-mini-monitor".into(),
640            ..Default::default()
641        }];
642        let out = resolve_schedulable_placements(&placements, &nodes);
643        let r = out.get("mac-mini-monitor").expect("role resolved");
644        assert_eq!(r.pool, "mac-mini-monitor");
645        assert_eq!(r.host_label.as_deref(), Some("mini"));
646    }
647
648    #[test]
649    fn remote_placement_takes_host_label_from_node_by_ssh_host() {
650        let nodes = vec![ssh_node("n1", "mini", "mini.local", None)];
651        let placements = vec![RemoteActorPlacement {
652            role: "explorer".into(),
653            endpoint: "ws://mini.local:8899".into(),
654            ..Default::default()
655        }];
656        let out = resolve_remote_placements(&placements, &nodes);
657        assert_eq!(
658            out.get("explorer").unwrap().host_label.as_deref(),
659            Some("mini")
660        );
661    }
662
663    #[test]
664    fn no_host_label_when_no_node_matches() {
665        let nodes = vec![ssh_node("n1", "mini", "mini.local", Some("other-role"))];
666        let sched = vec![SchedulablePlacement {
667            role: "x".into(),
668            pool: "unmatched".into(),
669            ..Default::default()
670        }];
671        assert_eq!(
672            resolve_schedulable_placements(&sched, &nodes)
673                .get("x")
674                .unwrap()
675                .host_label,
676            None
677        );
678        let remote = vec![RemoteActorPlacement {
679            role: "y".into(),
680            endpoint: "ws://other-host:9000".into(),
681            ..Default::default()
682        }];
683        assert_eq!(
684            resolve_remote_placements(&remote, &nodes)
685                .get("y")
686                .unwrap()
687                .host_label,
688            None
689        );
690    }
691}