Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

tests/airgap_install_mcp.rs

//! EPIC AIRGAP (Skíðblaðnir) — the `airgap_install` MCP verb suite.
//!
//! `airgap_install` is the MCP counterpart of the `skidbladnir install <pkg>`
//! CLI (design §6.3, "both CLI + MCP"). It drives
//! `skidbladnir::install::install_prefix_payload` — verify-on-receipt → unfold
//! `software/<version>/` → atomic `current` flip → drop the relocated
//! `.cargo/config` → emit `rust-dev-env.sh` — for a rust-dev prefix payload,
//! adding NO install logic of its own.
//!
//! This suite drives the REAL `nornir-mcp` binary over its stdio JSON-RPC
//! transport:
//!
//!   * `airgap_install_registers_and_validates_args` (non-ignored) — the verb is
//!     registered with an agent-facing description + an inputSchema that carries
//!     the `bundle` property, and a call at a nonexistent bundle returns a clean
//!     tool error (the weak-LLM recovery requirement). No real toolchain needed.
//!   * `airgap_install_live_roundtrip` (`#[ignore]`) — a full install of a real
//!     bundle at `$NORNIR_AIRGAP_BUNDLE` into a temp prefix, asserting the
//!     unfolded `current` + env script come back in the outcome. Needs a real
//!     bundle on disk, so it is opt-in:
//!       NORNIR_AIRGAP_BUNDLE=/path/to/rust-dev-rhel8.tar.zst \
//!         cargo test --features mcp --test airgap_install_mcp -- --ignored --nocapture
//!
//!   cargo test --features mcp --test airgap_install_mcp
#![cfg(feature = "mcp")]

use std::io::{BufRead, BufReader, Write};
use std::process::{Child, Command, Stdio};

use serde_json::{Value, json};

const CONFIG_TOML: &str = r#"
[repo.nornir]
remote = "https://git.gunnar.rs/nordisk/nornir"
[storage]
local_path = ".nornir"
"#;

fn send(stdin: &mut impl Write, v: Value) {
    writeln!(stdin, "{v}").unwrap();
    stdin.flush().unwrap();
}

fn read_resp<R: BufRead + ?Sized>(reader: &mut R, id: i64) -> Value {
    for _ in 0..400 {
        let mut line = String::new();
        if reader.read_line(&mut line).unwrap() == 0 {
            panic!("nornir-mcp closed stdout before id={id}");
        }
        let line = line.trim();
        if line.is_empty() || !line.starts_with('{') {
            continue;
        }
        let Ok(v) = serde_json::from_str::<Value>(line) else {
            continue;
        };
        if v["id"].as_i64() == Some(id) {
            return v;
        }
    }
    panic!("no response for id={id}");
}

fn spawn_mcp() -> (Child, impl Write, impl BufRead, tempfile::TempDir) {
    let dir = tempfile::tempdir().expect("tempdir");
    let cfgdir = dir.path().join("workspace_test").join("release");
    std::fs::create_dir_all(&cfgdir).unwrap();
    let cfg = cfgdir.join("nornir.toml");
    std::fs::write(&cfg, CONFIG_TOML).unwrap();
    // A HOME with no ssh dir in it — see the isolation note below.
    let scratch_home = dir.path().join("home");
    std::fs::create_dir_all(&scratch_home).unwrap();
        // ISOLATION (2026-09-10). This fixture used to spawn the server with the
        // operator's real $HOME and a config carrying real
        // remotes of ours on the old forge. It was safe only by
        // COINCIDENCE — every verb it calls happens not to fetch — while
        // `clone_or_fetch` coerces `https://codeberg.org/...` to
        // `git@codeberg.org:...` and `deploykey`'s ladder would have answered
        // with `~/.nornir/ssh/id_ed25519`. One added fetching verb and this test
        // would have authenticated to codeberg with a real key, which is exactly
        // what `tests/security_isolated_live.rs` was caught doing.
        //
        // So: a scratch HOME (empties the ladder's $HOME rungs), the CONFINE
        // (removes the absolute /home/dwarves/.ssh and /home/nornir/.ssh rungs,
        // which no env var can otherwise reach), and a local clone base.
    let mut child = Command::new(env!("CARGO_BIN_EXE_nornir-server"))
        .arg("mcp") // MCP moved into the server binary 2026-08-03; same stdio protocol
        .env("NORNIR_CONFIG", &cfg)
        .env("HOME", &scratch_home)
        .env("DWARVES_SSH_CONFINE", "1")
        .env_remove("DWARVES_SSH_DIR")
        .env_remove("NORNIR_SSH_DIR")
        .env("NORNIR_DEP_CLONE_BASE", format!("file://{}", scratch_home.display()))
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()
        .expect("spawn nornir-server mcp");
    let stdin = child.stdin.take().unwrap();
    let out = BufReader::new(child.stdout.take().unwrap());
    (child, stdin, out, dir)
}

fn initialize(stdin: &mut impl Write, out: &mut impl BufRead) {
    send(
        stdin,
        json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
            "protocolVersion":"2024-11-05","capabilities":{},
            "clientInfo":{"name":"airgap_install_mcp","version":"1"}}}),
    );
    let init = read_resp(out, 1);
    assert!(
        init["result"]["serverInfo"].is_object() || init["result"]["protocolVersion"].is_string(),
        "initialize failed: {init}"
    );
    send(
        stdin,
        json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
    );
}

