Skip to main content

horto_os_ui_shared/remote/
ecosystem.rs

1//! Shared status-api + MCP install (remote payload and embedded full apply).
2
3use super::bins::{resolve_local_ecosystem_bins, LocalBins};
4use super::process::{ProcessRunner, StdioMode};
5use crate::error::{HortoError, Result};
6use std::fmt::Write;
7use std::fs;
8use std::io::Write as IoWrite;
9use std::path::{Path, PathBuf};
10
11/// Default install prefix for box binaries and unit `ExecStart` paths.
12pub const DEFAULT_INSTALL_DIR: &str = "/usr/local/bin";
13
14/// Which ecosystem services to install (user opt-in, default neither).
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub struct EcosystemInstallChoice {
17    /// Install/enable `horto-os-ui-status-api`.
18    pub status_api: bool,
19    /// Install/enable `horto-os-ui-mcp`.
20    pub mcp: bool,
21}
22
23impl EcosystemInstallChoice {
24    /// Neither service.
25    #[must_use]
26    pub const fn none() -> Self {
27        Self {
28            status_api: false,
29            mcp: false,
30        }
31    }
32
33    /// True when at least one service is selected.
34    #[must_use]
35    pub const fn any(self) -> bool {
36        self.status_api || self.mcp
37    }
38}
39
40/// Status-api systemd unit body (`ExecStart` defaults to `/usr/local/bin`).
41pub const STATUS_API_UNIT: &str = r"[Unit]
42Description=Horto OS UI status API
43After=network.target
44
45[Service]
46Type=simple
47EnvironmentFile=/etc/horto-os-ui/api.env
48ExecStart=/usr/local/bin/horto-os-ui-status-api --bind 0.0.0.0:8787
49Restart=on-failure
50
51[Install]
52WantedBy=multi-user.target
53";
54
55/// MCP systemd unit from packaging (kept in sync via `include_str!`).
56///
57/// The packaged unit loads `/etc/horto-os-ui/api.env` (shared bearer) and optional
58/// `mcp.env`. Installing MCP alone does not create `api.env`; status-api install does.
59pub const MCP_UNIT: &str =
60    include_str!("../../../horto-os-ui-mcp/packaging/horto-os-ui-mcp.service");
61
62/// User-owned drop file basename for token capture (remote).
63pub const API_TOKEN_DROP_BASENAME: &str = ".horto-os-ui-api-token";
64
65/// Ensure `/etc/horto-os-ui/api.env` exists with a random bearer token (0600).
66///
67/// Reuses an existing file so reinstall does not rotate the token. Writes the
68/// hex into `$HOME/.horto-os-ui-api-token` (0600) for PC-side Capture (no TTY echo).
69pub const ENSURE_API_TOKEN_SCRIPT: &str = r#"
70set -e
71sudo mkdir -p /etc/horto-os-ui
72if [ ! -f /etc/horto-os-ui/api.env ]; then
73  TOKEN=$(openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')
74  printf 'HORTO_API_TOKEN=%s\n' "$TOKEN" | sudo tee /etc/horto-os-ui/api.env >/dev/null
75  sudo chmod 600 /etc/horto-os-ui/api.env
76fi
77DROP="${HOME}/.horto-os-ui-api-token"
78sudo grep '^HORTO_API_TOKEN=' /etc/horto-os-ui/api.env > "$DROP"
79chmod 600 "$DROP"
80"#;
81
82/// Rewrite default `/usr/local/bin/` `ExecStart` paths for a custom install prefix.
83#[must_use]
84pub fn unit_with_install_dir(unit: &str, install_dir: &str) -> String {
85    let install = install_dir.trim_end_matches('/');
86    if install == DEFAULT_INSTALL_DIR {
87        return unit.to_owned();
88    }
89    unit.replace("/usr/local/bin/", &format!("{install}/"))
90}
91
92/// Shell fragment: install selected bins/units from `staging`.
93///
94/// Always installs CLI + TUI. Adds status-api / MCP bins and units per `choice`.
95/// `staging` already contains the binary basenames.
96#[must_use]
97pub fn remote_enable_ecosystem_script(
98    staging: &str,
99    install_dir: &str,
100    choice: EcosystemInstallChoice,
101) -> String {
102    let install = install_dir.trim_end_matches('/');
103    let staging = staging.trim_end_matches('/');
104    let api_unit_path = "/etc/systemd/system/horto-os-ui-status-api.service";
105    let mcp_unit_path = "/etc/systemd/system/horto-os-ui-mcp.service";
106    let api_unit = unit_with_install_dir(STATUS_API_UNIT, install);
107    let mcp_unit = unit_with_install_dir(MCP_UNIT, install);
108
109    let mut bin_names = vec!["horto-os-ui", "horto-os-ui-tui"];
110    if choice.status_api {
111        bin_names.push("horto-os-ui-status-api");
112    }
113    if choice.mcp {
114        bin_names.push("horto-os-ui-mcp");
115    }
116    let bins = bin_names
117        .iter()
118        .map(|n| format!("{staging}/{n}"))
119        .collect::<Vec<_>>()
120        .join(" ");
121
122    let mut script = String::new();
123    if choice.status_api {
124        script.push_str(ENSURE_API_TOKEN_SCRIPT);
125        script.push('\n');
126        let _ = write!(
127            script,
128            "sudo tee {api_unit_path} > /dev/null <<'HORTO_UNIT_EOF'\n{api_unit}HORTO_UNIT_EOF\n"
129        );
130    }
131    if choice.mcp {
132        let _ = write!(
133            script,
134            "sudo tee {mcp_unit_path} > /dev/null <<'HORTO_UNIT_EOF'\n{mcp_unit}HORTO_UNIT_EOF\n"
135        );
136    }
137    let _ = write!(script, "sudo install -m 755 {bins} {install}/");
138    script.push_str(" && sudo systemctl daemon-reload");
139    if choice.status_api {
140        script.push_str(" && sudo systemctl enable --now horto-os-ui-status-api.service");
141    }
142    if choice.mcp {
143        script.push_str(" && sudo systemctl enable --now horto-os-ui-mcp.service");
144    }
145    if install != DEFAULT_INSTALL_DIR {
146        script.push_str(" && sudo systemctl daemon-reload");
147        if choice.status_api {
148            script.push_str(" && sudo systemctl restart horto-os-ui-status-api.service");
149        }
150        if choice.mcp {
151            script.push_str(" && sudo systemctl restart horto-os-ui-mcp.service");
152        }
153    }
154    script
155}
156fn api_env_path(etc_root: &Path) -> PathBuf {
157    etc_root.join("horto-os-ui").join("api.env")
158}
159
160/// Create or reuse `horto-os-ui/api.env` under `etc_root` and return the bearer hex.
161///
162/// Production passes `etc_root = /etc`. Tests pass a temp directory so CI never
163/// writes the real host `/etc`.
164///
165/// Behavior:
166/// - Missing file → generate a random hex token, write `HORTO_API_TOKEN=…` mode 0600
167/// - Existing file → keep it (reinstall must not rotate the bearer)
168/// - File present but value not valid hex → `Ok(None)`
169///
170/// # Errors
171///
172/// Returns [`crate::HortoError`] when the directory or file cannot be created/read.
173fn ensure_api_env_at(etc_root: &Path) -> Result<Option<String>> {
174    let dir = etc_root.join("horto-os-ui");
175    fs::create_dir_all(&dir)
176        .map_err(|e| HortoError::msg(format!("create {}: {e}", dir.display())))?;
177    let env_path = api_env_path(etc_root);
178    if !env_path.is_file() {
179        let token = random_hex_token();
180        write_mode_600(&env_path, &format!("HORTO_API_TOKEN={token}\n"))?;
181    }
182    let raw = fs::read_to_string(&env_path)
183        .map_err(|e| HortoError::msg(format!("read {}: {e}", env_path.display())))?;
184    Ok(parse_api_token_line(&raw))
185}
186
187fn parse_api_token_line(raw: &str) -> Option<String> {
188    let line = raw.lines().map(str::trim).find(|l| !l.is_empty())?;
189    let hex = line.strip_prefix("HORTO_API_TOKEN=").unwrap_or(line).trim();
190    if hex.is_empty() || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
191        return None;
192    }
193    Some(hex.to_owned())
194}
195
196fn random_hex_token() -> String {
197    use std::io::Read;
198    let mut buf = [0u8; 32];
199    if let Ok(mut f) = fs::File::open("/dev/urandom") {
200        let _ = f.read_exact(&mut buf);
201    } else {
202        use std::time::{SystemTime, UNIX_EPOCH};
203        let nanos = SystemTime::now()
204            .duration_since(UNIX_EPOCH)
205            .map_or(0, |d| d.as_nanos());
206        for (i, b) in buf.iter_mut().enumerate() {
207            *b = ((nanos >> ((i % 16) * 8)) & 0xff) as u8;
208        }
209    }
210    buf.iter()
211        .fold(String::with_capacity(buf.len() * 2), |mut s, b| {
212            let _ = write!(s, "{b:02x}");
213            s
214        })
215}
216
217fn write_mode_600(path: &Path, body: &str) -> Result<()> {
218    #[cfg(unix)]
219    {
220        use std::os::unix::fs::OpenOptionsExt;
221        let mut f = fs::OpenOptions::new()
222            .write(true)
223            .create(true)
224            .truncate(true)
225            .mode(0o600)
226            .open(path)
227            .map_err(|e| HortoError::msg(format!("write {}: {e}", path.display())))?;
228        f.write_all(body.as_bytes())
229            .map_err(|e| HortoError::msg(format!("write {}: {e}", path.display())))?;
230    }
231    #[cfg(not(unix))]
232    {
233        fs::write(path, body)
234            .map_err(|e| HortoError::msg(format!("write {}: {e}", path.display())))?;
235    }
236    Ok(())
237}
238
239fn write_unit_file(path: &Path, body: &str) -> Result<()> {
240    if let Some(parent) = path.parent() {
241        fs::create_dir_all(parent)
242            .map_err(|e| HortoError::msg(format!("create {}: {e}", parent.display())))?;
243    }
244    fs::write(path, body).map_err(|e| HortoError::msg(format!("write {}: {e}", path.display())))?;
245    Ok(())
246}
247
248fn install_bin(src: &Path, dest: &Path) -> Result<()> {
249    if let Some(parent) = dest.parent() {
250        fs::create_dir_all(parent)
251            .map_err(|e| HortoError::msg(format!("create {}: {e}", parent.display())))?;
252    }
253    fs::copy(src, dest).map_err(|e| {
254        HortoError::msg(format!(
255            "install {} → {}: {e}",
256            src.display(),
257            dest.display()
258        ))
259    })?;
260    #[cfg(unix)]
261    {
262        use std::os::unix::fs::PermissionsExt;
263        fs::set_permissions(dest, fs::Permissions::from_mode(0o755))
264            .map_err(|e| HortoError::msg(format!("chmod {}: {e}", dest.display())))?;
265    }
266    Ok(())
267}
268
269/// Install selected ecosystem bins into `install_dir` and enable chosen units.
270///
271/// Always copies CLI + TUI. Copies status-api / MCP and enables units per `choice`.
272/// Intended for embedded full apply (already root). Writes units and `api.env`
273/// under the real host `/etc`.
274///
275/// # Errors
276///
277/// Returns [`crate::HortoError`] when install or `systemctl` fails.
278pub fn install_ecosystem_services(
279    runner: &dyn ProcessRunner,
280    bins: &LocalBins,
281    install_dir: &Path,
282    choice: EcosystemInstallChoice,
283) -> Result<Option<String>> {
284    install_ecosystem_services_at(runner, bins, install_dir, choice, Path::new("/etc"))
285}
286
287/// Same as [`install_ecosystem_services`], with injectable `etc_root` for tests.
288///
289/// Layout under `etc_root`:
290/// - `horto-os-ui/api.env` when `choice.status_api`
291/// - `systemd/system/*.service` for selected units
292fn install_ecosystem_services_at(
293    runner: &dyn ProcessRunner,
294    bins: &LocalBins,
295    install_dir: &Path,
296    choice: EcosystemInstallChoice,
297    etc_root: &Path,
298) -> Result<Option<String>> {
299    let choice = EcosystemInstallChoice {
300        status_api: choice.status_api,
301        mcp: choice.mcp && bins.mcp.is_some(),
302    };
303    if !choice.any() {
304        return Ok(None);
305    }
306    let install = install_dir
307        .to_str()
308        .ok_or_else(|| HortoError::msg("non-utf8 install_dir"))?;
309
310    let token = if choice.status_api {
311        ensure_api_env_at(etc_root)?
312    } else {
313        None
314    };
315
316    let mut pairs: Vec<(&PathBuf, &str)> =
317        vec![(&bins.cli, "horto-os-ui"), (&bins.tui, "horto-os-ui-tui")];
318    if choice.status_api {
319        pairs.push((&bins.status_api, "horto-os-ui-status-api"));
320    }
321    if let Some(mcp) = bins.mcp.as_ref() {
322        if choice.mcp {
323            pairs.push((mcp, "horto-os-ui-mcp"));
324        }
325    }
326    for (src, name) in pairs {
327        let dest = install_dir.join(name);
328        if src != &dest || !dest.is_file() {
329            install_bin(src, &dest)?;
330        }
331    }
332
333    let unit_dir = etc_root.join("systemd").join("system");
334    if choice.status_api {
335        let api_unit = unit_with_install_dir(STATUS_API_UNIT, install);
336        write_unit_file(&unit_dir.join("horto-os-ui-status-api.service"), &api_unit)?;
337    }
338    if choice.mcp {
339        let mcp_unit = unit_with_install_dir(MCP_UNIT, install);
340        write_unit_file(&unit_dir.join("horto-os-ui-mcp.service"), &mcp_unit)?;
341    }
342
343    let mut sys_args: Vec<&[&str]> = vec![&["daemon-reload"]];
344    if choice.status_api {
345        sys_args.push(&["enable", "--now", "horto-os-ui-status-api.service"]);
346    }
347    if choice.mcp {
348        sys_args.push(&["enable", "--now", "horto-os-ui-mcp.service"]);
349    }
350    for args in sys_args {
351        let out = runner.run("systemctl", args, &[], StdioMode::Capture)?;
352        if !out.success() {
353            let detail = if out.stderr.trim().is_empty() {
354                out.stdout.trim()
355            } else {
356                out.stderr.trim()
357            };
358            return Err(HortoError::command(
359                "systemctl",
360                format!("exit {}: {detail}", out.status),
361            ));
362        }
363    }
364
365    Ok(token)
366}
367
368/// Resolve local bins and install selected ecosystem services after embedded full apply.
369///
370/// # Errors
371///
372/// Returns [`crate::HortoError`] when bins cannot be resolved or install fails.
373pub fn install_ecosystem_after_embedded_apply(
374    runner: &dyn ProcessRunner,
375    install_dir: &Path,
376    choice: EcosystemInstallChoice,
377) -> Result<Option<String>> {
378    if !choice.any() {
379        return Ok(None);
380    }
381    let bins = resolve_local_ecosystem_bins(install_dir)?;
382    install_ecosystem_services(runner, &bins, install_dir, choice)
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::remote::bins::BOX_BIN_NAMES;
389    use crate::remote::process::ScriptedRunner;
390    use tempfile::TempDir;
391
392    #[test]
393    fn unit_rewrite_custom_prefix() {
394        let u = unit_with_install_dir(STATUS_API_UNIT, "/opt/horto/bin");
395        assert!(u.contains("/opt/horto/bin/horto-os-ui-status-api"));
396        assert!(!u.contains("/usr/local/bin/horto-os-ui-status-api"));
397        let m = unit_with_install_dir(MCP_UNIT, "/opt/horto/bin");
398        assert!(m.contains("/opt/horto/bin/horto-os-ui-mcp"));
399        assert!(MCP_UNIT.contains("horto-os-ui-status-api.service"));
400    }
401
402    #[test]
403    fn remote_enable_script_mentions_both_units_and_mcp_bin() {
404        let both = EcosystemInstallChoice {
405            status_api: true,
406            mcp: true,
407        };
408        let s = remote_enable_ecosystem_script("/tmp/stage", "/usr/local/bin", both);
409        assert!(s.contains("horto-os-ui-status-api.service"));
410        assert!(s.contains("horto-os-ui-mcp.service"));
411        assert!(s.contains("horto-os-ui-mcp"));
412        assert!(s.contains("enable --now horto-os-ui-mcp.service"));
413        assert!(!s.contains("sudo sed"));
414    }
415
416    #[test]
417    fn remote_enable_script_api_only_skips_mcp_unit() {
418        let api = EcosystemInstallChoice {
419            status_api: true,
420            mcp: false,
421        };
422        let s = remote_enable_ecosystem_script("/tmp/stage", "/usr/local/bin", api);
423        assert!(s.contains("horto-os-ui-status-api.service"));
424        assert!(!s.contains("horto-os-ui-mcp.service"));
425    }
426
427    #[test]
428    fn remote_enable_script_custom_prefix_restarts() {
429        let both = EcosystemInstallChoice {
430            status_api: true,
431            mcp: true,
432        };
433        let s = remote_enable_ecosystem_script("/tmp/stage", "/opt/horto/bin", both);
434        assert!(s.contains("/opt/horto/bin/horto-os-ui-mcp"));
435        assert!(s.contains("restart horto-os-ui-mcp.service"));
436    }
437
438    #[test]
439    fn remote_enable_script_mcp_only_skips_api_token() {
440        let mcp = EcosystemInstallChoice {
441            status_api: false,
442            mcp: true,
443        };
444        let s = remote_enable_ecosystem_script("/tmp/stage", "/usr/local/bin", mcp);
445        assert!(!s.contains("openssl rand"));
446        assert!(!s.contains(API_TOKEN_DROP_BASENAME));
447        assert!(s.contains("horto-os-ui-mcp.service"));
448        assert!(!s.contains("enable --now horto-os-ui-status-api.service"));
449    }
450
451    #[test]
452    fn parse_api_token_line_accepts_and_rejects() {
453        assert_eq!(
454            parse_api_token_line("HORTO_API_TOKEN=aabb\n").as_deref(),
455            Some("aabb")
456        );
457        assert_eq!(
458            parse_api_token_line("deadbeef\n").as_deref(),
459            Some("deadbeef")
460        );
461        assert!(parse_api_token_line("").is_none());
462        assert!(parse_api_token_line("HORTO_API_TOKEN=\n").is_none());
463        assert!(parse_api_token_line("not-hex!\n").is_none());
464    }
465
466    #[test]
467    fn choice_helpers() {
468        assert!(!EcosystemInstallChoice::none().any());
469        assert!(EcosystemInstallChoice {
470            status_api: true,
471            mcp: false
472        }
473        .any());
474        assert!(EcosystemInstallChoice {
475            status_api: false,
476            mcp: true
477        }
478        .any());
479    }
480
481    #[test]
482    fn install_ecosystem_services_none_is_noop() {
483        let tmp = TempDir::new().unwrap();
484        let bins = LocalBins {
485            dir: tmp.path().to_path_buf(),
486            cli: tmp.path().join("horto-os-ui"),
487            tui: tmp.path().join("horto-os-ui-tui"),
488            status_api: tmp.path().join("horto-os-ui-status-api"),
489            mcp: Some(tmp.path().join("horto-os-ui-mcp")),
490        };
491        let runner = ScriptedRunner::default();
492        let token = install_ecosystem_services_at(
493            &runner,
494            &bins,
495            tmp.path(),
496            EcosystemInstallChoice::none(),
497            tmp.path().join("etc").as_path(),
498        )
499        .unwrap();
500        assert!(token.is_none());
501        assert_eq!(runner.calls.lock().unwrap().as_slice(), &[]);
502    }
503
504    #[test]
505    fn install_ecosystem_services_copies_and_enables_both() {
506        let tmp = TempDir::new().unwrap();
507        let src = tmp.path().join("src");
508        let dest = tmp.path().join("dest");
509        let etc = tmp.path().join("etc");
510        fs::create_dir_all(&src).unwrap();
511        fs::create_dir_all(&dest).unwrap();
512        for name in BOX_BIN_NAMES {
513            fs::write(src.join(name), b"bin").unwrap();
514        }
515        let bins = LocalBins {
516            dir: src.clone(),
517            cli: src.join("horto-os-ui"),
518            tui: src.join("horto-os-ui-tui"),
519            status_api: src.join("horto-os-ui-status-api"),
520            mcp: Some(src.join("horto-os-ui-mcp")),
521        };
522        let runner = ScriptedRunner::default();
523        runner.push("systemctl", ScriptedRunner::ok(""));
524        runner.push("systemctl", ScriptedRunner::ok(""));
525        runner.push("systemctl", ScriptedRunner::ok(""));
526
527        let token = install_ecosystem_services_at(
528            &runner,
529            &bins,
530            &dest,
531            EcosystemInstallChoice {
532                status_api: true,
533                mcp: true,
534            },
535            &etc,
536        )
537        .unwrap();
538        assert!(token.is_some());
539        assert!(token
540            .as_ref()
541            .unwrap()
542            .chars()
543            .all(|c| c.is_ascii_hexdigit()));
544        for name in BOX_BIN_NAMES {
545            assert!(dest.join(name).is_file(), "missing {name}");
546        }
547        assert!(etc
548            .join("systemd/system/horto-os-ui-status-api.service")
549            .is_file());
550        assert!(etc.join("systemd/system/horto-os-ui-mcp.service").is_file());
551        assert!(etc.join("horto-os-ui/api.env").is_file());
552        // Reuse token on second install
553        let runner2 = ScriptedRunner::default();
554        runner2.push("systemctl", ScriptedRunner::ok(""));
555        runner2.push("systemctl", ScriptedRunner::ok(""));
556        runner2.push("systemctl", ScriptedRunner::ok(""));
557        let token2 = install_ecosystem_services_at(
558            &runner2,
559            &bins,
560            &dest,
561            EcosystemInstallChoice {
562                status_api: true,
563                mcp: true,
564            },
565            &etc,
566        )
567        .unwrap();
568        assert_eq!(token, token2);
569    }
570
571    #[test]
572    fn install_ecosystem_services_mcp_only_skips_api_env() {
573        let tmp = TempDir::new().unwrap();
574        let src = tmp.path().join("src");
575        let dest = tmp.path().join("dest");
576        let etc = tmp.path().join("etc");
577        fs::create_dir_all(&src).unwrap();
578        for name in BOX_BIN_NAMES {
579            fs::write(src.join(name), b"bin").unwrap();
580        }
581        let bins = LocalBins {
582            dir: src.clone(),
583            cli: src.join("horto-os-ui"),
584            tui: src.join("horto-os-ui-tui"),
585            status_api: src.join("horto-os-ui-status-api"),
586            mcp: Some(src.join("horto-os-ui-mcp")),
587        };
588        let runner = ScriptedRunner::default();
589        runner.push("systemctl", ScriptedRunner::ok(""));
590        runner.push("systemctl", ScriptedRunner::ok(""));
591
592        let token = install_ecosystem_services_at(
593            &runner,
594            &bins,
595            &dest,
596            EcosystemInstallChoice {
597                status_api: false,
598                mcp: true,
599            },
600            &etc,
601        )
602        .unwrap();
603        assert!(token.is_none());
604        assert!(dest.join("horto-os-ui-mcp").is_file());
605        assert!(!dest.join("horto-os-ui-status-api").exists());
606        assert!(!etc.join("horto-os-ui/api.env").exists());
607        assert!(etc.join("systemd/system/horto-os-ui-mcp.service").is_file());
608        assert!(!etc
609            .join("systemd/system/horto-os-ui-status-api.service")
610            .exists());
611    }
612
613    #[test]
614    fn install_ecosystem_services_systemctl_failure() {
615        let tmp = TempDir::new().unwrap();
616        let src = tmp.path().join("src");
617        let dest = tmp.path().join("dest");
618        let etc = tmp.path().join("etc");
619        fs::create_dir_all(&src).unwrap();
620        for name in BOX_BIN_NAMES {
621            fs::write(src.join(name), b"bin").unwrap();
622        }
623        let bins = LocalBins {
624            dir: src.clone(),
625            cli: src.join("horto-os-ui"),
626            tui: src.join("horto-os-ui-tui"),
627            status_api: src.join("horto-os-ui-status-api"),
628            mcp: Some(src.join("horto-os-ui-mcp")),
629        };
630        let runner = ScriptedRunner::default();
631        runner.push("systemctl", ScriptedRunner::fail(1, "nope"));
632
633        let err = install_ecosystem_services_at(
634            &runner,
635            &bins,
636            &dest,
637            EcosystemInstallChoice {
638                status_api: true,
639                mcp: false,
640            },
641            &etc,
642        )
643        .unwrap_err();
644        assert!(err.to_string().contains("systemctl"));
645    }
646
647    #[test]
648    fn install_after_embedded_none_skips_resolve() {
649        let tmp = TempDir::new().unwrap();
650        let runner = ScriptedRunner::default();
651        let token = install_ecosystem_after_embedded_apply(
652            &runner,
653            tmp.path(),
654            EcosystemInstallChoice::none(),
655        )
656        .unwrap();
657        assert!(token.is_none());
658    }
659
660    #[test]
661    fn random_hex_token_is_64_hex_chars() {
662        let t = random_hex_token();
663        assert_eq!(t.len(), 64);
664        assert!(t.chars().all(|c| c.is_ascii_hexdigit()));
665    }
666}