Skip to main content

horto_os_ui_shared/remote/runner/
mod.rs

1//! High-level remote apply: probe arch, drop CLI agent, run setup, install payload.
2
3mod apply;
4mod options;
5mod probe;
6mod reboot;
7mod status;
8mod token;
9
10pub use apply::{
11    remote_ensure_ssh_key, remote_install_payload, remote_run_cli, remote_setup_run,
12    remote_upload_cli, RemoteRunOutcome,
13};
14pub use options::{
15    remote_doctor_report_banner, remote_progress_message, remote_run_banner_detail, session_from,
16    RemoteOptions, RemoteOptionsInput, RemoteRunFlags, RemoteRunRequest, DEFAULT_GITHUB_REPO,
17    DEFAULT_INSTALL_DIR, DEFAULT_REMOTE_AGENT_DIR,
18};
19pub use probe::{
20    classify_ssh_failure, normalize_cli_version, probe_remote_cli, remote_box_snapshot,
21    remote_cli_version_is_current, remote_probe_arch, RemoteBoxCliStatus, RemoteBoxSnapshot,
22    RemoteCliProbe,
23};
24pub use reboot::{remote_reboot, remote_reboot_with_sudo_password, wants_reboot_now};
25pub use status::{parse_remote_json, remote_doctor, remote_setup_status};
26pub use token::api_token_config_path;
27pub use token::{
28    finish_save_api_token, offer_save_api_token, parse_api_token_drop, pull_remote_api_token,
29    usable_api_token_hex, write_api_token_file, MIN_API_TOKEN_HEX_LEN,
30};
31
32#[cfg(test)]
33mod tests {
34    use super::super::arch::BoxArch;
35    use super::super::bins::LocalBins;
36    use super::super::ecosystem::EcosystemInstallChoice;
37    use super::super::ssh::SshSession;
38    use super::options::{build_remote_command_at, remote_progress, shell_quote};
39    use super::probe::ssh_command_detail;
40    use super::reboot::finish_remote_reboot;
41    use super::status::parse_setup_status_text;
42    use super::token::offer_save_api_token_with;
43    use super::*;
44    use crate::remote::process::{CommandOutput, ScriptedRunner};
45    use crate::LONG_VERSION;
46    use std::fs;
47    use tempfile::TempDir;
48
49    /// Env vars for config path are process-global; serialize tests that mutate them.
50    use crate::remote::ENV_LOCK as CONFIG_ENV_LOCK;
51
52    /// Restore `HOME` / `XDG_CONFIG_HOME` even if a test panics mid-write.
53    struct EnvVarGuard {
54        key: &'static str,
55        prev: Option<std::ffi::OsString>,
56    }
57
58    impl EnvVarGuard {
59        fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
60            let prev = std::env::var_os(key);
61            // SAFETY: callers hold CONFIG_ENV_LOCK for the duration of this guard.
62            unsafe {
63                std::env::set_var(key, value);
64            }
65            Self { key, prev }
66        }
67
68        fn remove(key: &'static str) -> Self {
69            let prev = std::env::var_os(key);
70            unsafe {
71                std::env::remove_var(key);
72            }
73            Self { key, prev }
74        }
75    }
76
77    impl Drop for EnvVarGuard {
78        fn drop(&mut self) {
79            unsafe {
80                match &self.prev {
81                    Some(v) => std::env::set_var(self.key, v),
82                    None => std::env::remove_var(self.key),
83                }
84            }
85        }
86    }
87
88    fn bin_dir_with_stubs() -> TempDir {
89        let tmp = TempDir::new().unwrap();
90        for name in [
91            "horto-os-ui",
92            "horto-os-ui-tui",
93            "horto-os-ui-status-api",
94            "horto-os-ui-mcp",
95        ] {
96            fs::write(tmp.path().join(name), b"#!/bin/true\n").unwrap();
97        }
98        tmp
99    }
100
101    #[test]
102    fn shell_quote_safe_and_unsafe() {
103        assert_eq!(shell_quote(""), "''");
104        assert_eq!(shell_quote("setup"), "setup");
105        assert_eq!(shell_quote("a b"), "'a b'");
106        assert_eq!(shell_quote("a'b"), "'a'\\''b'");
107    }
108
109    #[test]
110    fn remote_progress_message_labels_host_and_detail() {
111        assert_eq!(
112            remote_progress_message("horto", "probe arch (SSH; may ask password)"),
113            "[horto remote] PC → box 'horto': probe arch (SSH; may ask password)"
114        );
115    }
116
117    #[test]
118    fn parse_setup_status_text_reads_cli_lines() {
119        let raw = "\
120Setup kind: full
121  [  done] s1 - Install base packages (v1)
122  [pending] s5 - Apply staged configs to /etc (v2) [destructive] [reboot]
123";
124        let report = parse_setup_status_text(raw).unwrap();
125        assert_eq!(report.kind, "full");
126        assert_eq!(report.steps.len(), 2);
127        assert_eq!(report.steps[0].id, "s1");
128        assert_eq!(report.steps[0].status, "done");
129        assert_eq!(report.steps[1].id, "s5");
130        assert!(report.steps[1].destructive);
131        assert!(report.steps[1].needs_reboot_after);
132    }
133
134    #[test]
135    fn parse_setup_status_text_skips_noise_and_allows_title_without_version() {
136        let raw = "\
137noise before
138Setup kind: minimal
139[broken
140[x] no dash here
141  [  done] s2 - Title only
142";
143        let report = parse_setup_status_text(raw).unwrap();
144        assert_eq!(report.kind, "minimal");
145        assert_eq!(report.steps.len(), 1);
146        assert_eq!(report.steps[0].id, "s2");
147        assert_eq!(report.steps[0].title, "Title only");
148        assert_eq!(report.steps[0].step_version, 0);
149    }
150
151    #[test]
152    fn parse_setup_status_text_errors_without_steps() {
153        let err = parse_setup_status_text("Setup kind: full\njust noise\n").unwrap_err();
154        assert!(err.to_string().contains("no step lines"));
155    }
156
157    #[test]
158    fn parse_remote_json_skips_leading_noise() {
159        let raw = "ssh warn\n{\"is_root\":false,\"has_sudo\":true,\"docker_present\":true,\"active_setup_dir\":true,\"full_env\":true,\"minimal_env\":false,\"docker_dir\":true,\"backup_dir\":true,\"notes\":[]}\n";
160        let doc: crate::ops::doctor::DoctorReport = parse_remote_json(raw).unwrap();
161        assert!(doc.has_sudo);
162        assert!(doc.docker_present);
163    }
164
165    #[test]
166    fn remote_run_banner_detail_marks_sudo() {
167        assert!(remote_run_banner_detail("horto-os-ui doctor", false).contains("SSH;"));
168        assert!(!remote_run_banner_detail("horto-os-ui doctor", false).contains("sudo"));
169        assert!(remote_run_banner_detail("sudo horto-os-ui setup", true).contains("SSH + sudo"));
170    }
171
172    #[test]
173    fn remote_progress_and_doctor_banner_emit() {
174        remote_progress("box", "unit-test detail");
175        remote_doctor_report_banner();
176    }
177
178    fn push_cli_probes_missing(runner: &ScriptedRunner) {
179        // install_dir then agent_dir: test -x fails → ssh non-zero
180        runner.push("ssh", ScriptedRunner::fail(1, "missing"));
181        runner.push("ssh", ScriptedRunner::fail(1, "missing"));
182    }
183
184    fn push_cli_probe_current(runner: &ScriptedRunner) {
185        runner.push("ssh", ScriptedRunner::ok(&format!("{LONG_VERSION}\n")));
186    }
187
188    #[test]
189    fn normalize_and_match_cli_version() {
190        assert_eq!(
191            normalize_cli_version(&format!("  {LONG_VERSION} \n")),
192            LONG_VERSION
193        );
194        assert!(remote_cli_version_is_current(LONG_VERSION));
195        assert!(remote_cli_version_is_current(&format!(
196            "horto-os-ui {LONG_VERSION}"
197        )));
198        assert!(!remote_cli_version_is_current("0.0.0 (deadbeef)"));
199        assert!(!remote_cli_version_is_current(""));
200    }
201
202    #[test]
203    fn classify_ssh_failure_auth_vs_unreachable() {
204        assert_eq!(
205            classify_ssh_failure("exit 255: Permission denied (publickey,password)"),
206            RemoteBoxCliStatus::AuthFailed
207        );
208        assert_eq!(
209            classify_ssh_failure("exit 255: Connection refused"),
210            RemoteBoxCliStatus::Unreachable
211        );
212        assert_eq!(
213            classify_ssh_failure("exit 255: Could not resolve hostname"),
214            RemoteBoxCliStatus::Unreachable
215        );
216    }
217
218    #[test]
219    fn remote_box_cli_status_as_label_covers_all_variants() {
220        assert_eq!(RemoteBoxCliStatus::Missing.as_label(), "missing");
221        assert_eq!(RemoteBoxCliStatus::AuthFailed.as_label(), "auth failed");
222        assert_eq!(RemoteBoxCliStatus::Unreachable.as_label(), "unreachable");
223        assert_eq!(
224            RemoteBoxCliStatus::Found("0.1.0 (abc)".into()).as_label(),
225            "0.1.0 (abc)"
226        );
227    }
228
229    #[test]
230    fn ssh_command_detail_filters_non_ssh_errors() {
231        assert!(ssh_command_detail(&crate::error::HortoError::msg("nope")).is_none());
232        assert_eq!(
233            ssh_command_detail(&crate::error::HortoError::command("ssh", "exit 255: x")),
234            Some("exit 255: x")
235        );
236        assert_eq!(
237            ssh_command_detail(&crate::error::HortoError::command("scp", "denied")),
238            Some("denied")
239        );
240        assert!(ssh_command_detail(&crate::error::HortoError::command("tar", "x")).is_none());
241    }
242
243    #[test]
244    fn probe_remote_cli_reports_unreachable_on_connection_refused() {
245        let runner = ScriptedRunner::default();
246        runner.push("ssh", ScriptedRunner::fail(255, "Connection refused"));
247        let opts = RemoteOptions {
248            host: "box".into(),
249            ..RemoteOptions::default()
250        };
251        let probe = probe_remote_cli(&runner, &session_from(&opts).unwrap(), &opts).unwrap();
252        assert_eq!(probe.status, RemoteBoxCliStatus::Unreachable);
253    }
254
255    #[test]
256    fn probe_remote_cli_reports_auth_failed() {
257        let runner = ScriptedRunner::default();
258        runner.push("ssh", ScriptedRunner::fail(255, "Permission denied"));
259        let opts = RemoteOptions {
260            host: "box".into(),
261            ..RemoteOptions::default()
262        };
263        let probe = probe_remote_cli(&runner, &session_from(&opts).unwrap(), &opts).unwrap();
264        assert_eq!(probe.status, RemoteBoxCliStatus::AuthFailed);
265        assert!(!probe.current);
266    }
267
268    #[test]
269    fn probe_remote_cli_reports_missing_when_binaries_absent() {
270        let runner = ScriptedRunner::default();
271        push_cli_probes_missing(&runner);
272        let opts = RemoteOptions {
273            host: "box".into(),
274            ..RemoteOptions::default()
275        };
276        let probe = probe_remote_cli(&runner, &session_from(&opts).unwrap(), &opts).unwrap();
277        assert_eq!(probe.status, RemoteBoxCliStatus::Missing);
278    }
279
280    #[test]
281    fn remote_box_snapshot_no_scp_when_cli_current() {
282        let runner = ScriptedRunner::default();
283        push_cli_probe_current(&runner);
284        runner.push(
285            "ssh",
286            ScriptedRunner::ok(
287                r#"{"kind":"full","steps":[{"id":"s1","title":"Base","status":"done","step_version":1,"destructive":false,"needs_reboot_after":false}]}"#,
288            ),
289        );
290        runner.push(
291            "ssh",
292            ScriptedRunner::ok(
293                r#"{"is_root":false,"has_sudo":true,"docker_present":true,"active_setup_dir":true,"full_env":true,"minimal_env":false,"docker_dir":true,"backup_dir":true,"notes":["ok"]}"#,
294            ),
295        );
296
297        let snap = remote_box_snapshot(
298            &runner,
299            &RemoteOptions {
300                host: "box".into(),
301                ..RemoteOptions::default()
302            },
303            true,
304        )
305        .unwrap();
306        assert!(snap.cli_current);
307        assert_eq!(snap.setup.as_ref().unwrap().steps[0].id, "s1");
308        assert!(snap.doctor.as_ref().unwrap().has_sudo);
309        assert_eq!(
310            runner
311                .calls
312                .lock()
313                .unwrap()
314                .iter()
315                .filter(|(p, _, _, _)| p == "scp")
316                .count(),
317            0,
318            "status refresh must never SCP when CLI is current"
319        );
320    }
321
322    #[test]
323    fn remote_box_snapshot_stale_skips_status_without_scp() {
324        let runner = ScriptedRunner::default();
325        push_cli_probes_missing(&runner);
326
327        let snap = remote_box_snapshot(
328            &runner,
329            &RemoteOptions {
330                host: "box".into(),
331                ..RemoteOptions::default()
332            },
333            true,
334        )
335        .unwrap();
336        assert!(!snap.cli_current);
337        assert!(snap.setup.is_none());
338        assert!(snap.doctor.is_none());
339        assert_eq!(snap.cli_status, RemoteBoxCliStatus::Missing);
340        assert_eq!(
341            runner
342                .calls
343                .lock()
344                .unwrap()
345                .iter()
346                .filter(|(p, _, _, _)| p == "scp")
347                .count(),
348            0
349        );
350    }
351
352    #[test]
353    fn remote_box_snapshot_falls_back_to_text_status() {
354        let runner = ScriptedRunner::default();
355        push_cli_probe_current(&runner);
356        runner.push("ssh", ScriptedRunner::ok("not json\n"));
357        runner.push(
358            "ssh",
359            ScriptedRunner::ok("Setup kind: full\n  [  done] s1 - Install base packages (v1)\n"),
360        );
361        runner.push(
362            "ssh",
363            ScriptedRunner::ok(
364                r#"{"is_root":false,"has_sudo":true,"docker_present":false,"active_setup_dir":false,"full_env":false,"minimal_env":true,"docker_dir":false,"backup_dir":false,"notes":[]}"#,
365            ),
366        );
367
368        let snap = remote_box_snapshot(
369            &runner,
370            &RemoteOptions {
371                host: "box".into(),
372                ..RemoteOptions::default()
373            },
374            true,
375        )
376        .unwrap();
377        assert_eq!(snap.setup.as_ref().unwrap().steps[0].id, "s1");
378        assert!(!snap.doctor.as_ref().unwrap().docker_present);
379        assert_eq!(
380            runner
381                .calls
382                .lock()
383                .unwrap()
384                .iter()
385                .filter(|(p, _, _, _)| p == "scp")
386                .count(),
387            0
388        );
389    }
390
391    #[test]
392    fn remote_upload_cli_scps_once() {
393        let stubs = bin_dir_with_stubs();
394        let runner = ScriptedRunner::default();
395        runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
396        runner.push("ssh", ScriptedRunner::ok(""));
397        runner.push("scp", ScriptedRunner::ok(""));
398        runner.push("ssh", ScriptedRunner::ok(""));
399        runner.push("ssh", ScriptedRunner::fail(1, "missing"));
400        runner.push("ssh", ScriptedRunner::ok(&format!("{LONG_VERSION}\n")));
401
402        let probe = remote_upload_cli(
403            &runner,
404            &RemoteOptions {
405                host: "box".into(),
406                bin_dir: Some(stubs.path().to_path_buf()),
407                ..RemoteOptions::default()
408            },
409        )
410        .unwrap();
411        assert!(probe.current);
412        assert_eq!(
413            runner
414                .calls
415                .lock()
416                .unwrap()
417                .iter()
418                .filter(|(p, _, _, _)| p == "scp")
419                .count(),
420            1
421        );
422    }
423
424    #[test]
425    fn probe_remote_cli_skips_empty_version_then_accepts_agent() {
426        let runner = ScriptedRunner::default();
427        runner.push("ssh", ScriptedRunner::ok("\n"));
428        push_cli_probe_current(&runner);
429        let opts = RemoteOptions {
430            host: "box".into(),
431            ..RemoteOptions::default()
432        };
433        let probe = probe_remote_cli(&runner, &session_from(&opts).unwrap(), &opts).unwrap();
434        assert!(probe.current);
435        assert!(probe.path.unwrap().contains("horto-os-ui"));
436    }
437
438    #[test]
439    fn probe_remote_cli_keeps_stale_install_when_agent_missing() {
440        let runner = ScriptedRunner::default();
441        runner.push("ssh", ScriptedRunner::ok("0.0.0 (deadbeef)\n"));
442        runner.push("ssh", ScriptedRunner::fail(1, "missing"));
443        let opts = RemoteOptions {
444            host: "box".into(),
445            ..RemoteOptions::default()
446        };
447        let probe = probe_remote_cli(&runner, &session_from(&opts).unwrap(), &opts).unwrap();
448        assert!(!probe.current);
449        assert_eq!(probe.version.as_deref(), Some("0.0.0 (deadbeef)"));
450        assert!(probe.path.is_some());
451    }
452
453    #[test]
454    fn remote_run_skips_scp_when_box_cli_already_current() {
455        let stubs = bin_dir_with_stubs();
456        let runner = ScriptedRunner::default();
457        runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
458        push_cli_probe_current(&runner);
459        runner.push("ssh", ScriptedRunner::ok("ok\n"));
460
461        let log = remote_run_cli(
462            &runner,
463            &RemoteRunRequest {
464                options: RemoteOptions {
465                    host: "box".into(),
466                    bin_dir: Some(stubs.path().to_path_buf()),
467                    ..RemoteOptions::default()
468                },
469                cli_args: vec!["doctor".into()],
470                flags: RemoteRunFlags {
471                    use_sudo: false,
472                    install_payload_on_success: false,
473                    offer_reboot_on_success: false,
474                    capture_output: false,
475                },
476                ecosystem: EcosystemInstallChoice::none(),
477            },
478        )
479        .unwrap()
480        .log;
481        assert!(log.contains("ok"));
482        assert_eq!(
483            runner
484                .calls
485                .lock()
486                .unwrap()
487                .iter()
488                .filter(|(p, _, _, _)| p == "scp")
489                .count(),
490            0
491        );
492    }
493
494    #[test]
495    fn remote_run_capture_output_merges_stderr() {
496        let stubs = bin_dir_with_stubs();
497        let runner = ScriptedRunner::default();
498        runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
499        push_cli_probe_current(&runner);
500        runner.push(
501            "ssh",
502            CommandOutput {
503                status: 0,
504                stdout: "out-line\n".into(),
505                stderr: "warn-line\n".into(),
506            },
507        );
508
509        let log = remote_run_cli(
510            &runner,
511            &RemoteRunRequest {
512                options: RemoteOptions {
513                    host: "box".into(),
514                    bin_dir: Some(stubs.path().to_path_buf()),
515                    ..RemoteOptions::default()
516                },
517                cli_args: vec!["doctor".into()],
518                flags: RemoteRunFlags {
519                    use_sudo: false,
520                    install_payload_on_success: false,
521                    offer_reboot_on_success: false,
522                    capture_output: true,
523                },
524                ecosystem: EcosystemInstallChoice::none(),
525            },
526        )
527        .unwrap()
528        .log;
529        assert!(log.contains("out-line"));
530        assert!(log.contains("warn-line"));
531    }
532
533    #[test]
534    fn remote_setup_status_and_doctor_require_current_cli() {
535        let runner = ScriptedRunner::default();
536        push_cli_probes_missing(&runner);
537        let err = remote_setup_status(
538            &runner,
539            &RemoteOptions {
540                host: "box".into(),
541                ..RemoteOptions::default()
542            },
543            true,
544        )
545        .unwrap_err();
546        assert!(err.to_string().contains("sync CLI"));
547
548        let runner2 = ScriptedRunner::default();
549        push_cli_probes_missing(&runner2);
550        let err = remote_doctor(
551            &runner2,
552            &RemoteOptions {
553                host: "box".into(),
554                ..RemoteOptions::default()
555            },
556        )
557        .unwrap_err();
558        assert!(err.to_string().contains("sync CLI"));
559    }
560
561    #[test]
562    fn remote_setup_status_and_doctor_return_reports_when_current() {
563        let runner = ScriptedRunner::default();
564        push_cli_probe_current(&runner);
565        runner.push(
566            "ssh",
567            ScriptedRunner::ok(
568                r#"{"kind":"full","steps":[{"id":"s1","title":"Base","status":"done","step_version":1,"destructive":false,"needs_reboot_after":false}]}"#,
569            ),
570        );
571        runner.push(
572            "ssh",
573            ScriptedRunner::ok(
574                r#"{"is_root":false,"has_sudo":true,"docker_present":true,"active_setup_dir":true,"full_env":true,"minimal_env":false,"docker_dir":true,"backup_dir":true,"notes":[]}"#,
575            ),
576        );
577        let status = remote_setup_status(
578            &runner,
579            &RemoteOptions {
580                host: "box".into(),
581                ..RemoteOptions::default()
582            },
583            true,
584        )
585        .unwrap();
586        assert_eq!(status.steps[0].id, "s1");
587
588        let runner2 = ScriptedRunner::default();
589        push_cli_probe_current(&runner2);
590        runner2.push(
591            "ssh",
592            ScriptedRunner::ok(
593                r#"{"kind":"full","steps":[{"id":"s1","title":"Base","status":"done","step_version":1,"destructive":false,"needs_reboot_after":false}]}"#,
594            ),
595        );
596        runner2.push(
597            "ssh",
598            ScriptedRunner::ok(
599                r#"{"is_root":false,"has_sudo":true,"docker_present":true,"active_setup_dir":true,"full_env":true,"minimal_env":false,"docker_dir":true,"backup_dir":true,"notes":[]}"#,
600            ),
601        );
602        let doc = remote_doctor(
603            &runner2,
604            &RemoteOptions {
605                host: "box".into(),
606                ..RemoteOptions::default()
607            },
608        )
609        .unwrap();
610        assert!(doc.has_sudo);
611    }
612
613    #[test]
614    fn build_remote_command_shapes() {
615        let cmd = build_remote_command_at(
616            "/tmp/agent/horto-os-ui",
617            &[
618                "--apply".into(),
619                "setup".into(),
620                "run".into(),
621                "--full".into(),
622            ],
623            false,
624        );
625        assert_eq!(cmd, "/tmp/agent/horto-os-ui --apply setup run --full");
626        let cmd_sudo = build_remote_command_at("/tmp/agent/horto-os-ui", &["doctor".into()], true);
627        assert!(cmd_sudo.starts_with("sudo "));
628    }
629
630    #[test]
631    fn remote_run_does_not_copy_id_by_default() {
632        let stubs = bin_dir_with_stubs();
633        let runner = ScriptedRunner::default();
634        // uname
635        runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
636        push_cli_probes_missing(&runner);
637        // mkdir agent
638        runner.push("ssh", ScriptedRunner::ok(""));
639        // scp agent
640        runner.push("scp", ScriptedRunner::ok(""));
641        // chmod
642        runner.push("ssh", ScriptedRunner::ok(""));
643        // remote cli
644        runner.push("ssh", ScriptedRunner::ok("ok\n"));
645
646        let log = remote_run_cli(
647            &runner,
648            &RemoteRunRequest {
649                options: RemoteOptions {
650                    host: "box".into(),
651                    bin_dir: Some(stubs.path().to_path_buf()),
652                    install_ssh_key: false,
653                    ..RemoteOptions::default()
654                },
655                cli_args: vec!["setup".into(), "status".into()],
656                flags: RemoteRunFlags {
657                    use_sudo: false,
658                    install_payload_on_success: false,
659                    offer_reboot_on_success: false,
660                    capture_output: false,
661                },
662                ecosystem: EcosystemInstallChoice::none(),
663            },
664        )
665        .unwrap()
666        .log;
667        assert!(log.contains("ok"));
668        let programs: Vec<_> = runner
669            .calls
670            .lock()
671            .unwrap()
672            .iter()
673            .map(|(p, _, _, _)| p.clone())
674            .collect();
675        assert!(!programs.iter().any(|p| p == "ssh-copy-id"));
676    }
677
678    #[test]
679    fn remote_run_install_key_when_opt_in() {
680        crate::remote::ssh::tests::with_fake_default_pubkey(|_| {
681            let stubs = bin_dir_with_stubs();
682            let runner = ScriptedRunner::default();
683            runner.push("ssh", ScriptedRunner::ok("aarch64\n"));
684            push_cli_probes_missing(&runner);
685            runner.push("ssh", ScriptedRunner::ok(""));
686            runner.push("scp", ScriptedRunner::ok(""));
687            runner.push("ssh", ScriptedRunner::ok(""));
688            // Key probe: not authorized yet.
689            runner.push("ssh", ScriptedRunner::fail(255, "Permission denied"));
690            runner.push("ssh-copy-id", ScriptedRunner::ok(""));
691            runner.push("ssh", ScriptedRunner::ok("done\n"));
692
693            remote_run_cli(
694                &runner,
695                &RemoteRunRequest {
696                    options: RemoteOptions {
697                        host: "box".into(),
698                        bin_dir: Some(stubs.path().to_path_buf()),
699                        install_ssh_key: true,
700                        ..RemoteOptions::default()
701                    },
702                    cli_args: vec!["doctor".into()],
703                    flags: RemoteRunFlags {
704                        use_sudo: false,
705                        install_payload_on_success: false,
706                        offer_reboot_on_success: false,
707                        capture_output: false,
708                    },
709                    ecosystem: EcosystemInstallChoice::none(),
710                },
711            )
712            .unwrap();
713            assert!(runner
714                .calls
715                .lock()
716                .unwrap()
717                .iter()
718                .any(|(p, _, _, _)| p == "ssh-copy-id"));
719        });
720    }
721
722    #[test]
723    fn remote_run_skips_ssh_copy_id_when_key_works() {
724        crate::remote::ssh::tests::with_fake_default_pubkey(|_| {
725            let stubs = bin_dir_with_stubs();
726            let runner = ScriptedRunner::default();
727            runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
728            push_cli_probes_missing(&runner);
729            runner.push("ssh", ScriptedRunner::ok(""));
730            runner.push("scp", ScriptedRunner::ok(""));
731            runner.push("ssh", ScriptedRunner::ok(""));
732            // Key probe: already authorized.
733            runner.push("ssh", ScriptedRunner::ok(""));
734            runner.push("ssh", ScriptedRunner::ok("doctor ok\n"));
735
736            remote_run_cli(
737                &runner,
738                &RemoteRunRequest {
739                    options: RemoteOptions {
740                        host: "box".into(),
741                        bin_dir: Some(stubs.path().to_path_buf()),
742                        install_ssh_key: true,
743                        ..RemoteOptions::default()
744                    },
745                    cli_args: vec!["doctor".into()],
746                    flags: RemoteRunFlags {
747                        use_sudo: false,
748                        install_payload_on_success: false,
749                        offer_reboot_on_success: false,
750                        capture_output: false,
751                    },
752                    ecosystem: EcosystemInstallChoice::none(),
753                },
754            )
755            .unwrap();
756            assert!(!runner
757                .calls
758                .lock()
759                .unwrap()
760                .iter()
761                .any(|(p, _, _, _)| p == "ssh-copy-id"));
762        });
763    }
764
765    #[test]
766    fn default_options_key_off() {
767        assert!(!RemoteOptions::default().install_ssh_key);
768    }
769
770    #[test]
771    fn remote_options_from_input_force_askpass_tag_and_defaults() {
772        let opts = RemoteOptions::from_input(RemoteOptionsInput {
773            host: "box".into(),
774            install_ssh_key: true,
775            bin_dir: None,
776            release_tag: Some("  v9.9.9  ".into()),
777            force_askpass: true,
778        });
779        assert_eq!(opts.host, "box");
780        assert!(opts.install_ssh_key);
781        assert!(opts.force_askpass);
782        assert_eq!(opts.release_tag, "v9.9.9");
783        assert_eq!(opts.github_repo, DEFAULT_GITHUB_REPO);
784
785        let defaults = RemoteOptions::from_input(RemoteOptionsInput {
786            host: "h".into(),
787            ..RemoteOptionsInput::default()
788        });
789        assert!(!defaults.force_askpass);
790        assert!(!defaults.install_ssh_key);
791        assert_eq!(defaults.release_tag, RemoteOptions::default().release_tag);
792
793        let blank_tag = RemoteOptions::from_input(RemoteOptionsInput {
794            host: "h".into(),
795            release_tag: Some("   ".into()),
796            ..RemoteOptionsInput::default()
797        });
798        assert_eq!(blank_tag.release_tag, RemoteOptions::default().release_tag);
799    }
800
801    #[test]
802    fn remote_probe_arch_ok() {
803        let runner = ScriptedRunner::default();
804        runner.push("ssh", ScriptedRunner::ok("aarch64\n"));
805        let arch = remote_probe_arch(
806            &runner,
807            &RemoteOptions {
808                host: "box".into(),
809                ..RemoteOptions::default()
810            },
811        )
812        .unwrap();
813        assert_eq!(arch, BoxArch::Arm64);
814    }
815
816    #[test]
817    fn remote_setup_run_plan_minimal_skip_piper() {
818        let stubs = bin_dir_with_stubs();
819        let runner = ScriptedRunner::default();
820        runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
821        push_cli_probes_missing(&runner);
822        runner.push("ssh", ScriptedRunner::ok(""));
823        runner.push("scp", ScriptedRunner::ok(""));
824        runner.push("ssh", ScriptedRunner::ok(""));
825        runner.push("ssh", ScriptedRunner::ok("pipeline ok\n"));
826        let log = remote_setup_run(
827            &runner,
828            RemoteOptions {
829                host: "box".into(),
830                bin_dir: Some(stubs.path().to_path_buf()),
831                ..RemoteOptions::default()
832            },
833            false,
834            false,
835            true,
836            EcosystemInstallChoice::none(),
837        )
838        .unwrap()
839        .log;
840        assert!(log.contains("pipeline ok") || log.contains("remote command"));
841        let cli_ssh = runner
842            .calls
843            .lock()
844            .unwrap()
845            .iter()
846            .rev()
847            .find(|(p, _, _, _)| p == "ssh")
848            .unwrap()
849            .1
850            .join(" ");
851        assert!(!cli_ssh.contains("--apply"));
852        assert!(cli_ssh.contains("--skip-piper"));
853        assert!(cli_ssh.contains("--minimal"));
854    }
855
856    #[test]
857    fn remote_run_merges_stderr_and_empty_inherit_log() {
858        let stubs = bin_dir_with_stubs();
859        let runner = ScriptedRunner::default();
860        runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
861        push_cli_probes_missing(&runner);
862        runner.push("ssh", ScriptedRunner::ok(""));
863        runner.push("scp", ScriptedRunner::ok(""));
864        runner.push("ssh", ScriptedRunner::ok(""));
865        runner.push(
866            "ssh",
867            CommandOutput {
868                status: 0,
869                stdout: "out\n".into(),
870                stderr: "warn\n".into(),
871            },
872        );
873        let log = remote_run_cli(
874            &runner,
875            &RemoteRunRequest {
876                options: RemoteOptions {
877                    host: "box".into(),
878                    bin_dir: Some(stubs.path().to_path_buf()),
879                    ..RemoteOptions::default()
880                },
881                cli_args: vec!["doctor".into()],
882                flags: RemoteRunFlags {
883                    use_sudo: false,
884                    install_payload_on_success: false,
885                    offer_reboot_on_success: false,
886                    capture_output: false,
887                },
888                ecosystem: EcosystemInstallChoice::none(),
889            },
890        )
891        .unwrap()
892        .log;
893        assert!(log.contains("out"));
894        assert!(log.contains("warn"));
895
896        let runner2 = ScriptedRunner::default();
897        runner2.push("ssh", ScriptedRunner::ok("x86_64\n"));
898        push_cli_probes_missing(&runner2);
899        runner2.push("ssh", ScriptedRunner::ok(""));
900        runner2.push("scp", ScriptedRunner::ok(""));
901        runner2.push("ssh", ScriptedRunner::ok(""));
902        runner2.push("ssh", ScriptedRunner::ok(""));
903        let log2 = remote_run_cli(
904            &runner2,
905            &RemoteRunRequest {
906                options: RemoteOptions {
907                    host: "box".into(),
908                    bin_dir: Some(stubs.path().to_path_buf()),
909                    ..RemoteOptions::default()
910                },
911                cli_args: vec!["doctor".into()],
912                flags: RemoteRunFlags {
913                    use_sudo: false,
914                    install_payload_on_success: false,
915                    offer_reboot_on_success: false,
916                    capture_output: false,
917                },
918                ecosystem: EcosystemInstallChoice::none(),
919            },
920        )
921        .unwrap()
922        .log;
923        assert!(log2.contains("remote command finished"));
924    }
925
926    #[test]
927    fn remote_install_payload_scp_and_custom_prefix() {
928        let stubs = bin_dir_with_stubs();
929        let bins = LocalBins {
930            dir: stubs.path().to_path_buf(),
931            cli: stubs.path().join("horto-os-ui"),
932            tui: stubs.path().join("horto-os-ui-tui"),
933            status_api: stubs.path().join("horto-os-ui-status-api"),
934            mcp: Some(stubs.path().join("horto-os-ui-mcp")),
935        };
936        let runner = ScriptedRunner::default();
937        // mkdir staging
938        runner.push("ssh", ScriptedRunner::ok(""));
939        // prefer_rsync false
940        runner.push("rsync", ScriptedRunner::fail(127, "no"));
941        // scp_files mkdir + 4 scp
942        runner.push("ssh", ScriptedRunner::ok(""));
943        runner.push("scp", ScriptedRunner::ok(""));
944        runner.push("scp", ScriptedRunner::ok(""));
945        runner.push("scp", ScriptedRunner::ok(""));
946        runner.push("scp", ScriptedRunner::ok(""));
947        // one enable (token+units+bins[+restart]) + cat drop + rm drop
948        runner.push("ssh", ScriptedRunner::ok(""));
949        runner.push(
950            "ssh",
951            ScriptedRunner::ok("HORTO_API_TOKEN=deadbeefcafebabedeadbeefcafebabe\n"),
952        );
953        runner.push("ssh", ScriptedRunner::ok(""));
954
955        let token = remote_install_payload(
956            &runner,
957            &RemoteOptions {
958                host: "box".into(),
959                install_dir: "/opt/horto/bin".into(),
960                ..RemoteOptions::default()
961            },
962            &bins,
963            EcosystemInstallChoice {
964                status_api: true,
965                mcp: true,
966            },
967        )
968        .unwrap();
969        assert_eq!(token.as_deref(), Some("deadbeefcafebabedeadbeefcafebabe"));
970        let ssh_cmds: Vec<String> = runner
971            .calls
972            .lock()
973            .unwrap()
974            .iter()
975            .filter(|(p, _, _, _)| p == "ssh")
976            .map(|(_, args, _, _)| args.join(" "))
977            .collect();
978        assert!(ssh_cmds
979            .iter()
980            .any(|c| c.contains("horto-os-ui-status-api.service")
981                && c.contains("horto-os-ui-mcp.service")
982                && c.contains("/opt/horto/bin")
983                && c.contains("restart horto-os-ui-mcp.service")));
984        assert!(ssh_cmds.len() >= 5);
985    }
986
987    #[test]
988    fn remote_run_with_payload_install() {
989        let stubs = bin_dir_with_stubs();
990        let runner = ScriptedRunner::default();
991        runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
992        push_cli_probes_missing(&runner);
993        runner.push("ssh", ScriptedRunner::ok(""));
994        runner.push("scp", ScriptedRunner::ok(""));
995        runner.push("ssh", ScriptedRunner::ok(""));
996        runner.push("ssh", ScriptedRunner::ok("done\n"));
997        // payload
998        runner.push("ssh", ScriptedRunner::ok(""));
999        runner.push("rsync", ScriptedRunner::fail(127, "no"));
1000        runner.push("ssh", ScriptedRunner::ok(""));
1001        runner.push("scp", ScriptedRunner::ok(""));
1002        runner.push("scp", ScriptedRunner::ok(""));
1003        runner.push("scp", ScriptedRunner::ok(""));
1004        runner.push("scp", ScriptedRunner::ok(""));
1005        // enable (token+units+bins) + cat drop + rm
1006        runner.push("ssh", ScriptedRunner::ok(""));
1007        runner.push(
1008            "ssh",
1009            ScriptedRunner::ok("aabbccddeeff00112233445566778899\n"),
1010        );
1011        runner.push("ssh", ScriptedRunner::ok(""));
1012
1013        let outcome = remote_run_cli(
1014            &runner,
1015            &RemoteRunRequest {
1016                options: RemoteOptions {
1017                    host: "box".into(),
1018                    bin_dir: Some(stubs.path().to_path_buf()),
1019                    ..RemoteOptions::default()
1020                },
1021                cli_args: vec!["setup".into(), "run".into(), "--full".into()],
1022                flags: RemoteRunFlags {
1023                    use_sudo: true,
1024                    install_payload_on_success: true,
1025                    offer_reboot_on_success: false,
1026                    capture_output: false,
1027                },
1028                ecosystem: EcosystemInstallChoice {
1029                    status_api: true,
1030                    mcp: true,
1031                },
1032            },
1033        )
1034        .unwrap();
1035        assert!(outcome.log.contains("done"));
1036        assert_eq!(
1037            outcome.api_token.as_deref(),
1038            Some("aabbccddeeff00112233445566778899")
1039        );
1040    }
1041
1042    #[test]
1043    fn remote_run_payload_skipped_when_ecosystem_none() {
1044        let stubs = bin_dir_with_stubs();
1045        let runner = ScriptedRunner::default();
1046        runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
1047        push_cli_probes_missing(&runner);
1048        runner.push("ssh", ScriptedRunner::ok(""));
1049        runner.push("scp", ScriptedRunner::ok(""));
1050        runner.push("ssh", ScriptedRunner::ok(""));
1051        runner.push("ssh", ScriptedRunner::ok("done\n"));
1052
1053        let outcome = remote_run_cli(
1054            &runner,
1055            &RemoteRunRequest {
1056                options: RemoteOptions {
1057                    host: "box".into(),
1058                    bin_dir: Some(stubs.path().to_path_buf()),
1059                    ..RemoteOptions::default()
1060                },
1061                cli_args: vec!["setup".into(), "run".into(), "--full".into()],
1062                flags: RemoteRunFlags {
1063                    use_sudo: true,
1064                    install_payload_on_success: true,
1065                    offer_reboot_on_success: false,
1066                    capture_output: false,
1067                },
1068                ecosystem: EcosystemInstallChoice::none(),
1069            },
1070        )
1071        .unwrap();
1072        assert!(outcome.log.contains("done"));
1073        assert!(outcome.api_token.is_none());
1074        let scp = runner
1075            .calls
1076            .lock()
1077            .unwrap()
1078            .iter()
1079            .filter(|(p, _, _, _)| p == "scp" || p == "rsync")
1080            .count();
1081        // Only CLI upload SCP, no payload transfer of four bins.
1082        assert_eq!(scp, 1);
1083    }
1084
1085    #[test]
1086    fn parse_api_token_drop_accepts_prefix_and_bare_hex() {
1087        assert_eq!(
1088            parse_api_token_drop("HORTO_API_TOKEN=aabbccddeeff00112233445566778899\n").as_deref(),
1089            Some("aabbccddeeff00112233445566778899")
1090        );
1091        assert_eq!(
1092            parse_api_token_drop("  deadbeefcafebabedeadbeefcafebabe  \n").as_deref(),
1093            Some("deadbeefcafebabedeadbeefcafebabe")
1094        );
1095        assert!(parse_api_token_drop("").is_none());
1096        assert!(parse_api_token_drop("not-hex!").is_none());
1097        assert!(parse_api_token_drop("HORTO_API_TOKEN=abc123\n").is_none());
1098        assert!(parse_api_token_drop("11223344\n").is_none());
1099    }
1100
1101    #[test]
1102    fn finish_save_api_token_writes_under_xdg_config() {
1103        let _guard = CONFIG_ENV_LOCK
1104            .lock()
1105            .unwrap_or_else(std::sync::PoisonError::into_inner);
1106        let tmp = TempDir::new().unwrap();
1107        let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", tmp.path());
1108        assert!(!finish_save_api_token("aabb", "n").unwrap());
1109        assert!(!tmp.path().join("horto-os-ui").join("api_token").exists());
1110        assert!(finish_save_api_token("aabbccddeeff00112233445566778899", "y").unwrap());
1111        let path = tmp.path().join("horto-os-ui").join("api_token");
1112        let body = fs::read_to_string(&path).unwrap();
1113        assert_eq!(body.trim(), "aabbccddeeff00112233445566778899");
1114    }
1115
1116    #[test]
1117    fn write_api_token_file_uses_home_when_xdg_empty() {
1118        let _guard = CONFIG_ENV_LOCK
1119            .lock()
1120            .unwrap_or_else(std::sync::PoisonError::into_inner);
1121        let tmp = TempDir::new().unwrap();
1122        let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", "   ");
1123        let _home = EnvVarGuard::set("HOME", tmp.path());
1124        let path = write_api_token_file("11223344556677889900aabbccddeeff").unwrap();
1125        assert_eq!(
1126            path,
1127            tmp.path()
1128                .join(".config")
1129                .join("horto-os-ui")
1130                .join("api_token")
1131        );
1132        assert_eq!(
1133            fs::read_to_string(&path).unwrap().trim(),
1134            "11223344556677889900aabbccddeeff"
1135        );
1136    }
1137
1138    #[test]
1139    fn write_api_token_file_uses_home_when_xdg_unset() {
1140        let _guard = CONFIG_ENV_LOCK
1141            .lock()
1142            .unwrap_or_else(std::sync::PoisonError::into_inner);
1143        let tmp = TempDir::new().unwrap();
1144        let _xdg = EnvVarGuard::remove("XDG_CONFIG_HOME");
1145        let _home = EnvVarGuard::set("HOME", tmp.path());
1146        let path = write_api_token_file("556677889900aabbccddeeff11223344").unwrap();
1147        assert!(path.ends_with("horto-os-ui/api_token"));
1148        assert_eq!(
1149            fs::read_to_string(&path).unwrap().trim(),
1150            "556677889900aabbccddeeff11223344"
1151        );
1152    }
1153
1154    #[test]
1155    fn offer_save_api_token_non_tty_skips_write() {
1156        let _guard = CONFIG_ENV_LOCK
1157            .lock()
1158            .unwrap_or_else(std::sync::PoisonError::into_inner);
1159        let tmp = TempDir::new().unwrap();
1160        let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", tmp.path());
1161        assert!(
1162            !offer_save_api_token_with("99aabbccddeeff001122334455667788", false, None).unwrap()
1163        );
1164        assert!(!tmp.path().join("horto-os-ui").join("api_token").exists());
1165        // Public wrapper still exercises is_terminal() + dispatch.
1166        let _ = offer_save_api_token("99aabbccddeeff001122334455667788");
1167    }
1168
1169    #[test]
1170    fn offer_save_api_token_tty_canned_yes_and_no() {
1171        let _guard = CONFIG_ENV_LOCK
1172            .lock()
1173            .unwrap_or_else(std::sync::PoisonError::into_inner);
1174        let tmp = TempDir::new().unwrap();
1175        let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", tmp.path());
1176        assert!(!offer_save_api_token_with("aa11", true, Some("n")).unwrap());
1177        assert!(!tmp.path().join("horto-os-ui").join("api_token").exists());
1178        assert!(
1179            offer_save_api_token_with("bb22cc33ddeeff001122334455667788", true, Some("yes"))
1180                .unwrap()
1181        );
1182        let body = fs::read_to_string(tmp.path().join("horto-os-ui").join("api_token")).unwrap();
1183        assert_eq!(body.trim(), "bb22cc33ddeeff001122334455667788");
1184    }
1185
1186    #[test]
1187    fn parse_api_token_drop_rejects_empty_hex_value() {
1188        assert!(parse_api_token_drop("HORTO_API_TOKEN=\n").is_none());
1189        assert!(parse_api_token_drop("HORTO_API_TOKEN= \n").is_none());
1190        assert_eq!(
1191            parse_api_token_drop("\n\nHORTO_API_TOKEN=aabbccddeeff00112233445566778899\n")
1192                .as_deref(),
1193            Some("aabbccddeeff00112233445566778899")
1194        );
1195    }
1196
1197    #[test]
1198    fn write_api_token_file_rejects_short_before_create() {
1199        let _guard = CONFIG_ENV_LOCK
1200            .lock()
1201            .unwrap_or_else(std::sync::PoisonError::into_inner);
1202        let tmp = TempDir::new().unwrap();
1203        let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", tmp.path());
1204        let err = write_api_token_file("11223344").unwrap_err();
1205        assert!(err.to_string().contains("at least"));
1206        assert!(!tmp.path().join("horto-os-ui").join("api_token").exists());
1207    }
1208
1209    #[test]
1210    fn pull_remote_api_token_rejects_empty_host() {
1211        let runner = ScriptedRunner::default();
1212        let err = pull_remote_api_token(
1213            &runner,
1214            &RemoteOptions {
1215                host: "  ".into(),
1216                ..RemoteOptions::default()
1217            },
1218        )
1219        .unwrap_err();
1220        assert!(err.to_string().contains("empty"));
1221    }
1222
1223    #[test]
1224    fn pull_remote_api_token_writes_tip_from_drop() {
1225        let _guard = CONFIG_ENV_LOCK
1226            .lock()
1227            .unwrap_or_else(std::sync::PoisonError::into_inner);
1228        let tmp = TempDir::new().unwrap();
1229        let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", tmp.path());
1230        let runner = ScriptedRunner::default();
1231        // Inherit sudo grep into drop file.
1232        runner.push("ssh", ScriptedRunner::ok(""));
1233        // Capture cat of drop file.
1234        runner.push(
1235            "ssh",
1236            ScriptedRunner::ok("HORTO_API_TOKEN=aabbccddeeff00112233445566778899\n"),
1237        );
1238        let tok = pull_remote_api_token(
1239            &runner,
1240            &RemoteOptions {
1241                host: "box".into(),
1242                ..RemoteOptions::default()
1243            },
1244        )
1245        .unwrap();
1246        assert_eq!(tok, "aabbccddeeff00112233445566778899");
1247        let tip = tmp.path().join("horto-os-ui").join("api_token");
1248        assert_eq!(fs::read_to_string(&tip).unwrap().trim(), tok);
1249    }
1250
1251    #[test]
1252    fn pull_remote_api_token_errors_when_drop_unusable() {
1253        let _guard = CONFIG_ENV_LOCK
1254            .lock()
1255            .unwrap_or_else(std::sync::PoisonError::into_inner);
1256        let tmp = TempDir::new().unwrap();
1257        let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", tmp.path());
1258        let runner = ScriptedRunner::default();
1259        runner.push("ssh", ScriptedRunner::ok(""));
1260        runner.push("ssh", ScriptedRunner::ok("HORTO_API_TOKEN=short\n"));
1261        let err = pull_remote_api_token(
1262            &runner,
1263            &RemoteOptions {
1264                host: "box".into(),
1265                ..RemoteOptions::default()
1266            },
1267        )
1268        .unwrap_err();
1269        assert!(err.to_string().contains("could not read Status API token"));
1270        assert!(!tmp.path().join("horto-os-ui").join("api_token").exists());
1271    }
1272
1273    #[test]
1274    fn write_api_token_file_errors_when_config_parent_blocked() {
1275        let _guard = CONFIG_ENV_LOCK
1276            .lock()
1277            .unwrap_or_else(std::sync::PoisonError::into_inner);
1278        let tmp = TempDir::new().unwrap();
1279        let blocker = tmp.path().join("blocked");
1280        fs::write(&blocker, b"not-a-directory").unwrap();
1281        let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", &blocker);
1282        let err = write_api_token_file("aabbccddeeff00112233445566778899").unwrap_err();
1283        assert!(err.to_string().contains("create"));
1284    }
1285
1286    #[test]
1287    fn remote_install_payload_default_prefix_none_token() {
1288        let stubs = bin_dir_with_stubs();
1289        let bins = LocalBins {
1290            dir: stubs.path().to_path_buf(),
1291            cli: stubs.path().join("horto-os-ui"),
1292            tui: stubs.path().join("horto-os-ui-tui"),
1293            status_api: stubs.path().join("horto-os-ui-status-api"),
1294            mcp: Some(stubs.path().join("horto-os-ui-mcp")),
1295        };
1296        let runner = ScriptedRunner::default();
1297        runner.push("ssh", ScriptedRunner::ok(""));
1298        runner.push("rsync", ScriptedRunner::fail(127, "no"));
1299        runner.push("ssh", ScriptedRunner::ok(""));
1300        runner.push("scp", ScriptedRunner::ok(""));
1301        runner.push("scp", ScriptedRunner::ok(""));
1302        runner.push("scp", ScriptedRunner::ok(""));
1303        runner.push("scp", ScriptedRunner::ok(""));
1304        // enable (token+units+bins) + empty drop + rm (default /usr/local/bin: no restart)
1305        runner.push("ssh", ScriptedRunner::ok(""));
1306        runner.push("ssh", ScriptedRunner::ok("\n"));
1307        runner.push("ssh", ScriptedRunner::ok(""));
1308
1309        let token = remote_install_payload(
1310            &runner,
1311            &RemoteOptions {
1312                host: "box".into(),
1313                ..RemoteOptions::default()
1314            },
1315            &bins,
1316            EcosystemInstallChoice {
1317                status_api: true,
1318                mcp: true,
1319            },
1320        )
1321        .unwrap();
1322        assert!(token.is_none());
1323    }
1324
1325    #[test]
1326    fn remote_setup_run_full_apply_captures_token() {
1327        let stubs = bin_dir_with_stubs();
1328        let runner = ScriptedRunner::default();
1329        runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
1330        push_cli_probes_missing(&runner);
1331        runner.push("ssh", ScriptedRunner::ok(""));
1332        runner.push("scp", ScriptedRunner::ok(""));
1333        runner.push("ssh", ScriptedRunner::ok(""));
1334        runner.push("ssh", ScriptedRunner::ok("setup ok\n"));
1335        // payload
1336        runner.push("ssh", ScriptedRunner::ok(""));
1337        runner.push("rsync", ScriptedRunner::fail(127, "no"));
1338        runner.push("ssh", ScriptedRunner::ok(""));
1339        runner.push("scp", ScriptedRunner::ok(""));
1340        runner.push("scp", ScriptedRunner::ok(""));
1341        runner.push("scp", ScriptedRunner::ok(""));
1342        runner.push("scp", ScriptedRunner::ok(""));
1343        runner.push("ssh", ScriptedRunner::ok(""));
1344        runner.push(
1345            "ssh",
1346            ScriptedRunner::ok("HORTO_API_TOKEN=ffeeddccbbaa99887766554433221100\n"),
1347        );
1348        runner.push("ssh", ScriptedRunner::ok(""));
1349
1350        let outcome = remote_setup_run(
1351            &runner,
1352            RemoteOptions {
1353                host: "box".into(),
1354                bin_dir: Some(stubs.path().to_path_buf()),
1355                ..RemoteOptions::default()
1356            },
1357            true,
1358            true,
1359            false,
1360            EcosystemInstallChoice {
1361                status_api: true,
1362                mcp: true,
1363            },
1364        )
1365        .unwrap();
1366        assert!(outcome.log.contains("setup ok"));
1367        assert_eq!(
1368            outcome.api_token.as_deref(),
1369            Some("ffeeddccbbaa99887766554433221100")
1370        );
1371        let cli_ssh = runner
1372            .calls
1373            .lock()
1374            .unwrap()
1375            .iter()
1376            .find(|(p, args, _, _)| p == "ssh" && args.iter().any(|a| a.contains("--full")))
1377            .map(|(_, args, _, _)| args.join(" "))
1378            .unwrap_or_default();
1379        assert!(cli_ssh.contains("--full"));
1380        assert!(cli_ssh.contains("--apply"));
1381    }
1382
1383    #[test]
1384    fn remote_run_outcome_default_is_empty() {
1385        let o = RemoteRunOutcome::default();
1386        assert_eq!(o.log, "");
1387        assert!(o.api_token.is_none());
1388    }
1389
1390    #[test]
1391    fn wants_reboot_now_parses_answers() {
1392        assert!(wants_reboot_now("y"));
1393        assert!(wants_reboot_now("YES"));
1394        assert!(!wants_reboot_now(""));
1395        assert!(!wants_reboot_now("n"));
1396        assert!(!wants_reboot_now("maybe"));
1397    }
1398
1399    #[test]
1400    fn ssh_drop_after_reboot_matches_expected_phrases() {
1401        for msg in [
1402            "Connection closed by remote host",
1403            "Connection reset by peer",
1404            "Broken pipe",
1405            "ssh: exit 255",
1406        ] {
1407            assert!(
1408                super::reboot::ssh_drop_after_reboot(&crate::error::HortoError::msg(msg)),
1409                "expected drop: {msg}"
1410            );
1411        }
1412        assert!(!super::reboot::ssh_drop_after_reboot(
1413            &crate::error::HortoError::msg("Sorry, try again.")
1414        ));
1415    }
1416
1417    fn test_session() -> SshSession {
1418        session_from(&RemoteOptions {
1419            host: "box".into(),
1420            ..RemoteOptions::default()
1421        })
1422        .unwrap()
1423    }
1424
1425    #[test]
1426    fn finish_reboot_no_skips_ssh() {
1427        let runner = ScriptedRunner::default();
1428        finish_remote_reboot(&runner, &test_session(), "n", None).unwrap();
1429        assert!(runner.calls.lock().unwrap().is_empty());
1430    }
1431
1432    #[test]
1433    fn finish_reboot_yes_runs_sudo_reboot() {
1434        let runner = ScriptedRunner::default();
1435        runner.push("ssh", ScriptedRunner::ok(""));
1436        finish_remote_reboot(&runner, &test_session(), "yes", None).unwrap();
1437        let calls = runner.calls.lock().unwrap();
1438        assert_eq!(calls.len(), 1);
1439        assert!(calls[0].1.iter().any(|a| a.contains("sudo reboot")));
1440        drop(calls);
1441    }
1442
1443    #[test]
1444    fn finish_reboot_yes_treats_ssh_drop_as_ok() {
1445        let runner = ScriptedRunner::default();
1446        runner.push("ssh", ScriptedRunner::fail(255, "Connection closed"));
1447        finish_remote_reboot(&runner, &test_session(), "y", None).unwrap();
1448    }
1449
1450    #[test]
1451    fn finish_reboot_with_password_feeds_sudo_dash_s() {
1452        let runner = ScriptedRunner::default();
1453        runner.push("ssh", ScriptedRunner::ok(""));
1454        finish_remote_reboot(&runner, &test_session(), "y", Some("pw")).unwrap();
1455        let calls = runner.calls.lock().unwrap();
1456        assert_eq!(calls.len(), 1);
1457        assert!(calls[0].1.iter().any(|a| a.contains("sudo -S reboot")));
1458        assert!(!calls[0].1.iter().any(|a| *a == "-tt"));
1459        drop(calls);
1460    }
1461
1462    #[test]
1463    fn finish_reboot_with_password_propagates_sudo_failure() {
1464        let runner = ScriptedRunner::default();
1465        runner.push("ssh", ScriptedRunner::fail(1, "Sorry, try again."));
1466        let err = finish_remote_reboot(&runner, &test_session(), "y", Some("bad")).unwrap_err();
1467        assert!(err.to_string().contains("Sorry") || err.to_string().contains("exit 1"));
1468    }
1469
1470    #[test]
1471    fn finish_reboot_with_password_treats_ssh_drop_as_ok() {
1472        for stderr in [
1473            "Connection closed by remote host",
1474            "Connection reset by peer",
1475            "Broken pipe",
1476            "ssh: exit 255",
1477        ] {
1478            let runner = ScriptedRunner::default();
1479            runner.push("ssh", ScriptedRunner::fail(255, stderr));
1480            finish_remote_reboot(&runner, &test_session(), "y", Some("pw")).unwrap();
1481        }
1482    }
1483
1484    #[test]
1485    fn remote_reboot_with_sudo_password_wrapper() {
1486        let runner = ScriptedRunner::default();
1487        runner.push("ssh", ScriptedRunner::ok(""));
1488        let opts = RemoteOptions {
1489            host: "box".into(),
1490            ..RemoteOptions::default()
1491        };
1492        remote_reboot_with_sudo_password(&runner, &opts, "secret").unwrap();
1493        let calls = runner.calls.lock().unwrap();
1494        assert!(calls[0].1.iter().any(|a| a.contains("sudo -S reboot")));
1495        drop(calls);
1496    }
1497
1498    #[test]
1499    fn remote_ensure_ssh_key_and_reboot_wrappers() {
1500        crate::remote::ssh::tests::with_fake_default_pubkey(|_| {
1501            let runner = ScriptedRunner::default();
1502            // Probe: already authorized → skip ssh-copy-id.
1503            runner.push("ssh", ScriptedRunner::ok(""));
1504            let opts = RemoteOptions {
1505                host: "box".into(),
1506                ..RemoteOptions::default()
1507            };
1508            remote_ensure_ssh_key(&runner, &opts).unwrap();
1509
1510            let runner2 = ScriptedRunner::default();
1511            runner2.push("ssh", ScriptedRunner::ok(""));
1512            remote_reboot(&runner2, &opts).unwrap();
1513        });
1514    }
1515
1516    #[test]
1517    fn remote_run_offers_reboot_on_success_non_tty() {
1518        let stubs = bin_dir_with_stubs();
1519        let runner = ScriptedRunner::default();
1520        runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
1521        push_cli_probes_missing(&runner);
1522        runner.push("ssh", ScriptedRunner::ok(""));
1523        runner.push("scp", ScriptedRunner::ok(""));
1524        runner.push("ssh", ScriptedRunner::ok(""));
1525        runner.push("ssh", ScriptedRunner::ok("done\n"));
1526        remote_run_cli(
1527            &runner,
1528            &RemoteRunRequest {
1529                options: RemoteOptions {
1530                    host: "box".into(),
1531                    bin_dir: Some(stubs.path().to_path_buf()),
1532                    ..RemoteOptions::default()
1533                },
1534                cli_args: vec!["doctor".into()],
1535                flags: RemoteRunFlags {
1536                    use_sudo: false,
1537                    install_payload_on_success: false,
1538                    offer_reboot_on_success: true,
1539                    capture_output: false,
1540                },
1541                ecosystem: EcosystemInstallChoice::none(),
1542            },
1543        )
1544        .unwrap();
1545    }
1546}