1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct SurfaceProbeReport {
26 pub local_version: String,
28 pub ssh: SshSurfaceProbe,
30 pub cli: CliSurfaceProbe,
32 pub api: ApiSurfaceProbe,
34 pub mcp_pc: McpHostProbe,
36 pub mcp_box: McpHostProbe,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct SshSurfaceProbe {
43 pub host: String,
45 pub status: String,
47 pub key_ok: bool,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct CliSurfaceProbe {
54 pub status: RemoteBoxCliStatus,
56 pub version: Option<String>,
58 pub current: bool,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ApiSurfaceProbe {
65 pub url: String,
67 pub health: String,
69 pub status: String,
71 pub local_token: bool,
73 pub unit: String,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct McpHostProbe {
80 pub docker: Option<String>,
82 pub binary: Option<String>,
84 pub http_url: String,
86 pub http_reach: String,
88 pub unit: String,
90 pub api_health: String,
92}
93
94const DEFAULT_MCP_IMAGE: &str = "horto-os-ui-mcp:local";
96
97#[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#[must_use]
111pub fn local_api_token_path() -> PathBuf {
112 api_token_config_path()
113}
114
115pub 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
157pub 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
177pub 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
191pub 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
218pub 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#[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
323pub 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#[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 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 runner.push("ssh", ScriptedRunner::ok(""));
824 runner.push("ssh", ScriptedRunner::ok(&format!("{LONG_VERSION}\n")));
826 runner.push("ssh", ScriptedRunner::ok("active\n"));
828 runner.push("ssh", ScriptedRunner::ok("inactive\n"));
830 runner.push("ssh", ScriptedRunner::ok(""));
832 runner.push("ssh", ScriptedRunner::ok(""));
834 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 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 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 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 runner.push("ssh", ScriptedRunner::ok(""));
1141 runner.push("ssh", ScriptedRunner::ok(""));
1143 runner.push("ssh", ScriptedRunner::ok(&format!("{LONG_VERSION}\n")));
1144 runner.push("ssh", ScriptedRunner::ok(""));
1146 runner.push("ssh", ScriptedRunner::ok("active\n"));
1147 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}