fn call(stdin: &mut impl Write, out: &mut impl BufRead, id: i64, name: &str, args: Value) -> Value {
    send(
        stdin,
        json!({"jsonrpc":"2.0","id":id,"method":"tools/call",
               "params":{"name":name,"arguments":args}}),
    );
    read_resp(out, id)
}

/// Text content + isError flag of a tools/call response (rmcp surfaces a
/// `McpError` as `result.isError`, a protocol error as `error`).
fn outcome(resp: &Value) -> (bool, String) {
    if resp.get("error").is_some() {
        return (
            true,
            resp["error"]["message"]
                .as_str()
                .unwrap_or_default()
                .to_string(),
        );
    }
    let is_error = resp["result"]["isError"].as_bool().unwrap_or(false);
    let text = resp["result"]["content"]
        .as_array()
        .map(|a| {
            a.iter()
                .filter_map(|c| c["text"].as_str())
                .collect::<String>()
        })
        .unwrap_or_default();
    (is_error, text)
}

/// Non-ignored: the verb registers with a usable schema, and a bad bundle path
/// yields a clean tool error. Needs no real toolchain — this is the always-on
/// register + arg-validation gate the task asks for.
#[test]
fn airgap_install_registers_and_validates_args() {
    let (mut child, mut stdin, mut out, _dir) = spawn_mcp();
    initialize(&mut stdin, &mut out);

    // Registered with an agent-facing description + a `bundle` property in schema.
    send(
        &mut stdin,
        json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}),
    );
    let tools = read_resp(&mut out, 2);
    let list = tools["result"]["tools"].as_array().expect("tools array");
    let t = list
        .iter()
        .find(|t| t["name"].as_str() == Some("airgap_install"))
        .expect("airgap_install tool must be registered");
    assert!(
        t["description"]
            .as_str()
            .map(|d| d.contains("install") && !d.is_empty())
            .unwrap_or(false),
        "airgap_install needs an agent-facing description: {t}"
    );
    let props = &t["inputSchema"]["properties"];
    assert!(
        props["bundle"].is_object(),
        "airgap_install schema must expose `bundle`: {t}"
    );
    assert!(
        props["prefix"].is_object(),
        "airgap_install schema must expose `prefix`: {t}"
    );

    // Arg validation: a bundle that does not exist errors cleanly (no panic, no
    // silent success), naming the missing bundle so an agent can recover.
    let (is_error, text) = outcome(&call(
        &mut stdin,
        &mut out,
        3,
        "airgap_install",
        json!({"bundle": "/no/such/rust-dev-bundle.tar.zst"}),
    ));
    assert!(is_error, "missing bundle must be a tool error, got: {text}");
    assert!(
        text.contains("does not exist"),
        "error should name the missing bundle: {text}"
    );

    // An unknown payload name is likewise a clean, informative error.
    let (is_error, text) = outcome(&call(
        &mut stdin,
        &mut out,
        4,
        "airgap_install",
        json!({"bundle": "/no/such/bundle.tar.zst", "pkg": "does-not-exist-xyz"}),
    ));
    assert!(is_error, "unknown pkg must be a tool error, got: {text}");

    drop(stdin);
    let _ = child.kill();
    let _ = child.wait();
}

/// Opt-in live round-trip: install a REAL bundle at `$NORNIR_AIRGAP_BUNDLE` into
/// a temp prefix and assert the outcome carries the activated `current` + the
/// env script. `#[ignore]` because it needs a real bundle on disk (a real
/// `.znippy` install needs the `airgap-znippy` build).
#[test]
#[ignore = "needs a real airgap bundle at $NORNIR_AIRGAP_BUNDLE (a .znippy needs --features airgap-znippy)"]
fn airgap_install_live_roundtrip() {
    let bundle = match std::env::var("NORNIR_AIRGAP_BUNDLE") {
        Ok(b) if !b.is_empty() => b,
        _ => {
            eprintln!("[skip] NORNIR_AIRGAP_BUNDLE unset");
            return;
        }
    };
    let (mut child, mut stdin, mut out, dir) = spawn_mcp();
    initialize(&mut stdin, &mut out);
    let prefix = dir.path().join("live-prefix");

    let (is_error, text) = outcome(&call(
        &mut stdin,
        &mut out,
        2,
        "airgap_install",
        json!({"bundle": bundle, "prefix": prefix.to_string_lossy()}),
    ));
    assert!(!is_error, "live install failed: {text}");
    assert!(
        text.contains("current") && text.contains("env_script"),
        "outcome shape: {text}"
    );
    let v: Value = serde_json::from_str(&text).expect("outcome is JSON");
    assert!(
        v["current"]
            .as_str()
            .map(|c| c.contains("current"))
            .unwrap_or(false),
        "activated current: {text}"
    );
    assert!(
        v["env_script"]
            .as_str()
            .map(|s| s.ends_with("-env.sh"))
            .unwrap_or(false),
        "env script: {text}"
    );

    drop(stdin);
    let _ = child.kill();
    let _ = child.wait();
}