Skip to main content

horto_os_ui_mcp/
remote_ops.rs

1//! Privileged ops via the shared OpenSSH remote runner (`HORTO_MCP_MODE=pc`).
2
3use anyhow::{bail, Context, Result};
4use horto_os_ui_shared::{
5    remote_probe_arch, remote_run_cli, remote_setup_run, EcosystemInstallChoice, RemoteOptions,
6    RemoteOptionsInput, RemoteRunFlags, RemoteRunRequest, SystemProcessRunner,
7};
8
9use crate::config::McpSettings;
10
11fn remote_options(settings: &McpSettings) -> Result<RemoteOptions> {
12    let host = settings
13        .remote_host
14        .clone()
15        .context("HORTO_REMOTE_HOST required for remote tools (OpenSSH Host alias or user@host)")?;
16    Ok(RemoteOptions::from_input(RemoteOptionsInput {
17        host,
18        install_ssh_key: settings.install_ssh_key,
19        bin_dir: settings.bin_dir.clone(),
20        release_tag: settings.release_tag.clone(),
21        force_askpass: true,
22    }))
23}
24
25fn run_cli(settings: &McpSettings, args: &[&str], use_sudo: bool) -> Result<String> {
26    let opts = remote_options(settings)?;
27    let cli_args: Vec<String> = args.iter().map(|s| (*s).to_owned()).collect();
28    remote_run_cli(
29        &SystemProcessRunner,
30        &RemoteRunRequest {
31            options: opts,
32            cli_args,
33            flags: RemoteRunFlags {
34                use_sudo,
35                install_payload_on_success: false,
36                offer_reboot_on_success: false,
37                capture_output: false,
38            },
39            ecosystem: EcosystemInstallChoice::none(),
40        },
41    )
42    .map(|o| o.log)
43    .map_err(|e| anyhow::anyhow!("{e}"))
44}
45
46/// Probe box arch over SSH.
47///
48/// # Errors
49///
50/// Returns when SSH or uname fails.
51pub fn remote_probe(settings: &McpSettings) -> Result<String> {
52    let opts = remote_options(settings)?;
53    remote_probe_arch(&SystemProcessRunner, &opts)
54        .map(|a| a.cache_label().to_owned())
55        .map_err(|e| anyhow::anyhow!("{e}"))
56}
57
58/// Remote `setup status`.
59///
60/// # Errors
61///
62/// Returns on remote runner failure.
63pub fn setup_status(settings: &McpSettings, apply: bool, full: bool) -> Result<String> {
64    let mut args = Vec::new();
65    if apply {
66        args.push("--apply");
67    }
68    args.push("setup");
69    args.push("status");
70    if full {
71        args.push("--full");
72    } else {
73        args.push("--minimal");
74    }
75    run_cli(settings, &args, false)
76}
77
78/// Remote `setup run` (optional payload install when apply).
79///
80/// # Errors
81///
82/// Returns on remote runner failure.
83pub fn setup_run(
84    settings: &McpSettings,
85    apply: bool,
86    full: bool,
87    skip_piper: bool,
88) -> Result<String> {
89    let opts = remote_options(settings)?;
90    remote_setup_run(
91        &SystemProcessRunner,
92        opts,
93        apply,
94        full,
95        skip_piper,
96        EcosystemInstallChoice::none(),
97    )
98    .map(|o| o.log)
99    .map_err(|e| anyhow::anyhow!("{e}"))
100}
101
102/// Remote `setup step`.
103///
104/// # Errors
105///
106/// Returns on remote runner failure.
107pub fn setup_step(
108    settings: &McpSettings,
109    step_id: &str,
110    apply: bool,
111    full: bool,
112) -> Result<String> {
113    let kind = if full { "full" } else { "minimal" };
114    let mut args = Vec::new();
115    if apply {
116        args.push("--apply");
117    }
118    args.push("setup");
119    args.push("step");
120    args.push(step_id);
121    args.push(kind);
122    run_cli(settings, &args, apply)
123}
124
125/// Remote `doctor`.
126///
127/// # Errors
128///
129/// Returns on remote runner failure.
130pub fn doctor(settings: &McpSettings) -> Result<String> {
131    run_cli(settings, &["doctor"], false)
132}
133
134/// Remote `docker status`.
135///
136/// # Errors
137///
138/// Returns on remote runner failure.
139pub fn docker_status(settings: &McpSettings) -> Result<String> {
140    run_cli(settings, &["docker", "status"], false)
141}
142
143/// Remote `docker rebuild` (confirm required by caller).
144///
145/// # Errors
146///
147/// Returns when confirm is wrong or remote fails.
148pub fn docker_rebuild(settings: &McpSettings, confirm: &str) -> Result<String> {
149    if confirm != "docker-rebuild" {
150        bail!("confirm must be exactly `docker-rebuild`");
151    }
152    run_cli(settings, &["docker", "rebuild"], true)
153}
154
155/// Remote `backup list`.
156///
157/// # Errors
158///
159/// Returns on remote runner failure.
160pub fn backup_list(settings: &McpSettings) -> Result<String> {
161    run_cli(settings, &["backup", "list"], false)
162}
163
164/// Remote `backup disk-status`.
165///
166/// # Errors
167///
168/// Returns on remote runner failure.
169pub fn backup_disk_status(settings: &McpSettings) -> Result<String> {
170    run_cli(settings, &["backup", "disk-status"], false)
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn docker_rebuild_rejects_bad_confirm() {
179        let settings = McpSettings {
180            mode: crate::config::McpMode::Pc,
181            status_api_url: "http://127.0.0.1:8787".into(),
182            api_token: None,
183            mcp_token: None,
184            remote_host: Some("horto".into()),
185            release_tag: None,
186            bin_dir: None,
187            install_ssh_key: false,
188        };
189        let err = docker_rebuild(&settings, "nope").expect_err("confirm");
190        assert!(err.to_string().contains("docker-rebuild"));
191    }
192
193    #[test]
194    fn remote_options_require_host() {
195        let settings = McpSettings {
196            mode: crate::config::McpMode::Pc,
197            status_api_url: "http://127.0.0.1:8787".into(),
198            api_token: None,
199            mcp_token: None,
200            remote_host: None,
201            release_tag: None,
202            bin_dir: None,
203            install_ssh_key: false,
204        };
205        assert!(remote_options(&settings).is_err());
206    }
207}