Skip to main content

oxios_kernel/host_tools/
provisioner.rs

1//! Provisioner (RFC-041 Phase 4).
2//!
3//! Executes [`SkillInstallSpec`]s as a **privileged kernel operation** — the
4//! same pattern the clawhub/skills_sh installers use (`tokio::process::Command`
5//! directly), NOT the agent-sandbox `ExecTool`. Per D8, `ExecTool`'s allowlist
6//! and metacharacter blocking defend against *untrusted agent command strings*;
7//! here the command is kernel-constructed from a trusted registry spec, so those
8//! defenses are redundant. The security gate is user consent at the API layer
9//! + audit logging + registry-derived command (no free text → no injection).
10//!
11//! Covers the package-manager install kinds (`brew`/`node`/`bun`/`cargo`/
12//! `pip`/`go`/`uv`). The `download` kind fetches an archive and extracts it.
13
14use std::path::PathBuf;
15
16use anyhow::{Context, Result};
17use serde::Serialize;
18use tokio::process::Command;
19
20use crate::skill::{InstallKind, SkillInstallSpec};
21
22/// Outcome of an install attempt.
23#[derive(Debug, Clone, Serialize)]
24#[serde(rename_all = "camelCase")]
25pub struct InstallOutput {
26    /// Whether the command exited successfully.
27    pub success: bool,
28    /// Human-readable description of what was run, e.g. `brew install gh`.
29    pub command: String,
30    /// Combined stdout/stderr (truncated for log surface).
31    pub output: String,
32    /// Exit code, when available.
33    pub exit_code: Option<i32>,
34}
35
36/// Build the command line for a single spec (no spawn). Used by dry-run + tests.
37///
38/// Returns `None` when the spec lacks the field its kind needs (e.g. a `brew`
39/// spec without `formula`) — the provisioner skips such specs.
40pub fn build_command(spec: &SkillInstallSpec) -> Option<(String, Vec<String>)> {
41    let (bin, args): (&str, Vec<String>) = match spec.kind {
42        InstallKind::Brew => {
43            let formula = spec
44                .formula
45                .as_deref()
46                .context("brew spec missing formula")
47                .ok()?;
48            ("brew", vec!["install".into(), formula.into()])
49        }
50        InstallKind::Node => {
51            let pkg = spec
52                .package
53                .as_deref()
54                .context("node spec missing package")
55                .ok()?;
56            ("npm", vec!["install".into(), "-g".into(), pkg.into()])
57        }
58        InstallKind::Bun => {
59            let pkg = spec
60                .package
61                .as_deref()
62                .context("bun spec missing package")
63                .ok()?;
64            ("bun", vec!["install".into(), "-g".into(), pkg.into()])
65        }
66        InstallKind::Cargo => {
67            let pkg = spec
68                .package
69                .as_deref()
70                .context("cargo spec missing package")
71                .ok()?;
72            ("cargo", vec!["install".into(), pkg.into()])
73        }
74        InstallKind::Pip => {
75            let pkg = spec
76                .package
77                .as_deref()
78                .context("pip spec missing package")
79                .ok()?;
80            ("pip", vec!["install".into(), "--user".into(), pkg.into()])
81        }
82        InstallKind::Go => {
83            let module = spec
84                .module
85                .as_deref()
86                .context("go spec missing module")
87                .ok()?;
88            ("go", vec!["install".into(), module.into()])
89        }
90        InstallKind::Uv => {
91            let pkg = spec
92                .package
93                .as_deref()
94                .context("uv spec missing package")
95                .ok()?;
96            ("uv", vec!["tool".into(), "install".into(), pkg.into()])
97        }
98        InstallKind::Download => {
99            // Download is fetch+extract, not a single command — handled in
100            // `install_download`. No command line here.
101            return None;
102        }
103    };
104    Some((bin.to_string(), args))
105}
106
107/// Format a command for display.
108fn fmt_cmd(bin: &str, args: &[String]) -> String {
109    let mut s = String::from(bin);
110    for a in args {
111        s.push(' ');
112        s.push_str(a);
113    }
114    s
115}
116
117/// Run a package-manager install spec. Spawns the command directly (D8).
118pub async fn install_manager(spec: &SkillInstallSpec) -> Result<InstallOutput> {
119    let (bin, args) = build_command(spec)
120        .context("install spec is incomplete (missing formula/package/module)")?;
121    let cmd_str = fmt_cmd(&bin, &args);
122    tracing::info!(command = %cmd_str, "provisioning: running install");
123
124    let output = Command::new(&bin)
125        .args(&args)
126        .output()
127        .await
128        .with_context(|| format!("failed to spawn `{cmd_str}`"))?;
129
130    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
131    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
132    let combined = if stderr.is_empty() {
133        stdout
134    } else if stdout.is_empty() {
135        stderr
136    } else {
137        format!("{stdout}\n--- stderr ---\n{stderr}")
138    };
139    let success = output.status.success();
140    Ok(InstallOutput {
141        success,
142        command: cmd_str,
143        output: combined,
144        exit_code: output.status.code(),
145    })
146}
147
148/// Fetch a `download` spec's archive and extract it into `target_dir`.
149///
150/// Honors `strip_components`. Uses reqwest + tar/flate2 (same crates the
151/// clawhub installer relies on). Returns an [`InstallOutput`] describing the
152/// result.
153pub async fn install_download(spec: &SkillInstallSpec) -> Result<InstallOutput> {
154    let url = spec.url.as_deref().context("download spec missing url")?;
155    let target = spec
156        .target_dir
157        .as_deref()
158        .map(PathBuf::from)
159        .unwrap_or_else(|| PathBuf::from("."));
160    let strip = spec.strip_components.unwrap_or(0) as usize;
161
162    tracing::info!(url = %url, target = %target.display(), "provisioning: downloading archive");
163    let resp = reqwest::get(url)
164        .await
165        .context("fetching download archive")?;
166    let bytes = resp
167        .bytes()
168        .await
169        .context("reading download archive body")?;
170
171    // Determine archive type by extension and extract.
172    std::fs::create_dir_all(&target).with_context(|| format!("creating {}", target.display()))?;
173    let is_gz = url.ends_with(".gz") || url.ends_with(".tgz");
174    let is_tar = url.ends_with(".tar") || url.ends_with(".tar.gz") || url.ends_with(".tgz");
175
176    let archive_bytes: &[u8] = &bytes;
177    let decoder;
178    if is_tar && is_gz {
179        decoder = flate2::read::GzDecoder::new(archive_bytes);
180        let mut tar = tar::Archive::new(decoder);
181        tar.set_overwrite(true);
182        unpack_with_strip(&mut tar, &target, strip)?;
183    } else if is_tar {
184        let mut tar = tar::Archive::new(archive_bytes);
185        tar.set_overwrite(true);
186        unpack_with_strip(&mut tar, &target, strip)?;
187    } else {
188        // Plain file: write the (possibly gunzipped) bytes to target/<basename>.
189        let name = url.rsplit('/').next().unwrap_or("download");
190        std::fs::write(target.join(name), &bytes).context("writing downloaded file")?;
191        let _ = archive_bytes; // silence unused borrow on non-tar path
192    }
193
194    Ok(InstallOutput {
195        success: true,
196        command: format!("download+extract {url} → {}", target.display()),
197        output: format!("extracted to {}", target.display()),
198        exit_code: None,
199    })
200}
201
202/// Unpack a tar archive, dropping the first `strip` path components of each entry.
203fn unpack_with_strip<R: std::io::Read>(
204    tar: &mut tar::Archive<R>,
205    target: &std::path::Path,
206    strip: usize,
207) -> Result<()> {
208    for entry in tar.entries()? {
209        let mut entry = entry.context("reading tar entry")?;
210        let path = entry.path()?.into_owned();
211        let mut comps = path.components();
212        for _ in 0..strip {
213            comps.next();
214        }
215        let stripped: PathBuf = comps.collect();
216        if stripped.as_os_str().is_empty() {
217            continue;
218        }
219        // Security: reject absolute / parent-traversal paths.
220        if stripped.is_absolute() || stripped.components().any(|c| c.as_os_str() == "..") {
221            anyhow::bail!("unsafe entry path in archive: {}", stripped.display());
222        }
223        let entry_type = entry.header().entry_type();
224        anyhow::ensure!(
225            entry_type.is_file() || entry_type.is_dir(),
226            "unsupported archive entry type for {}",
227            stripped.display()
228        );
229        let destination = target.join(&stripped);
230        if let Some(parent) = destination.parent() {
231            std::fs::create_dir_all(parent)
232                .with_context(|| format!("creating {}", parent.display()))?;
233        }
234        entry
235            .unpack(&destination)
236            .with_context(|| format!("unpacking {} to {}", stripped.display(), target.display()))?;
237    }
238    Ok(())
239}
240
241/// Run the first applicable spec from a list (the first whose kind's manager is
242/// available, else the first spec). Package-manager specs spawn directly;
243/// `download` specs fetch+extract.
244pub async fn install(specs: &[SkillInstallSpec]) -> Result<InstallOutput> {
245    anyhow::ensure!(!specs.is_empty(), "no install specs provided");
246    // Prefer the first spec that has a manager binary on PATH, else take [0].
247    let pick = specs
248        .iter()
249        .find(|s| manager_available(s.kind))
250        .or_else(|| specs.first())
251        .context("no install spec")?;
252    match pick.kind {
253        InstallKind::Download => install_download(pick).await,
254        _ => install_manager(pick).await,
255    }
256}
257
258/// Whether the package manager for an install kind is detectable on PATH.
259fn manager_available(kind: InstallKind) -> bool {
260    let bin = match kind {
261        InstallKind::Brew => "brew",
262        InstallKind::Node => "npm",
263        InstallKind::Bun => "bun",
264        InstallKind::Cargo => "cargo",
265        InstallKind::Pip => "pip3",
266        InstallKind::Go => "go",
267        InstallKind::Uv => "uv",
268        InstallKind::Download => return true, // uses reqwest, no manager needed
269    };
270    which_sync(bin).is_some()
271}
272
273/// Sync PATH lookup (private — mirrors scanner's bootstrap check).
274fn which_sync(name: &str) -> Option<PathBuf> {
275    let sep = if cfg!(windows) { ';' } else { ':' };
276    let path = std::env::var("PATH").ok()?;
277    for dir in path.split(sep) {
278        let p = PathBuf::from(dir);
279        if cfg!(windows) {
280            for ext in ["exe", "cmd", "bat"] {
281                let c = p.join(format!("{name}.{ext}"));
282                if c.is_file() {
283                    return Some(c);
284                }
285            }
286        } else if p.join(name).is_file() {
287            return Some(p.join(name));
288        }
289    }
290    None
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    fn spec(
298        kind: InstallKind,
299        formula: Option<&str>,
300        package: Option<&str>,
301        module: Option<&str>,
302    ) -> SkillInstallSpec {
303        SkillInstallSpec {
304            kind,
305            formula: formula.map(String::from),
306            package: package.map(String::from),
307            module: module.map(String::from),
308            url: None,
309            archive: None,
310            extract: None,
311            strip_components: None,
312            target_dir: None,
313            os: vec![],
314        }
315    }
316
317    #[test]
318    fn builds_brew_command() {
319        let s = spec(InstallKind::Brew, Some("gh"), None, None);
320        let (bin, args) = build_command(&s).unwrap();
321        assert_eq!(bin, "brew");
322        assert_eq!(args, vec!["install", "gh"]);
323    }
324
325    #[test]
326    fn builds_cargo_command() {
327        let s = spec(InstallKind::Cargo, None, Some("ripgrep"), None);
328        let (bin, args) = build_command(&s).unwrap();
329        assert_eq!(bin, "cargo");
330        assert_eq!(args, vec!["install", "ripgrep"]);
331    }
332
333    #[test]
334    fn builds_bun_command() {
335        let s = spec(InstallKind::Bun, None, Some("tsx"), None);
336        let (bin, args) = build_command(&s).unwrap();
337        assert_eq!(bin, "bun");
338        assert_eq!(args, vec!["install", "-g", "tsx"]);
339    }
340
341    #[test]
342    fn incomplete_spec_returns_none() {
343        // brew without formula → None
344        let s = spec(InstallKind::Brew, None, None, None);
345        assert!(build_command(&s).is_none());
346    }
347
348    #[test]
349    fn download_has_no_command() {
350        let s = spec(InstallKind::Download, None, None, None);
351        assert!(build_command(&s).is_none());
352    }
353
354    #[test]
355    fn unpack_applies_strip_components() {
356        use std::io::Cursor;
357
358        let mut builder = tar::Builder::new(Vec::new());
359        let contents = b"hello";
360        let mut header = tar::Header::new_gnu();
361        header.set_size(contents.len() as u64);
362        header.set_mode(0o644);
363        header.set_cksum();
364        builder
365            .append_data(&mut header, "tool-v1/bin/tool", Cursor::new(contents))
366            .unwrap();
367        let bytes = builder.into_inner().unwrap();
368
369        let tmp = tempfile::tempdir().unwrap();
370        let mut archive = tar::Archive::new(Cursor::new(bytes));
371        unpack_with_strip(&mut archive, tmp.path(), 1).unwrap();
372
373        assert_eq!(
374            std::fs::read(tmp.path().join("bin/tool")).unwrap(),
375            contents
376        );
377        assert!(!tmp.path().join("tool-v1/bin/tool").exists());
378    }
379
380    #[test]
381    fn fmt_cmd_joins_args() {
382        assert_eq!(
383            fmt_cmd("brew", &["install".into(), "gh".into()]),
384            "brew install gh"
385        );
386    }
387}