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();
}