Skip to main content

horto_os_ui_shared/remote/
surfaces.rs

1//! Cross-surface probes: SSH, box CLI, status-api, MCP (PC + box).
2//!
3//! One report powers TUI tabs, `horto-os-ui surfaces`, and Desktop Connection.
4
5use super::process::{ProcessRunner, StdioMode};
6use super::runner::{
7    api_token_config_path, classify_ssh_failure, probe_remote_cli, session_from,
8    usable_api_token_hex, RemoteBoxCliStatus, RemoteOptions,
9};
10use crate::error::Result;
11use crate::LONG_VERSION;
12use serde::{Deserialize, Serialize};
13use std::fmt::Write;
14use std::io::{Read, Write as IoWrite};
15use std::net::TcpStream;
16use std::path::{Path, PathBuf};
17use std::time::Duration;
18
19const HTTP_TIMEOUT: Duration = Duration::from_secs(2);
20const STATUS_API_PORT: u16 = 8787;
21const MCP_HTTP_PORT: u16 = 8790;
22
23/// Full surface probe for tip + box.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct SurfaceProbeReport {
26    /// Tip CLI long version (`local=`).
27    pub local_version: String,
28    /// OpenSSH access to the box (or embedded N/A).
29    pub ssh: SshSurfaceProbe,
30    /// Box CLI binary / version.
31    pub cli: CliSurfaceProbe,
32    /// Status API `:8787`.
33    pub api: ApiSurfaceProbe,
34    /// MCP on the tip (PC): Docker / binary / HTTP / unit / API health.
35    pub mcp_pc: McpHostProbe,
36    /// MCP on the box: Docker / binary / HTTP / unit / API health.
37    pub mcp_box: McpHostProbe,
38}
39
40/// SSH reachability / key auth.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct SshSurfaceProbe {
43    /// Host string used for OpenSSH.
44    pub host: String,
45    /// Operator label: `ok`, `auth failed`, `unreachable`, `n/a`.
46    pub status: String,
47    /// True when `BatchMode` key login works.
48    pub key_ok: bool,
49}
50
51/// Box CLI probe row.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct CliSurfaceProbe {
54    /// After probe: missing / auth failed / unreachable / Found(version).
55    pub status: RemoteBoxCliStatus,
56    /// Normalized version when found.
57    pub version: Option<String>,
58    /// Matches tip [`LONG_VERSION`].
59    pub current: bool,
60}
61
62/// Status-api HTTP (+ optional unit via SSH).
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ApiSurfaceProbe {
65    /// Base URL probed (`http://host:8787`).
66    pub url: String,
67    /// `ok` / `unreachable` / `http <code>` / `auth required`.
68    pub health: String,
69    /// `/v1/status` label when attempted.
70    pub status: String,
71    /// Local token file present (never the secret).
72    pub local_token: bool,
73    /// `systemctl is-active` when SSH worked; empty otherwise.
74    pub unit: String,
75}
76
77/// MCP readiness on one host (PC or box): stdio runtime + HTTP.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct McpHostProbe {
80    /// Docker image tag when present (`HORTO_MCP_IMAGE` or default).
81    pub docker: Option<String>,
82    /// Path to `horto-os-ui-mcp` when found.
83    pub binary: Option<String>,
84    /// `http://host:8790`.
85    pub http_url: String,
86    /// HTTP reachability label for [`Self::http_url`].
87    pub http_reach: String,
88    /// Unit/process hint when available.
89    pub unit: String,
90    /// Status-api `/health` on this host (`:8787`).
91    pub api_health: String,
92}
93
94/// Default MCP image tag (matches `docker/cursor-mcp-stdio.sh`).
95const DEFAULT_MCP_IMAGE: &str = "horto-os-ui-mcp:local";
96
97/// Read local tip bearer when present (hex only).
98///
99/// Rejects short leftovers (box install writes 64 hex chars). Those cause 401s
100/// against a real Status API.
101#[must_use]
102pub fn read_local_api_token() -> Option<String> {
103    let path = api_token_config_path();
104    let raw = std::fs::read_to_string(path).ok()?;
105    let line = raw.lines().map(str::trim).find(|l| !l.is_empty())?;
106    usable_api_token_hex(line).map(str::to_owned)
107}
108
109/// Path to the local tip API token file (UI display only).
110#[must_use]
111pub fn local_api_token_path() -> PathBuf {
112    api_token_config_path()
113}
114
115/// Probe all surfaces for a remote host, or loopback when `embedded`.
116///
117/// # Errors
118///
119/// Returns [`crate::HortoError`] when the SSH host string cannot be parsed.
120pub fn probe_surfaces(
121    runner: &dyn ProcessRunner,
122    opts: &RemoteOptions,
123    embedded: bool,
124) -> Result<SurfaceProbeReport> {
125    let host = if embedded {
126        "127.0.0.1".to_owned()
127    } else {
128        opts.host.clone()
129    };
130
131    let ssh = if embedded {
132        SshSurfaceProbe {
133            host: host.clone(),
134            status: "n/a".into(),
135            key_ok: false,
136        }
137    } else {
138        probe_ssh_access(runner, opts)?
139    };
140
141    let cli = probe_cli_row(runner, opts, embedded, &ssh)?;
142    let token = read_local_api_token();
143    let api = probe_api_row(runner, opts, &host, embedded, &ssh, token.as_deref());
144    let mcp_pc = probe_mcp_pc(runner, &host, opts.bin_dir.as_deref());
145    let mcp_box = probe_mcp_box(runner, opts, &host, embedded, &ssh);
146
147    Ok(SurfaceProbeReport {
148        local_version: LONG_VERSION.to_owned(),
149        ssh,
150        cli,
151        api,
152        mcp_pc,
153        mcp_box,
154    })
155}
156
157/// Probe SSH only (`BatchMode`).
158///
159/// # Errors
160///
161/// Returns [`crate::HortoError`] when the host string cannot be parsed.
162pub fn probe_ssh_surface(
163    runner: &dyn ProcessRunner,
164    opts: &RemoteOptions,
165    embedded: bool,
166) -> Result<SshSurfaceProbe> {
167    if embedded {
168        return Ok(SshSurfaceProbe {
169            host: "127.0.0.1".into(),
170            status: "n/a".into(),
171            key_ok: false,
172        });
173    }
174    probe_ssh_access(runner, opts)
175}
176
177/// Probe box CLI only.
178///
179/// # Errors
180///
181/// Returns [`crate::HortoError`] when SSH host parsing or remote CLI probe fails.
182pub fn probe_cli_surface(
183    runner: &dyn ProcessRunner,
184    opts: &RemoteOptions,
185    embedded: bool,
186) -> Result<CliSurfaceProbe> {
187    let ssh = probe_ssh_surface(runner, opts, embedded)?;
188    probe_cli_row(runner, opts, embedded, &ssh)
189}
190
191/// Probe status-api only.
192///
193/// # Errors
194///
195/// Returns [`crate::HortoError`] when the SSH host string cannot be parsed.
196pub fn probe_api_surface(
197    runner: &dyn ProcessRunner,
198    opts: &RemoteOptions,
199    embedded: bool,
200) -> Result<ApiSurfaceProbe> {
201    let host = if embedded {
202        "127.0.0.1".to_owned()
203    } else {
204        opts.host.clone()
205    };
206    let ssh = probe_ssh_surface(runner, opts, embedded)?;
207    let token = read_local_api_token();
208    Ok(probe_api_row(
209        runner,
210        opts,
211        &host,
212        embedded,
213        &ssh,
214        token.as_deref(),
215    ))
216}
217
218/// Probe MCP PC + box only.
219///
220/// # Errors
221///
222/// Returns [`crate::HortoError`] when the SSH host string cannot be parsed.
223pub fn probe_mcp_surface(
224    runner: &dyn ProcessRunner,
225    opts: &RemoteOptions,
226    embedded: bool,
227) -> Result<(McpHostProbe, McpHostProbe)> {
228    let host = if embedded {
229        "127.0.0.1".to_owned()
230    } else {
231        opts.host.clone()
232    };
233    let ssh = probe_ssh_surface(runner, opts, embedded)?;
234    Ok((
235        probe_mcp_pc(runner, &host, opts.bin_dir.as_deref()),
236        probe_mcp_box(runner, opts, &host, embedded, &ssh),
237    ))
238}
239
240/// Human-readable multi-line report (CLI `surfaces`).
241#[must_use]
242pub fn format_surfaces_report(report: &SurfaceProbeReport) -> String {
243    let mut out = String::new();
244    let _ = writeln!(out, "local={}", report.local_version);
245    let _ = writeln!(
246        out,
247        "ssh={} key_ok={}",
248        report.ssh.status, report.ssh.key_ok
249    );
250    let _ = writeln!(
251        out,
252        "cli={} current={}",
253        report.cli.status.as_label(),
254        report.cli.current
255    );
256    let _ = writeln!(
257        out,
258        "api={} health={} status={} token_file={} unit={}",
259        report.api.url,
260        report.api.health,
261        report.api.status,
262        report.api.local_token,
263        empty_dash(&report.api.unit)
264    );
265    out.push_str(&format_mcp_host_line("mcp_pc", &report.mcp_pc));
266    out.push_str(&format_mcp_host_line("mcp_box", &report.mcp_box));
267    out
268}
269
270fn format_mcp_host_line(prefix: &str, p: &McpHostProbe) -> String {
271    format!(
272        "{prefix} docker={} binary={} http={} reach={} unit={} api_health={}\n",
273        p.docker.as_deref().unwrap_or("missing"),
274        p.binary.as_deref().unwrap_or("missing"),
275        p.http_url,
276        p.http_reach,
277        empty_dash(&p.unit),
278        p.api_health
279    )
280}
281
282const fn empty_dash(s: &str) -> &str {
283    if s.is_empty() {
284        "-"
285    } else {
286        s
287    }
288}
289
290fn probe_cli_row(
291    runner: &dyn ProcessRunner,
292    opts: &RemoteOptions,
293    embedded: bool,
294    ssh: &SshSurfaceProbe,
295) -> Result<CliSurfaceProbe> {
296    if embedded {
297        return Ok(CliSurfaceProbe {
298            status: RemoteBoxCliStatus::Found(LONG_VERSION.to_owned()),
299            version: Some(LONG_VERSION.to_owned()),
300            current: true,
301        });
302    }
303    if ssh.status == "ok" || ssh.key_ok {
304        let session = session_from(opts)?;
305        let probe = probe_remote_cli(runner, &session, opts)?;
306        return Ok(CliSurfaceProbe {
307            status: probe.status,
308            version: probe.version,
309            current: probe.current,
310        });
311    }
312    Ok(CliSurfaceProbe {
313        status: if ssh.status == "auth failed" {
314            RemoteBoxCliStatus::AuthFailed
315        } else {
316            RemoteBoxCliStatus::Unreachable
317        },
318        version: None,
319        current: false,
320    })
321}
322
323/// `BatchMode` SSH `true` against the box (no password prompt).
324///
325/// # Errors
326///
327/// Returns [`crate::HortoError`] when the host string cannot be parsed.
328pub fn probe_ssh_access(
329    runner: &dyn ProcessRunner,
330    opts: &RemoteOptions,
331) -> Result<SshSurfaceProbe> {
332    let session = session_from(opts)?;
333    let pairs = session.env.as_pairs();
334    let env: Vec<(&str, &str)> = pairs
335        .iter()
336        .map(|(k, v)| (k.as_str(), v.as_str()))
337        .collect();
338    let mut args = Vec::new();
339    if let Some(cfg) = &session.config_file {
340        args.push("-F".to_owned());
341        args.push(cfg.display().to_string());
342    }
343    args.extend([
344        "-o".into(),
345        "BatchMode=yes".into(),
346        "-o".into(),
347        "StrictHostKeyChecking=accept-new".into(),
348        "-o".into(),
349        "ConnectTimeout=5".into(),
350        session.host.raw,
351        "true".into(),
352    ]);
353    let refs: Vec<&str> = args.iter().map(String::as_str).collect();
354    let out = runner.run("ssh", &refs, &env, StdioMode::Capture)?;
355    if out.success() {
356        return Ok(SshSurfaceProbe {
357            host: opts.host.clone(),
358            status: "ok".into(),
359            key_ok: true,
360        });
361    }
362    let detail = if out.stderr.trim().is_empty() {
363        out.stdout.trim()
364    } else {
365        out.stderr.trim()
366    };
367    let classified = classify_ssh_failure(&format!("exit {}: {detail}", out.status));
368    let status = match classified {
369        RemoteBoxCliStatus::AuthFailed => "auth failed",
370        _ => "unreachable",
371    };
372    Ok(SshSurfaceProbe {
373        host: opts.host.clone(),
374        status: status.into(),
375        key_ok: false,
376    })
377}
378
379fn probe_api_row(
380    runner: &dyn ProcessRunner,
381    opts: &RemoteOptions,
382    host: &str,
383    embedded: bool,
384    ssh: &SshSurfaceProbe,
385    token: Option<&str>,
386) -> ApiSurfaceProbe {
387    let url = format!("http://{host}:{STATUS_API_PORT}");
388    let health = http_get_label(host, STATUS_API_PORT, "/health", None);
389    let status = if health == "ok" {
390        http_get_label(host, STATUS_API_PORT, "/v1/status", token)
391    } else {
392        "skipped".into()
393    };
394    let unit = if !embedded && (ssh.status == "ok" || ssh.key_ok) {
395        ssh_systemctl_active(runner, opts, "horto-os-ui-status-api.service")
396    } else {
397        String::new()
398    };
399    ApiSurfaceProbe {
400        url,
401        health,
402        status,
403        local_token: token.is_some(),
404        unit,
405    }
406}
407
408fn probe_mcp_pc(runner: &dyn ProcessRunner, host: &str, bin_dir: Option<&Path>) -> McpHostProbe {
409    let http_url = format!("http://{host}:{MCP_HTTP_PORT}");
410    McpHostProbe {
411        docker: find_mcp_docker(runner),
412        binary: find_mcp_binary(bin_dir),
413        http_url,
414        http_reach: http_get_label(host, MCP_HTTP_PORT, "/", None),
415        unit: local_pgrep_mcp(runner),
416        api_health: http_get_label(host, STATUS_API_PORT, "/health", None),
417    }
418}
419
420fn probe_mcp_box(
421    runner: &dyn ProcessRunner,
422    opts: &RemoteOptions,
423    host: &str,
424    embedded: bool,
425    ssh: &SshSurfaceProbe,
426) -> McpHostProbe {
427    let http_url = format!("http://{host}:{MCP_HTTP_PORT}");
428    let http_reach = http_get_label(host, MCP_HTTP_PORT, "/", None);
429    let api_health = http_get_label(host, STATUS_API_PORT, "/health", None);
430    let ssh_ok = !embedded && (ssh.status == "ok" || ssh.key_ok);
431    let (docker, binary, unit) = if embedded {
432        (
433            find_mcp_docker(runner),
434            find_mcp_binary(opts.bin_dir.as_deref()),
435            local_pgrep_mcp(runner),
436        )
437    } else if ssh_ok {
438        let unit = {
439            let active = ssh_systemctl_active(runner, opts, "horto-os-ui-mcp.service");
440            if active.is_empty() || active == "inactive" || active == "unknown" {
441                ssh_pgrep_mcp(runner, opts)
442            } else {
443                active
444            }
445        };
446        let binary = ssh_mcp_binary(runner, opts);
447        let docker = ssh_mcp_docker(runner, opts);
448        (docker, binary, unit)
449    } else {
450        (None, None, String::new())
451    };
452    McpHostProbe {
453        docker,
454        binary,
455        http_url,
456        http_reach,
457        unit,
458        api_health,
459    }
460}
461
462fn mcp_image_name() -> String {
463    std::env::var("HORTO_MCP_IMAGE")
464        .ok()
465        .filter(|s| !s.trim().is_empty())
466        .unwrap_or_else(|| DEFAULT_MCP_IMAGE.to_owned())
467}
468
469fn find_mcp_docker(runner: &dyn ProcessRunner) -> Option<String> {
470    let image = mcp_image_name();
471    match runner.run(
472        "docker",
473        &["image", "inspect", "--format", "{{.Id}}", image.as_str()],
474        &[],
475        StdioMode::Capture,
476    ) {
477        Ok(out) if out.success() && !out.stdout.trim().is_empty() => Some(image),
478        _ => None,
479    }
480}
481
482fn find_mcp_binary(bin_dir: Option<&Path>) -> Option<String> {
483    if let Some(dir) = bin_dir {
484        let p = dir.join("horto-os-ui-mcp");
485        if p.is_file() {
486            return Some(p.display().to_string());
487        }
488    }
489    which::which("horto-os-ui-mcp")
490        .ok()
491        .map(|p| p.display().to_string())
492}
493
494fn local_pgrep_mcp(runner: &dyn ProcessRunner) -> String {
495    match runner.run("pgrep", &["-a", "horto-os-ui-mcp"], &[], StdioMode::Capture) {
496        Ok(out) if out.success() && !out.stdout.trim().is_empty() => "process".into(),
497        _ => String::new(),
498    }
499}
500
501fn ssh_mcp_binary(runner: &dyn ProcessRunner, opts: &RemoteOptions) -> Option<String> {
502    let Ok(session) = session_from(opts) else {
503        return None;
504    };
505    let cmd = "command -v horto-os-ui-mcp 2>/dev/null || true";
506    match session.exec(runner, cmd, StdioMode::Capture) {
507        Ok(out) => {
508            let path = out.stdout.lines().next().unwrap_or("").trim();
509            if path.is_empty() {
510                None
511            } else {
512                Some(path.to_owned())
513            }
514        }
515        Err(_) => None,
516    }
517}
518
519fn ssh_mcp_docker(runner: &dyn ProcessRunner, opts: &RemoteOptions) -> Option<String> {
520    let Ok(session) = session_from(opts) else {
521        return None;
522    };
523    let image = mcp_image_name();
524    let quoted = image.replace('\'', "'\\''");
525    let cmd = format!("docker image inspect --format '{{{{.Id}}}}' '{quoted}' 2>/dev/null || true");
526    match session.exec(runner, &cmd, StdioMode::Capture) {
527        Ok(out) if !out.stdout.trim().is_empty() => Some(image),
528        _ => None,
529    }
530}
531
532fn ssh_systemctl_active(runner: &dyn ProcessRunner, opts: &RemoteOptions, unit: &str) -> String {
533    let Ok(session) = session_from(opts) else {
534        return String::new();
535    };
536    let cmd = format!("systemctl is-active {unit} 2>/dev/null || true");
537    match session.exec(runner, &cmd, StdioMode::Capture) {
538        Ok(out) => out
539            .stdout
540            .lines()
541            .next()
542            .unwrap_or("unknown")
543            .trim()
544            .to_owned(),
545        Err(_) => String::new(),
546    }
547}
548
549fn ssh_pgrep_mcp(runner: &dyn ProcessRunner, opts: &RemoteOptions) -> String {
550    let Ok(session) = session_from(opts) else {
551        return String::new();
552    };
553    let cmd = "pgrep -a horto-os-ui-mcp 2>/dev/null | head -1 || true";
554    match session.exec(runner, cmd, StdioMode::Capture) {
555        Ok(out) if !out.stdout.trim().is_empty() => "process".into(),
556        _ => "inactive".into(),
557    }
558}
559
560/// Minimal HTTP/1.0 GET label for probes (no extra HTTP crate).
561#[must_use]
562pub fn http_get_label(host: &str, port: u16, path: &str, bearer: Option<&str>) -> String {
563    let addr = format!("{host}:{port}");
564    tcp_http_get(&addr, host, path, bearer)
565}
566
567fn tcp_http_get(addr: &str, host: &str, path: &str, bearer: Option<&str>) -> String {
568    let Ok(mut addrs) = std::net::ToSocketAddrs::to_socket_addrs(addr) else {
569        return "unreachable".into();
570    };
571    let Some(sock) = addrs.next() else {
572        return "unreachable".into();
573    };
574    let Ok(mut stream) = TcpStream::connect_timeout(&sock, HTTP_TIMEOUT) else {
575        return "unreachable".into();
576    };
577    finish_http_get(&mut stream, host, path, bearer)
578}
579
580fn finish_http_get(stream: &mut TcpStream, host: &str, path: &str, bearer: Option<&str>) -> String {
581    let _ = stream.set_read_timeout(Some(HTTP_TIMEOUT));
582    let _ = stream.set_write_timeout(Some(HTTP_TIMEOUT));
583    let auth = bearer
584        .map(|t| format!("Authorization: Bearer {t}\r\n"))
585        .unwrap_or_default();
586    let req = format!("GET {path} HTTP/1.0\r\nHost: {host}\r\nConnection: close\r\n{auth}\r\n");
587    if stream.write_all(req.as_bytes()).is_err() {
588        return "unreachable".into();
589    }
590    let mut buf = Vec::new();
591    let _ = stream.read_to_end(&mut buf);
592    let text = String::from_utf8_lossy(&buf);
593    let status_line = text.lines().next().unwrap_or("");
594    let code = status_line
595        .split_whitespace()
596        .nth(1)
597        .and_then(|c| c.parse::<u16>().ok());
598    match code {
599        Some(200) => "ok".into(),
600        Some(401 | 403) => "auth required".into(),
601        Some(c) => format!("http {c}"),
602        None if text.is_empty() => "unreachable".into(),
603        None => "ok".into(),
604    }
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610    use crate::remote::process::CommandOutput;
611    use crate::remote::process::ScriptedRunner;
612    use std::io::Write;
613    use std::net::TcpListener;
614    use std::path::PathBuf;
615    use std::thread;
616
617    fn with_xdg_config<R>(tmp: &tempfile::TempDir, f: impl FnOnce() -> R) -> R {
618        let _guard = crate::remote::ENV_LOCK
619            .lock()
620            .unwrap_or_else(std::sync::PoisonError::into_inner);
621        let prev = std::env::var("XDG_CONFIG_HOME").ok();
622        // SAFETY: serialized by ENV_LOCK for remote tests.
623        unsafe {
624            std::env::set_var("XDG_CONFIG_HOME", tmp.path());
625        }
626        let out = f();
627        unsafe {
628            if let Some(v) = prev {
629                std::env::set_var("XDG_CONFIG_HOME", v);
630            } else {
631                std::env::remove_var("XDG_CONFIG_HOME");
632            }
633        }
634        out
635    }
636
637    #[test]
638    fn with_xdg_config_restores_absent_var() {
639        let tmp = tempfile::TempDir::new().unwrap();
640        let guard = crate::remote::ENV_LOCK
641            .lock()
642            .unwrap_or_else(std::sync::PoisonError::into_inner);
643        unsafe {
644            std::env::remove_var("XDG_CONFIG_HOME");
645        }
646        drop(guard);
647        with_xdg_config(&tmp, || {
648            assert!(std::env::var("XDG_CONFIG_HOME").is_ok());
649        });
650        assert!(std::env::var("XDG_CONFIG_HOME").is_err());
651    }
652
653    #[test]
654    fn format_surfaces_report_includes_local_and_rows() {
655        let report = SurfaceProbeReport {
656            local_version: "0.1.0 (deadbeef)".into(),
657            ssh: SshSurfaceProbe {
658                host: "horto".into(),
659                status: "ok".into(),
660                key_ok: true,
661            },
662            cli: CliSurfaceProbe {
663                status: RemoteBoxCliStatus::Found("0.1.0 (deadbeef)".into()),
664                version: Some("0.1.0 (deadbeef)".into()),
665                current: true,
666            },
667            api: ApiSurfaceProbe {
668                url: "http://horto:8787".into(),
669                health: "ok".into(),
670                status: "ok".into(),
671                local_token: true,
672                unit: "active".into(),
673            },
674            mcp_pc: McpHostProbe {
675                docker: Some("horto-os-ui-mcp:local".into()),
676                binary: Some("/usr/bin/horto-os-ui-mcp".into()),
677                http_url: "http://horto:8790".into(),
678                http_reach: "unreachable".into(),
679                unit: String::new(),
680                api_health: "ok".into(),
681            },
682            mcp_box: McpHostProbe {
683                docker: None,
684                binary: None,
685                http_url: "http://horto:8790".into(),
686                http_reach: "unreachable".into(),
687                unit: "inactive".into(),
688                api_health: "ok".into(),
689            },
690        };
691        let text = format_surfaces_report(&report);
692        assert!(text.contains("local=0.1.0 (deadbeef)"));
693        assert!(text.contains("ssh=ok"));
694        assert!(text.contains("cli=0.1.0 (deadbeef)"));
695        assert!(text.contains("api=http://horto:8787"));
696        assert!(text.contains("mcp_pc"));
697        assert!(text.contains("mcp_box"));
698        assert!(text.contains("docker=horto-os-ui-mcp:local"));
699    }
700
701    #[test]
702    fn probe_ssh_access_ok_and_auth_failed() {
703        let runner = ScriptedRunner::default();
704        runner.push("ssh", ScriptedRunner::ok(""));
705        let opts = RemoteOptions {
706            host: "box".into(),
707            ..RemoteOptions::default()
708        };
709        let p = probe_ssh_access(&runner, &opts).unwrap();
710        assert_eq!(p.status, "ok");
711        assert!(p.key_ok);
712
713        let runner2 = ScriptedRunner::default();
714        runner2.push("ssh", ScriptedRunner::fail(255, "Permission denied"));
715        let p2 = probe_ssh_access(&runner2, &opts).unwrap();
716        assert_eq!(p2.status, "auth failed");
717        assert!(!p2.key_ok);
718    }
719
720    #[test]
721    fn probe_ssh_access_unreachable() {
722        let runner = ScriptedRunner::default();
723        runner.push("ssh", ScriptedRunner::fail(255, "Connection refused"));
724        let opts = RemoteOptions {
725            host: "box".into(),
726            ..RemoteOptions::default()
727        };
728        let p = probe_ssh_access(&runner, &opts).unwrap();
729        assert_eq!(p.status, "unreachable");
730    }
731
732    #[test]
733    fn probe_surfaces_embedded_skips_ssh() {
734        let runner = ScriptedRunner::default();
735        let opts = RemoteOptions::default();
736        let report = probe_surfaces(&runner, &opts, true).unwrap();
737        assert_eq!(report.ssh.status, "n/a");
738        assert!(report.cli.current);
739        assert!(report.mcp_pc.http_url.starts_with("http://127.0.0.1:8790"));
740        assert!(report.mcp_box.http_url.starts_with("http://127.0.0.1:8790"));
741    }
742
743    #[test]
744    fn probe_surfaces_remote_auth_maps_cli() {
745        let runner = ScriptedRunner::default();
746        runner.push("ssh", ScriptedRunner::fail(255, "Permission denied"));
747        let opts = RemoteOptions {
748            host: "box".into(),
749            ..RemoteOptions::default()
750        };
751        let report = probe_surfaces(&runner, &opts, false).unwrap();
752        assert_eq!(report.ssh.status, "auth failed");
753        assert_eq!(report.cli.status, RemoteBoxCliStatus::AuthFailed);
754    }
755
756    #[test]
757    fn http_get_label_ok_from_local_listener() {
758        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
759        let port = listener.local_addr().unwrap().port();
760        thread::spawn(move || {
761            let (mut s, _) = listener.accept().unwrap();
762            let mut buf = [0u8; 512];
763            let _ = s.read(&mut buf);
764            let _ = s.write_all(b"HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nok");
765        });
766        assert_eq!(http_get_label("127.0.0.1", port, "/health", None), "ok");
767    }
768
769    #[test]
770    fn http_get_label_auth_required() {
771        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
772        let port = listener.local_addr().unwrap().port();
773        thread::spawn(move || {
774            let (mut s, _) = listener.accept().unwrap();
775            let mut buf = [0u8; 512];
776            let _ = s.read(&mut buf);
777            let _ = s.write_all(b"HTTP/1.0 401 Unauthorized\r\n\r\n");
778        });
779        assert_eq!(
780            http_get_label("127.0.0.1", port, "/v1/status", Some("tok")),
781            "auth required"
782        );
783    }
784
785    #[test]
786    fn http_get_label_unreachable_port() {
787        assert_eq!(
788            http_get_label("127.0.0.1", 1, "/health", None),
789            "unreachable"
790        );
791    }
792
793    #[test]
794    fn read_local_api_token_roundtrip() {
795        let tmp = tempfile::TempDir::new().unwrap();
796        with_xdg_config(&tmp, || {
797            let path = api_token_config_path();
798            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
799            std::fs::write(&path, b"aabbccddeeff00112233445566778899aabbccdd\n").unwrap();
800            assert_eq!(
801                read_local_api_token().as_deref(),
802                Some("aabbccddeeff00112233445566778899aabbccdd")
803            );
804        });
805    }
806
807    #[test]
808    fn find_mcp_binary_in_bin_dir() {
809        let tmp = tempfile::TempDir::new().unwrap();
810        let bin = tmp.path().join("horto-os-ui-mcp");
811        std::fs::write(&bin, b"#!/bin/true\n").unwrap();
812        assert_eq!(
813            find_mcp_binary(Some(tmp.path())).as_deref(),
814            Some(bin.to_str().unwrap())
815        );
816        assert!(find_mcp_binary(Some(tmp.path().join("nope").as_path())).is_none());
817    }
818
819    #[test]
820    fn probe_surfaces_remote_ok_runs_cli_probe() {
821        let runner = ScriptedRunner::default();
822        // ssh BatchMode true
823        runner.push("ssh", ScriptedRunner::ok(""));
824        // probe_remote_cli: install then agent
825        runner.push("ssh", ScriptedRunner::ok(&format!("{LONG_VERSION}\n")));
826        // api unit
827        runner.push("ssh", ScriptedRunner::ok("active\n"));
828        // mcp unit
829        runner.push("ssh", ScriptedRunner::ok("inactive\n"));
830        // mcp pgrep
831        runner.push("ssh", ScriptedRunner::ok(""));
832        // mcp binary
833        runner.push("ssh", ScriptedRunner::ok(""));
834        // mcp docker
835        runner.push("ssh", ScriptedRunner::ok(""));
836        let opts = RemoteOptions {
837            host: "box".into(),
838            ..RemoteOptions::default()
839        };
840        let report = probe_surfaces(&runner, &opts, false).unwrap();
841        assert_eq!(report.ssh.status, "ok");
842        assert!(report.cli.current);
843        assert_eq!(report.api.unit, "active");
844    }
845
846    #[test]
847    fn surface_probe_report_json_roundtrip() {
848        let report = SurfaceProbeReport {
849            local_version: "0.1.0".into(),
850            ssh: SshSurfaceProbe {
851                host: "h".into(),
852                status: "ok".into(),
853                key_ok: true,
854            },
855            cli: CliSurfaceProbe {
856                status: RemoteBoxCliStatus::Missing,
857                version: None,
858                current: false,
859            },
860            api: ApiSurfaceProbe {
861                url: "http://h:8787".into(),
862                health: "unreachable".into(),
863                status: "skipped".into(),
864                local_token: false,
865                unit: String::new(),
866            },
867            mcp_pc: McpHostProbe {
868                docker: None,
869                binary: None,
870                http_url: "http://h:8790".into(),
871                http_reach: "unreachable".into(),
872                unit: String::new(),
873                api_health: "unreachable".into(),
874            },
875            mcp_box: McpHostProbe {
876                docker: None,
877                binary: None,
878                http_url: "http://h:8790".into(),
879                http_reach: "unreachable".into(),
880                unit: String::new(),
881                api_health: "unreachable".into(),
882            },
883        };
884        let json = serde_json::to_string(&report).unwrap();
885        let back: SurfaceProbeReport = serde_json::from_str(&json).unwrap();
886        assert_eq!(back, report);
887        let text = format_surfaces_report(&report);
888        assert!(text.contains("unit=-"));
889        assert!(text.contains("binary=missing"));
890        assert!(text.contains("docker=missing"));
891    }
892
893    #[test]
894    fn http_get_label_other_status_code() {
895        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
896        let port = listener.local_addr().unwrap().port();
897        thread::spawn(move || {
898            let (mut s, _) = listener.accept().unwrap();
899            let mut buf = [0u8; 512];
900            let _ = s.read(&mut buf);
901            let _ = s.write_all(b"HTTP/1.0 503 Service Unavailable\r\n\r\n");
902        });
903        assert_eq!(
904            http_get_label("127.0.0.1", port, "/health", None),
905            "http 503"
906        );
907    }
908
909    #[test]
910    fn local_api_token_path_is_under_config() {
911        let p = local_api_token_path();
912        assert!(p.to_string_lossy().contains("horto-os-ui"));
913    }
914
915    #[test]
916    fn probe_surfaces_remote_ok_mcp_process_fallback() {
917        let runner = ScriptedRunner::default();
918        runner.push("ssh", ScriptedRunner::ok(""));
919        runner.push("ssh", ScriptedRunner::ok(&format!("{LONG_VERSION}\n")));
920        runner.push("ssh", ScriptedRunner::ok("active\n"));
921        runner.push("ssh", ScriptedRunner::ok("inactive\n"));
922        runner.push("ssh", ScriptedRunner::ok("1234 horto-os-ui-mcp\n"));
923        runner.push(
924            "ssh",
925            ScriptedRunner::ok("/usr/local/bin/horto-os-ui-mcp\n"),
926        );
927        runner.push("ssh", ScriptedRunner::ok("sha256:deadbeef\n"));
928        let opts = RemoteOptions {
929            host: "box".into(),
930            ..RemoteOptions::default()
931        };
932        let report = probe_surfaces(&runner, &opts, false).unwrap();
933        assert_eq!(report.mcp_box.unit, "process");
934        assert_eq!(
935            report.mcp_box.binary.as_deref(),
936            Some("/usr/local/bin/horto-os-ui-mcp")
937        );
938        assert_eq!(
939            report.mcp_box.docker.as_deref(),
940            Some("horto-os-ui-mcp:local")
941        );
942    }
943
944    #[test]
945    fn probe_surfaces_remote_unreachable_maps_cli() {
946        let runner = ScriptedRunner::default();
947        runner.push("ssh", ScriptedRunner::fail(255, "Connection refused"));
948        let opts = RemoteOptions {
949            host: "box".into(),
950            ..RemoteOptions::default()
951        };
952        let report = probe_surfaces(&runner, &opts, false).unwrap();
953        assert_eq!(report.ssh.status, "unreachable");
954        assert_eq!(report.cli.status, RemoteBoxCliStatus::Unreachable);
955    }
956
957    #[test]
958    fn probe_ssh_access_uses_config_file_flag() {
959        let runner = ScriptedRunner::default();
960        runner.push("ssh", ScriptedRunner::ok(""));
961        let opts = RemoteOptions {
962            host: "box".into(),
963            ssh_config_file: Some(PathBuf::from("/tmp/horto-test-ssh-config")),
964            ..RemoteOptions::default()
965        };
966        let p = probe_ssh_access(&runner, &opts).unwrap();
967        assert_eq!(p.status, "ok");
968        let calls = runner.calls.lock().unwrap();
969        assert!(calls[0].1.iter().any(|a| a == "-F"));
970        assert!(calls[0]
971            .1
972            .iter()
973            .any(|a| a.contains("horto-test-ssh-config")));
974        drop(calls);
975    }
976
977    #[test]
978    fn probe_ssh_access_prefers_stdout_when_stderr_empty() {
979        let runner = ScriptedRunner::default();
980        runner.push(
981            "ssh",
982            CommandOutput {
983                status: 255,
984                stdout: "Permission denied (publickey)".into(),
985                stderr: String::new(),
986            },
987        );
988        let opts = RemoteOptions {
989            host: "box".into(),
990            ..RemoteOptions::default()
991        };
992        let p = probe_ssh_access(&runner, &opts).unwrap();
993        assert_eq!(p.status, "auth failed");
994    }
995
996    #[test]
997    fn read_local_api_token_rejects_non_hex() {
998        let tmp = tempfile::TempDir::new().unwrap();
999        with_xdg_config(&tmp, || {
1000            let path = api_token_config_path();
1001            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1002            std::fs::write(&path, b"HORTO_API_TOKEN=not-hex!!\n").unwrap();
1003            assert!(read_local_api_token().is_none());
1004            std::fs::write(&path, b"HORTO_API_TOKEN=\n").unwrap();
1005            assert!(read_local_api_token().is_none());
1006            // Short leftovers (e.g. test junk) must not be treated as tip tokens.
1007            std::fs::write(&path, b"11223344\n").unwrap();
1008            assert!(read_local_api_token().is_none());
1009        });
1010    }
1011
1012    #[test]
1013    fn probe_mcp_box_keeps_active_unit() {
1014        let runner = ScriptedRunner::default();
1015        runner.push("ssh", ScriptedRunner::ok(""));
1016        runner.push("ssh", ScriptedRunner::ok(&format!("{LONG_VERSION}\n")));
1017        runner.push("ssh", ScriptedRunner::ok("active\n"));
1018        runner.push("ssh", ScriptedRunner::ok("active\n"));
1019        runner.push("ssh", ScriptedRunner::ok(""));
1020        runner.push("ssh", ScriptedRunner::ok(""));
1021        let opts = RemoteOptions {
1022            host: "box".into(),
1023            ..RemoteOptions::default()
1024        };
1025        let report = probe_surfaces(&runner, &opts, false).unwrap();
1026        assert_eq!(report.mcp_box.unit, "active");
1027    }
1028
1029    #[test]
1030    fn ssh_helpers_handle_bad_host_and_exec_err() {
1031        let runner = ScriptedRunner::default();
1032        let bad = RemoteOptions {
1033            host: String::new(),
1034            ..RemoteOptions::default()
1035        };
1036        assert_eq!(ssh_systemctl_active(&runner, &bad, "x.service"), "");
1037        assert_eq!(ssh_pgrep_mcp(&runner, &bad), "");
1038
1039        let runner2 = ScriptedRunner::default();
1040        runner2.push("ssh", ScriptedRunner::fail(1, "nope"));
1041        let ok_host = RemoteOptions {
1042            host: "box".into(),
1043            ..RemoteOptions::default()
1044        };
1045        assert_eq!(ssh_systemctl_active(&runner2, &ok_host, "x.service"), "");
1046    }
1047
1048    #[test]
1049    fn http_get_label_empty_and_garbage_body() {
1050        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1051        let port = listener.local_addr().unwrap().port();
1052        thread::spawn(move || {
1053            let (s, _) = listener.accept().unwrap();
1054            drop(s);
1055        });
1056        assert_eq!(
1057            http_get_label("127.0.0.1", port, "/health", None),
1058            "unreachable"
1059        );
1060
1061        let listener2 = TcpListener::bind("127.0.0.1:0").unwrap();
1062        let port2 = listener2.local_addr().unwrap().port();
1063        thread::spawn(move || {
1064            let (mut s, _) = listener2.accept().unwrap();
1065            let mut buf = [0u8; 64];
1066            let _ = s.read(&mut buf);
1067            let _ = s.write_all(b"not-http-at-all");
1068        });
1069        assert_eq!(http_get_label("127.0.0.1", port2, "/health", None), "ok");
1070    }
1071
1072    #[test]
1073    fn probe_api_status_when_health_ok_on_8787() {
1074        // Serialize :8787 binds across parallel surfaces tests.
1075        static PORT_8787: std::sync::Mutex<()> = std::sync::Mutex::new(());
1076        let _guard = PORT_8787
1077            .lock()
1078            .unwrap_or_else(std::sync::PoisonError::into_inner);
1079        let listener = match TcpListener::bind("127.0.0.1:8787") {
1080            Ok(l) => l,
1081            Err(e) => {
1082                eprintln!("skip probe_api :8787 bind: {e}");
1083                return;
1084            }
1085        };
1086        thread::spawn(move || {
1087            for _ in 0..8 {
1088                if let Ok((mut s, _)) = listener.accept() {
1089                    let mut buf = [0u8; 512];
1090                    let _ = s.read(&mut buf);
1091                    let _ = s.write_all(b"HTTP/1.0 200 OK\r\n\r\nok");
1092                }
1093            }
1094        });
1095        thread::sleep(std::time::Duration::from_millis(20));
1096        let runner = ScriptedRunner::default();
1097        let opts = RemoteOptions::default();
1098        let report = probe_surfaces(&runner, &opts, true).unwrap();
1099        assert_eq!(report.api.health, "ok");
1100        assert_eq!(report.api.status, "ok");
1101    }
1102
1103    #[test]
1104    fn probe_one_surface_helpers_embedded() {
1105        let runner = ScriptedRunner::default();
1106        let opts = RemoteOptions::default();
1107        let ssh = probe_ssh_surface(&runner, &opts, true).unwrap();
1108        assert_eq!(ssh.status, "n/a");
1109        let cli = probe_cli_surface(&runner, &opts, true).unwrap();
1110        assert!(cli.current);
1111        // API/MCP hit localhost ports; only assert shape (no bind race with :8787 tests).
1112        let api = probe_api_surface(&runner, &opts, true).unwrap();
1113        assert!(api.url.starts_with("http://127.0.0.1:8787"));
1114        let (pc, bx) = probe_mcp_surface(&runner, &opts, true).unwrap();
1115        assert!(pc.http_url.starts_with("http://127.0.0.1:8790"));
1116        assert!(bx.http_url.starts_with("http://127.0.0.1:8790"));
1117    }
1118
1119    #[test]
1120    fn find_mcp_docker_reports_image_when_inspect_ok() {
1121        let runner = ScriptedRunner::default();
1122        runner.push("docker", ScriptedRunner::ok("sha256:abc\n"));
1123        assert_eq!(
1124            find_mcp_docker(&runner).as_deref(),
1125            Some("horto-os-ui-mcp:local")
1126        );
1127    }
1128
1129    #[test]
1130    fn find_mcp_docker_none_when_missing() {
1131        let runner = ScriptedRunner::default();
1132        runner.push("docker", ScriptedRunner::fail(1, "No such image"));
1133        assert!(find_mcp_docker(&runner).is_none());
1134    }
1135
1136    #[test]
1137    fn probe_one_surface_helpers_remote() {
1138        let runner = ScriptedRunner::default();
1139        // probe_ssh_surface → BatchMode true
1140        runner.push("ssh", ScriptedRunner::ok(""));
1141        // probe_cli_surface → ssh again + remote version (first candidate)
1142        runner.push("ssh", ScriptedRunner::ok(""));
1143        runner.push("ssh", ScriptedRunner::ok(&format!("{LONG_VERSION}\n")));
1144        // probe_api_surface → ssh + status-api unit
1145        runner.push("ssh", ScriptedRunner::ok(""));
1146        runner.push("ssh", ScriptedRunner::ok("active\n"));
1147        // probe_mcp_surface → ssh + mcp unit/pgrep + binary + docker
1148        runner.push("ssh", ScriptedRunner::ok(""));
1149        runner.push("ssh", ScriptedRunner::ok("inactive\n"));
1150        runner.push("ssh", ScriptedRunner::ok(""));
1151        runner.push("ssh", ScriptedRunner::ok(""));
1152        runner.push("ssh", ScriptedRunner::ok(""));
1153        let opts = RemoteOptions {
1154            host: "box.example".into(),
1155            ..RemoteOptions::default()
1156        };
1157        let ssh = probe_ssh_surface(&runner, &opts, false).unwrap();
1158        assert_eq!(ssh.status, "ok");
1159        let cli = probe_cli_surface(&runner, &opts, false).unwrap();
1160        assert!(cli.current);
1161        let api = probe_api_surface(&runner, &opts, false).unwrap();
1162        assert!(api.url.contains("box.example"));
1163        let (pc, bx) = probe_mcp_surface(&runner, &opts, false).unwrap();
1164        assert!(pc.http_url.contains("box.example"));
1165        assert!(bx.http_url.contains("box.example"));
1166    }
1167}