open-standards/wai/wai-rs/tests/cli_target.rs
Transaction Science 1d92a45337
All checks were successful
wai-app and Create / wai-app · does this change touch it (push) Successful in 15s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 11s
wai key · the real keychain / does this change touch it (push) Successful in 16s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Successful in 20s
joule-moq-room room service / joule-moq-room · does this change touch it (push) Successful in 22s
wai-audit collector and player integration / does this change touch it (push) Successful in 19s
wai-sink-swift native sink package / does this change touch it (push) Successful in 25s
wai-quantum studio and desktop / does this change touch it (push) Successful in 32s
WAI Studio / does this change touch it (push) Successful in 21s
wai-sink-android native sink library / does this change touch it (push) Successful in 59s
wai-quantum for JavaScript / does this change touch it (push) Successful in 1m0s
wai-fleet service / does this change touch it (push) Successful in 1m5s
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Successful in 1m10s
wai-ledger service / does this change touch it (push) Successful in 1m1s
wai key · Windows / does this change touch it (push) Successful in 1m2s
WAI operation catalogue / does this change touch it (push) Successful in 1m7s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Successful in 55s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Successful in 1m44s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Successful in 1m20s
wai-audit collector and player integration / collector · linux (push) Successful in 2m6s
wai key · the real keychain / the real keychain · macos (push) Successful in 48s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Successful in 3m5s
wai-ledger service / service · linux (push) Successful in 2m2s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Successful in 1m4s
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (push) Successful in 1m23s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Successful in 5m42s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (push) Successful in 4m30s
wai-fleet service / service · linux (push) Successful in 3m14s
joule-moq-room room service / joule-moq-room, every target (push) Successful in 6m14s
wai-quantum studio and desktop / studio · browser (push) Successful in 6m12s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Successful in 6m48s
wai-quantum for JavaScript / javascript · linux (push) Successful in 5m25s
wai-quantum studio and desktop / desktop app · linux (push) Successful in 9m2s
wai-quantum studio and desktop / desktop app · macos (push) Successful in 4m49s
WAI operation catalogue / the catalogue and every door · linux (push) Successful in 7m52s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Successful in 10m44s
wai-ledger service / service · macos (push) Successful in 56s
wai-fleet service / service · macos (push) Successful in 1m29s
WAI Studio / Studio in Chromium and Firefox · macos (push) Successful in 8m18s
WAI Studio / Studio in WebKit · linux (push) Successful in 12m35s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (push) Successful in 7m12s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (push) Successful in 16m13s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Successful in 16m3s
wai-quantum studio and desktop / desktop app · windows (push) Successful in 10m17s
wai key · Windows / wai key and the toolchain's tests · windows (push) Successful in 2m32s
wai-rs tests: every program a test runs is written by a process of its own
dx::target's a_kept_key_goes_to_the_keychain_on_standard_input_alone failed on main (CI run
27147, e7c7b209b): "the keychain tool did not start: .../security: Text file busy (os error
26)". It wrote its stand-in `security` from the test process and the keychain ran it at once. A
fork copies the forking process's descriptors into its child until that child's exec, so a spawn
another test's thread made while the file was open for writing still held it open when the
script ran, and Linux refused to run it. Renaming the file into place would not help: the child
holds the same file.

tests/support/program.rs is now the one way a test makes a program it runs: write_program (`sh`
writes the text and sets mode 755) and copy_program (`cp`). That process has exited before either
returns, so the test process never holds a descriptor for the file. The binary's unit tests
include it (src/bin/wai.rs, cfg(test)) and so do the integration tests. Every test that wrote or
copied a program in its own process and then ran it now uses it:
- unit: dx::target's keychain test (the failure), dx::dev's stale-stack programs, dx::doctor's
  automation-package browser; key.rs's and doctor.rs's own sh writers, which already wrote
  this way, now share it;
- integration: cli_project (the refusing packer, and the copy of wai run alone), cli_doctor
  (program(), the automation browser, the copy of wai when a hard link fails), cli_ops (the
  stack programs, the corpus generator), cli_conform_relay (the interop client, the corpus
  tools), cli_target (the old room service), cli_studio (the copy of fake-stack.py), and
  cli_conform's fake_python, which already wrote this way.
The scripts' bytes and what each test asserts are unchanged.

No retry is added. doctor::spawn already retries a spawn that fails with ETXTBSY (20 more tries,
10 ms apart) for the programs wai doctor and wai dev run, which a person can replace on disk
while they start; a_program_still_open_for_writing_is_tried_again holds it. The keychain's only
tool outside tests is /usr/bin/security on macOS, which nothing writes, so a retry there would be
code only a test reaches, and the tests no longer cause the error.

Measured on the Framework (Linux x86_64, 32 cores, shared), the release unit-test binary of wai
(--no-default-features --features quickstart, 172 tests), 8 copies at a time with --test-threads
32, each in its own TMPDIR, both trees at the same time:
  origin/main c4aa4e5d0: 8 of 3000 runs failed, each this test with ETXTBSY (5 of 1000 earlier)
  this change:           0 of 3000 (0 of 1000 earlier)
The binary's 172 tests and cli_doctor, cli_key, cli_ops, cli_project, cli_studio, cli_target,
cli_toolchain, cli_conform and cli_conform_relay pass on Linux and on macOS, the real keychain
test among them on macOS. clippy --all-targets (default features on macOS; full,key_discovery on
both) finds nothing new: the same findings before and after, none in a line changed here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:50:40 -05:00

725 lines
43 KiB
Rust

//! The services an operation acts on, and the keys they are reached with, through the `wai` binary:
//! `wai target add|use|list|remove`, `wai auth login|status|logout`, the shared `--target` on the
//! fleet's operations and on every operation of a service's API (`wai room show --target <name>`),
//! and `wai mcp --target` with `--allow-write`. The service is a stand-in in this test that records
//! every request it is sent and answers as it is told; nothing here depends on any other program.
//!
//! What is held, each against the fault it exists for:
//! - a key never reaches `wai.json`, a target's list, standard output, standard error, a plan or an
//! error, even where the service repeats it in its answer;
//! - the key sent is `WAI_TOKEN`'s ahead of the kept one, and only over https:// or to this machine;
//! - a read-only target refuses every operation that would change it, and nothing is sent;
//! - an unknown target is exit 5 (`target-not-found`) in the envelope;
//! - a change to a service waits for `--yes`, and a dry run names the request and sends nothing.
#![cfg(feature = "quickstart")]
use serde_json::{json, Value};
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::mpsc;
use std::time::Duration;
// A program a test runs is written by a process of its own (ETXTBSY: support/program.rs).
#[path = "support/program.rs"]
mod program;
const KEY: &str = "k_0123456789abcdef-a-key-for-the-tests";
const TOKEN: &str = "k_fedcba9876543210-the-environments";
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("wai-cli-target-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("project")).unwrap();
d
}
/// A run's environment: the project's directory, and a configuration directory of the test's own.
struct Env {
root: PathBuf,
token: Option<&'static str>,
}
impl Env {
fn new(name: &str) -> Env {
Env { root: scratch(name), token: None }
}
fn project(&self) -> PathBuf {
self.root.join("project")
}
fn config(&self) -> PathBuf {
self.root.join("config")
}
fn cmd(&self, args: &[&str]) -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_wai"));
c.current_dir(self.project()).env("WAI_CONFIG_DIR", self.config()).env_remove("WAI_TOKEN").args(args);
if let Some(t) = self.token {
c.env("WAI_TOKEN", t);
}
c
}
/// `wai` as an agent runs it: no terminal, `--json`.
fn json(&self, args: &[&str]) -> (Option<i32>, Value, Output) {
let o = self.cmd(args).arg("--json").stdin(Stdio::null()).output().expect("wai runs");
let v = serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("{args:?}: {e}: {}", text(&o)));
(o.status.code(), v, o)
}
fn text(&self, args: &[&str]) -> Output {
self.cmd(args).stdin(Stdio::null()).output().expect("wai runs")
}
/// `wai` given `input` on its standard input.
fn with_input(&self, args: &[&str], input: &str) -> Output {
let mut c = self.cmd(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().expect("wai runs");
// A run that refuses before it reads may have closed its input already.
let _ = c.stdin.take().unwrap().write_all(input.as_bytes());
c.wait_with_output().unwrap()
}
/// Every byte this test's runs left in the project and the configuration directory.
fn every_file(&self) -> String {
fn walk(p: &Path, out: &mut String) {
for e in std::fs::read_dir(p).into_iter().flatten().flatten() {
let q = e.path();
if q.is_dir() {
walk(&q, out);
} else {
out.push_str(&format!("{}\n{}\n", q.display(), String::from_utf8_lossy(&std::fs::read(&q).unwrap_or_default())));
}
}
}
let mut s = String::new();
walk(&self.root, &mut s);
s
}
}
fn text(o: &Output) -> String {
format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))
}
/// One request a stand-in service was sent.
#[derive(Debug)]
struct Seen {
line: String,
headers: Vec<(String, String)>,
body: String,
}
impl Seen {
fn header(&self, name: &str) -> Option<&str> {
self.headers.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str())
}
}
/// A service on a loopback port answering each request with the next of `answers`, and passing on
/// each request it was sent.
fn service(answers: Vec<(u16, Value)>) -> (String, mpsc::Receiver<Seen>) {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = format!("http://{}", l.local_addr().unwrap());
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
for (status, body) in answers {
let Ok((mut s, _)) = l.accept() else { return };
let mut buf = Vec::new();
let mut chunk = [0u8; 65536];
loop {
let n = s.read(&mut chunk).unwrap_or(0);
buf.extend_from_slice(&chunk[..n]);
if let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
let head = String::from_utf8_lossy(&buf[..at]).to_string();
let len = head.lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length: ").and_then(|v| v.parse::<usize>().ok())).unwrap_or(0);
while buf.len() < at + 4 + len {
let n = s.read(&mut chunk).unwrap_or(0);
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
}
let mut lines = head.lines();
let line = lines.next().unwrap_or("").to_string();
let headers = lines.filter_map(|l| l.split_once(':').map(|(n, v)| (n.trim().to_string(), v.trim().to_string()))).collect();
let _ = tx.send(Seen { line, headers, body: String::from_utf8_lossy(&buf[at + 4..]).to_string() });
break;
}
if n == 0 {
break;
}
}
let b = body.to_string();
let _ = s.write_all(format!("HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{b}", b.len()).as_bytes());
}
});
(addr, rx)
}
fn got(rx: &mpsc::Receiver<Seen>) -> Seen {
rx.recv_timeout(Duration::from_secs(20)).expect("the service was sent a request")
}
fn nothing_sent(rx: &mpsc::Receiver<Seen>) {
assert!(rx.recv_timeout(Duration::from_millis(300)).is_err(), "the service was sent a request");
}
fn minimal_project(e: &Env) {
std::fs::write(e.project().join("wai.json"), "{\n \"$schema\": \"https://wai.transaction.science/schema/wai.json/1\",\n \"wai\": 1,\n \"name\": \"p\"\n}\n").unwrap();
}
#[test]
fn a_kept_key_is_sent_to_its_origin_and_never_written_printed_or_logged() {
let e = Env::new("key");
minimal_project(&e);
let (url, rx) = service(vec![
(200, json!({"room": "abc"})),
(401, json!({"error": format!("the key {KEY} is not known here")})),
(200, json!({"room": "abc"})),
(200, json!({"room": "abc"})),
(200, json!({"room": "abc"})),
]);
let base = format!("{url}/room-api");
// A target, kept in the project's wai.json, with no key.
let (code, v, _) = e.json(&["target", "add", "staging", "--url", &base]);
assert_eq!(code, Some(0), "{v}");
assert_eq!((v["data"]["kept_in"].as_str(), v["data"]["key_kept"].as_bool()), (Some("wai.json"), Some(false)), "{v}");
let project: Value = serde_json::from_str(&std::fs::read_to_string(e.project().join("wai.json")).unwrap()).unwrap();
assert_eq!(project["targets"], json!({"staging": {"url": base}}), "{project}");
assert_eq!(project["name"], "p", "the rest of the file as it was");
// A key, from standard input, kept for the origin in a file only its owner reads.
let o = e.with_input(&["auth", "login", "--url", &base, "--store", "file"], &format!("{KEY}\n"));
assert!(o.status.success(), "{}", text(&o));
assert!(!text(&o).contains(KEY), "the key was printed: {}", text(&o));
let secret = std::fs::read_dir(e.config().join("credentials")).unwrap().flatten().next().unwrap().path();
assert_eq!(std::fs::read_to_string(&secret).unwrap().trim(), KEY);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&secret).unwrap().permissions().mode() & 0o777, 0o600, "only its owner reads it");
assert_eq!(std::fs::metadata(e.config()).unwrap().permissions().mode() & 0o077, 0, "nor its directory");
}
// Replacing it waits for --yes, asked before any key is read.
let o = e.with_input(&["auth", "login", "--url", &base, "--store", "file"], "k_another\n");
assert_eq!(o.status.code(), Some(4), "{}", text(&o));
assert_eq!(std::fs::read_to_string(&secret).unwrap().trim(), KEY, "nothing changed");
let (code, v, o) = e.json(&["auth", "status"]);
assert_eq!(code, Some(0), "{v}");
assert_eq!((v["data"]["credentials"][0]["origin"].as_str(), v["data"]["credentials"][0]["store"].as_str(), v["data"]["wai_token"].as_bool()), (Some(url.as_str()), Some("file"), Some(false)), "{v}");
assert!(!text(&o).contains(KEY));
// Sent to the target, as a bearer, on its path; the envelope says where it came from, never what.
let (code, v, o) = e.json(&["room", "show", "--target", "staging"]);
assert_eq!(code, Some(0), "{v}");
let s = got(&rx);
assert_eq!(s.line, "GET /room-api/room HTTP/1.0");
assert_eq!(s.header("authorization"), Some(format!("Bearer {KEY}").as_str()), "{s:?}");
assert_eq!((v["op"].as_str(), v["data"]["status"].as_u64(), v["data"]["body"]["room"].as_str(), v["data"]["key"].as_str()), (Some("room.show"), Some(200), Some("abc"), Some("kept")), "{v}");
assert!(!text(&o).contains(KEY), "{}", text(&o));
// A service that repeats the key in its refusal: exit 3, and the key is not in what is shown.
let (code, v, o) = e.json(&["room", "show", "--target", "staging"]);
let _ = got(&rx);
assert_eq!((code, v["error"]["code"].as_str()), (Some(3), Some("permission-denied")), "{v}");
assert!(v["error"]["message"].as_str().unwrap().contains("<key>"), "{v}");
assert!(!text(&o).contains(KEY), "the key was shown: {}", text(&o));
let o = e.text(&["room", "show", "--target", "staging"]);
let _ = got(&rx);
assert!(o.status.success() && !text(&o).contains(KEY), "{}", text(&o));
// WAI_TOKEN is sent ahead of the kept key.
let mut with_token = Env { root: e.root.clone(), token: Some(TOKEN) };
let (code, v, o) = with_token.json(&["room", "show", "--target", "staging"]);
assert_eq!(code, Some(0), "{v}");
assert_eq!(got(&rx).header("authorization"), Some(format!("Bearer {TOKEN}").as_str()));
assert_eq!(v["data"]["key"], "WAI_TOKEN");
assert!(!text(&o).contains(TOKEN) && !text(&o).contains(KEY), "{}", text(&o));
// ... and over http:// to another machine, neither goes: nothing is sent.
let (code, v, _) = with_token.json(&["room", "show", "--target", "http://192.0.2.1:9"]);
assert_eq!((code, v["error"]["code"].as_str()), (Some(3), Some("key-over-plaintext")), "{v}");
with_token.token = None;
// No key is kept for an http:// address of another machine.
let (code, v, _) = e.json(&["auth", "login", "--url", "http://192.0.2.1:9"]);
assert_eq!((code, v["error"]["code"].as_str()), (Some(2), Some("key-over-plaintext")), "{v}");
// A dry run of a change names its request, and nothing is sent or shown of the key.
let (code, v, o) = e.json(&["room", "grant", "--target", "staging", "--body", r#"{"principal":"00","role":"viewer"}"#, "--dry-run"]);
assert_eq!((code, v["dry_run"].as_bool()), (Some(0), Some(true)), "{v}");
assert_eq!((v["plan"][0]["request"].as_str(), v["plan"][0]["target"].as_str()), (Some("POST /grant"), Some(base.as_str())), "{v}");
assert!(!text(&o).contains(KEY));
nothing_sent(&rx);
// Nowhere in the project or the configuration but the key's own file.
let files = e.every_file();
assert_eq!(files.matches(KEY).count(), 1, "the key is in its own file alone:\n{files}");
assert!(!std::fs::read_to_string(e.project().join("wai.json")).unwrap().contains(KEY), "wai.json holds no key");
// Removed, it is gone, and nothing is sent with the next request.
assert_eq!(e.json(&["auth", "logout", "--url", &url]).0, Some(4), "removing a key waits for --yes");
let (code, v, _) = e.json(&["auth", "logout", "--url", &url, "--yes"]);
assert_eq!(code, Some(0), "{v}");
assert!(!secret.exists(), "its file is removed");
let (code, _, _) = e.json(&["room", "show", "--target", "staging"]);
assert_eq!(code, Some(0));
assert_eq!(got(&rx).header("authorization"), None, "no key is kept for it now");
assert_eq!(e.json(&["auth", "logout", "--url", &url, "--yes"]).0, Some(5), "none to remove");
assert!(!e.every_file().contains(KEY), "the key is gone");
let _ = std::fs::remove_dir_all(&e.root);
}
#[test]
fn a_project_file_never_holds_a_key() {
let e = Env::new("project-key");
std::fs::write(e.project().join("wai.json"), r#"{"targets":{"staging":{"url":"https://h","token":"k_hidden"}}}"#).unwrap();
let (code, v, o) = e.json(&["target", "list"]);
assert_eq!((code, v["error"]["code"].as_str()), (Some(2), Some("project-malformed")), "{v}");
assert!(v["error"]["message"].as_str().unwrap().contains("a key is never kept"), "{v}");
assert!(!text(&o).contains("k_hidden"), "{}", text(&o));
let (code, _, _) = e.json(&["room", "show", "--target", "staging"]);
assert_eq!(code, Some(2), "a file that holds a key is read for no target");
// An address that names a user is refused, from the command line too.
std::fs::write(e.project().join("wai.json"), "{}").unwrap();
for url in ["https://me:k_pw@h", "ftp://me:k_pw@h", "https://me:k_pw@h:99999/x?y"] {
let (code, v, o) = e.json(&["target", "add", "s", "--url", url]);
assert_eq!(code, Some(2), "{v}");
assert!(!text(&o).contains("k_pw"), "a user's password in an address is not repeated: {}", text(&o));
let (code, _, o) = e.json(&["room", "show", "--target", url]);
assert!(code == Some(2) && !text(&o).contains("k_pw"), "{}", text(&o));
}
assert_eq!(std::fs::read_to_string(e.project().join("wai.json")).unwrap(), "{}", "nothing written");
let _ = std::fs::remove_dir_all(&e.root);
}
#[test]
fn a_read_only_target_refuses_every_change_and_sends_nothing() {
let e = Env::new("read-only");
let (url, rx) = service(vec![(200, json!({"ok": true})), (200, json!({"enrolments": []}))]);
let (code, v, _) = e.json(&["target", "add", "watch", "--url", &url, "--read-only"]);
assert_eq!((code, v["data"]["read_only"].as_bool()), (Some(0), Some(true)), "{v}");
assert!(v["data"]["kept_in"].as_str().unwrap().ends_with("targets.json"), "no project here: the person's own list: {v}");
for args in [
&["room", "grant", "--target", "watch", "--body", r#"{"principal":"00","role":"viewer"}"#, "--yes"][..],
&["room", "grant", "--target", "watch", "--dry-run"],
&["message", "create", "--target", "watch", "--body", "{}", "--yes"],
&["report", "create", "--target", "watch", "--start", "0", "--end", "1", "--yes"],
] {
let (code, v, _) = e.json(args);
assert_eq!((code, v["error"]["code"].as_str(), v["exit"].as_u64()), (Some(3), Some("target-read-only"), Some(3)), "{args:?}: {v}");
}
#[cfg(feature = "fleet")]
{
std::fs::write(e.project().join("org.key"), "07".repeat(32)).unwrap();
let (code, v, _) = e.json(&["enrolment", "create", &"11".repeat(32), "--role", "relay", "--key", "org.key", "--target", "watch", "--yes"]);
assert_eq!((code, v["error"]["code"].as_str()), (Some(3), Some("target-read-only")), "{v}");
assert!(!e.project().join(format!("{}.wfe", "11".repeat(8))).exists(), "nothing written either");
}
nothing_sent(&rx);
// Reading it is what it is for.
let (code, v, _) = e.json(&["room", "show", "--target", "watch"]);
assert_eq!(code, Some(0), "{v}");
assert_eq!(got(&rx).line, "GET /room HTTP/1.0");
let (code, v, _) = e.json(&["enrolment", "list", "--target", "watch"]);
assert_eq!(code, Some(0), "{v}");
assert_eq!(got(&rx).line, "GET /v1/fleet/enrolments HTTP/1.0");
// A server for it offers its reads alone, --allow-write or not.
for extra in [&[][..], &["--allow-write"]] {
let mut args = vec!["mcp", "--target", "watch", "--dry-run"];
args.extend_from_slice(extra);
let (code, v, _) = e.json(&args);
assert_eq!(code, Some(0), "{v}");
let tools: Vec<&str> = v["data"]["tools"].as_array().unwrap().iter().map(|t| t.as_str().unwrap()).collect();
assert!(tools.contains(&"room_show") && !tools.contains(&"room_grant") && !tools.contains(&"message_create"), "{tools:?}");
}
let _ = std::fs::remove_dir_all(&e.root);
}
#[test]
fn an_unknown_target_is_not_found_exit_5_in_the_envelope() {
let e = Env::new("unknown");
for args in [&["room", "show", "--target", "nowhere"][..], &["enrolment", "list", "--target", "nowhere"], &["report", "create", "--target", "nowhere", "--start", "0", "--end", "1", "--yes"], &["target", "use", "nowhere"], &["target", "remove", "nowhere", "--yes"], &["mcp", "--target", "nowhere"]] {
let (code, v, _) = e.json(args);
assert_eq!(code, Some(5), "{args:?}: {v}");
assert_eq!((v["ok"].as_bool(), v["exit"].as_u64(), v["error"]["code"].as_str(), v["error"]["retryable"].as_bool()), (Some(false), Some(5), Some("target-not-found"), Some(false)), "{args:?}: {v}");
assert!(v["error"]["hint"].as_str().unwrap().contains("wai target add"), "{v}");
}
// None named and none in use: usage. A name that is neither a name nor an address, and a
// target named local, too.
for args in [&["room", "show"][..], &["room", "show", "--target", "Not A Name"], &["room", "show", "--target", "ftp://h"], &["target", "add", "local", "--url", "http://127.0.0.1:9"]] {
let (code, v, _) = e.json(args);
assert_eq!((code, v["error"]["code"].as_str()), (Some(2), Some("usage")), "{args:?}: {v}");
}
// local is the stack wai dev runs here, and none is recorded: not found, and how to start one.
let (code, v, _) = e.json(&["room", "show", "--target", "local"]);
assert_eq!((code, v["error"]["code"].as_str()), (Some(5), Some("stack-not-found")), "{v}");
assert!(v["error"]["hint"].as_str().unwrap().contains("wai dev"), "{v}");
// A target in use that is gone from every list is not found either.
let (url, _rx) = service(vec![]);
assert_eq!(e.json(&["target", "add", "gone", "--url", &url]).0, Some(0));
assert_eq!(e.json(&["target", "use", "gone"]).0, Some(0));
let own = e.config().join("targets.json");
let mut l: Value = serde_json::from_str(&std::fs::read_to_string(&own).unwrap()).unwrap();
l["targets"] = json!({});
std::fs::write(&own, l.to_string()).unwrap();
let (code, v, _) = e.json(&["room", "show"]);
assert_eq!((code, v["error"]["code"].as_str()), (Some(5), Some("target-not-found")), "{v}");
let _ = std::fs::remove_dir_all(&e.root);
}
/// The record `wai dev` keeps in `.wai/dev/stack.json` while its stack runs, its origin `url` (a
/// stand-in), its process `pid`.
fn stack_record(e: &Env, url: &str, pid: u64) -> PathBuf {
let dir = e.project().join(".wai").join("dev");
std::fs::create_dir_all(&dir).unwrap();
let rec = json!({
"format": "wai.dev-stack/1", "toolchain": env!("CARGO_PKG_VERSION"), "pid": pid, "dir": dir.display().to_string(),
"origin": url, "room_api": format!("{url}/room-api"), "service": url, "relay": "ws://127.0.0.1:9/moq",
"room_bin": env!("CARGO_BIN_EXE_wai"), "relay_quic": null, "relay_cert_sha256": null, "impair": null,
});
std::fs::write(dir.join("stack.json"), rec.to_string()).unwrap();
dir
}
/// What the stack's origin answers `GET /_wai/api/state` with: its directory and process.
fn state(dir: &Path, pid: u64) -> (u16, Value) {
(200, json!({"ready": true, "dir": dir.display().to_string(), "services": [{"pid": pid}]}))
}
/// `--target local` is the room service of the stack wai dev runs here: its record names its
/// origin, which is asked first whether it is that stack, then the operation's route is sent there.
/// A record whose stack has gone is stale, and nothing is sent.
#[test]
fn local_is_the_room_service_of_the_stack_wai_dev_runs_here() {
let e = Env::new("local");
let dir = stack_record(&e, "http://127.0.0.1:9", 4242);
let (url, rx) = service(vec![state(&dir, 4242), (200, json!({"room": "abc", "publishers": 0}))]);
stack_record(&e, &url, 4242);
let (code, v, o) = e.json(&["room", "show", "--target", "local"]);
assert_eq!(code, Some(0), "{v} {}", text(&o));
assert_eq!((v["data"]["body"]["room"].as_str(), v["data"]["target"]["name"].as_str(), v["data"]["request"].as_str()), (Some("abc"), Some("local"), Some("GET /room")), "{v}");
let probe = got(&rx);
assert_eq!((probe.line.as_str(), probe.header("x-wai-studio")), ("GET /_wai/api/state HTTP/1.1", Some("1")), "{probe:?}");
assert!(got(&rx).line.starts_with("GET /room-api/room "), "the route, through the stack's origin");
// A grant on the local room changes it: it waits for --yes, and nothing is sent.
let (url, rx) = service(vec![state(&dir, 4242)]);
stack_record(&e, &url, 4242);
let (code, v, _) = e.json(&["room", "grant", "--target", "local", "--body", r#"{"principal":"00","role":"viewer"}"#]);
assert_eq!((code, v["error"]["code"].as_str()), (Some(4), Some("confirmation-required")), "{v}");
assert_eq!(got(&rx).line, "GET /_wai/api/state HTTP/1.1");
nothing_sent(&rx);
// Another process answers at the origin: the record is stale, and nothing else is sent.
let (url, rx) = service(vec![state(&dir, 1)]);
stack_record(&e, &url, 4242);
let (code, v, _) = e.json(&["room", "show", "--target", "local"]);
assert_eq!((code, v["error"]["code"].as_str(), v["error"]["retryable"].as_bool()), (Some(6), Some("stack-stale"), Some(true)), "{v}");
let _ = got(&rx);
nothing_sent(&rx);
let _ = std::fs::remove_dir_all(&e.root);
}
/// The development room's doors on the command line, against a stand-in stack: `wai room join`
/// asks the stack's room service for a publisher's grant for the principal's key (the key its name
/// derives, as wai mcp's) and sends the join to the stack's participants with that grant; `wai room
/// leave` and `wai session show` send theirs. A join waits for --yes, a dry run sends nothing, a
/// refusal of the stack's is exit 1 with its reason, and a stack that is not running is refused.
#[test]
fn the_development_rooms_doors_drive_the_stack_wai_dev_runs_here() {
let e = Env::new("room-doors");
let pid = 5151;
let joined = json!({"ok": true, "id": 1, "room": "r00m", "principal": "ab", "events": ["join", "publish", "publish"], "objects_published": 2, "objects_served": 2});
let dir = stack_record(&e, "http://127.0.0.1:9", pid);
let (url, rx) = service(vec![
state(&dir, pid),
(200, json!({"grant": "a1b2", "digest": "d1", "exp": 9})),
(200, joined),
state(&dir, pid),
(200, json!({"ok": true, "events": ["leave"]})),
state(&dir, pid),
(200, json!({"ok": true, "room": "r00m", "descriptor": "dd", "grants": [], "key_log_head": "kk", "chains": [[]]})),
state(&dir, pid),
(200, json!({"ok": false, "error": "alice holds no session"})),
]);
stack_record(&e, &url, pid);
let (code, v, o) = e.json(&["room", "join", "--principal", "alice", "--yes"]);
assert_eq!(code, Some(0), "{v} {}", text(&o));
assert_eq!((v["op"].as_str(), v["data"]["name"].as_str(), v["data"]["development_only"].as_bool(), v["data"]["grant_digest"].as_str()), (Some("room.join"), Some("alice"), Some(true), Some("d1")), "{v}");
assert!(v["data"].get("ok").is_none() && v["data"].get("id").is_none(), "{v}");
let key = v["data"]["key"].as_str().unwrap().to_string();
assert_eq!(key.len(), 64);
let _ = got(&rx);
let g = got(&rx);
assert!(g.line.starts_with("POST /room-api/grant "), "{g:?}");
let asked: Value = serde_json::from_str(&g.body).unwrap();
assert_eq!((asked["principal"].as_str(), asked["role"].as_str()), (Some(key.as_str()), Some("publisher")), "{asked}");
let j = got(&rx);
assert!(j.line.starts_with("POST /_wai/api/control ") && j.header("x-wai-studio") == Some("1"), "{j:?}");
let line: Value = serde_json::from_str(&j.body).unwrap();
assert_eq!((line["op"].as_str(), line["grant"].as_str(), line["objects"].as_u64(), line["receipts"].as_bool()), (Some("join"), Some("a1b2"), Some(1), Some(true)), "{line}");
assert_eq!(line["seed"].as_str().unwrap().len(), 64);
let (code, v, _) = e.json(&["room", "leave", "--principal", "alice", "--yes"]);
assert_eq!((code, v["data"]["events"][0].as_str()), (Some(0), Some("leave")), "{v}");
let _ = got(&rx);
let l: Value = serde_json::from_str(&got(&rx).body).unwrap();
assert_eq!(l, json!({"op": "leave", "principal": key}), "the same name, the same key");
let (code, v, _) = e.json(&["session", "show"]);
assert_eq!((code, v["data"]["descriptor"].as_str(), v["data"]["development_only"].as_bool()), (Some(0), Some("dd"), Some(true)), "{v}");
let _ = got(&rx);
assert_eq!(serde_json::from_str::<Value>(&got(&rx).body).unwrap(), json!({"op": "session"}));
// The participants refuse: exit 1, with their reason.
let (code, v, _) = e.json(&["room", "leave", "--principal", "alice", "--yes"]);
assert_eq!((code, v["error"]["code"].as_str()), (Some(1), Some("refused")), "{v}");
assert!(v["error"]["message"].as_str().unwrap().contains("alice holds no session"), "{v}");
let _ = (got(&rx), got(&rx));
// Without --yes a join waits, a dry run sends nothing, and neither is sent anything past the probe.
let (url, rx) = service(vec![state(&dir, pid)]);
stack_record(&e, &url, pid);
let (code, v, _) = e.json(&["room", "join", "--principal", "bob"]);
assert_eq!((code, v["error"]["code"].as_str()), (Some(4), Some("confirmation-required")), "{v}");
let _ = got(&rx);
nothing_sent(&rx);
let (code, v, _) = e.json(&["room", "join", "--principal", "bob", "--dry-run"]);
assert_eq!((code, v["dry_run"].as_bool(), v["plan"].as_array().map(Vec::len)), (Some(0), Some(true), Some(2)), "{v}");
nothing_sent(&rx);
// A principal named by nothing, and objects past the bound, are usage.
for args in [&["room", "join", "--yes"][..], &["room", "join", "--principal", "bob", "--objects", "65", "--yes"], &["room", "leave", "--principal", "", "--yes"]] {
assert_eq!(e.json(args).0, Some(2), "{args:?}");
}
// A record whose stack is another process: stale, and the session is not asked for.
let (url, rx) = service(vec![state(&dir, pid + 1)]);
stack_record(&e, &url, pid);
let (code, v, _) = e.json(&["session", "show"]);
assert_eq!((code, v["error"]["code"].as_str()), (Some(6), Some("stack-stale")), "{v}");
let _ = got(&rx);
nothing_sent(&rx);
// An invitation needs the room's keys, and a room service of this toolchain.
let (code, v, _) = e.json(&["room", "invite"]);
assert_eq!((code, v["error"]["code"].as_str()), (Some(5), Some("not-found")), "no keys yet: {v}");
assert_eq!(e.json(&["room", "invite", "--seconds", "0"]).0, Some(2));
#[cfg(unix)]
{
std::fs::write(dir.join("room-keys.toml"), "# a stand-in\n").unwrap();
let old = e.root.join("old-wai-room");
program::write_program(&old, "#!/bin/sh\necho '{\"wai\":\"envelope/1\",\"op\":\"toolchain.show\",\"ok\":true,\"data\":{\"program\":\"wai-room\",\"version\":\"0.1.0\",\"toolchain\":\"0.0.1\"}}'\n");
let (code, v, _) = e.json(&["room", "invite", "--room", old.to_str().unwrap()]);
assert_eq!((code, v["error"]["code"].as_str()), (Some(2), Some("stack-stale")), "a room service of another toolchain: {v}");
}
// No stack recorded: not found, for each door.
std::fs::remove_file(dir.join("stack.json")).unwrap();
for args in [&["room", "join", "--principal", "bob", "--yes"][..], &["room", "leave", "--principal", "bob", "--yes"], &["session", "show"]] {
let (code, v, _) = e.json(args);
assert_eq!((code, v["error"]["code"].as_str()), (Some(5), Some("stack-not-found")), "{args:?}: {v}");
}
let _ = std::fs::remove_dir_all(&e.root);
}
/// `wai log tail` and `wai session list` read the stack's directory: a log from a byte, its end,
/// whole lines while followed; the relay's summaries, the last N, a line that does not read
/// counted and never shown. A log that is not there, or a name that is no log, is refused.
#[test]
fn the_stacks_logs_and_sessions_are_read_from_its_directory() {
let e = Env::new("logs");
let dir = e.project().join(".wai").join("dev");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("relay.log"), "one\ntwo\nthree").unwrap();
let (code, v, _) = e.json(&["log", "tail"]);
assert_eq!((code, v["data"]["text"].as_str(), v["data"]["from"].as_u64(), v["data"]["next"].as_u64()), (Some(0), Some("one\ntwo\nthree"), Some(0), Some(13)), "{v}");
let (_, v, _) = e.json(&["log", "tail", "--from", "4"]);
assert_eq!(v["data"]["text"], "two\nthree");
let o = e.text(&["log", "tail", "--from", "8"]);
assert_eq!(String::from_utf8_lossy(&o.stdout), "three");
// Followed with no stack running: one read of what is there, whole lines first, then the rest.
let o = e.cmd(&["log", "tail", "--follow", "--json"]).stdin(Stdio::null()).output().unwrap();
let lines: Vec<Value> = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.iter().map(|l| l["text"].as_str().unwrap()).collect::<String>(), "one\ntwo\nthree", "{lines:?}");
assert_eq!(lines[0]["text"], "one\ntwo\n");
assert_eq!(e.json(&["log", "tail", "--name", "room"]).1["error"]["code"], "log-not-found");
assert_eq!(e.json(&["log", "tail", "--name", "keys"]).0, Some(2));
assert_eq!(e.json(&["log", "tail", "--for", "1"]).0, Some(2), "--for is --follow's");
let s = |n: u64| json!({"summary": {"session": n, "transport": "websocket", "remote": "127.0.0.1:9", "opened_unix_ms": 10, "closed_unix_ms": 30}, "signature": "00"});
std::fs::write(dir.join("sessions.jsonl"), format!("{}\nnot a summary\n{}\n{}\n", s(1), s(2), s(3))).unwrap();
let (code, v, _) = e.json(&["session", "list"]);
assert_eq!((code, v["data"]["sessions"].as_array().map(Vec::len), v["data"]["unread"].as_u64()), (Some(0), Some(3), Some(1)), "{v}");
let (_, v, _) = e.json(&["session", "list", "--last", "1"]);
assert_eq!(v["data"]["sessions"], json!([s(3)]));
let t = String::from_utf8_lossy(&e.text(&["session", "list"]).stdout).into_owned();
assert!(t.lines().count() == 3 && t.contains("session 2") && t.contains("20 ms"), "{t}");
let o = e.cmd(&["session", "list", "--follow", "--json", "--last", "2"]).stdin(Stdio::null()).output().unwrap();
let got: Vec<Value> = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(got, vec![s(2), s(3)]);
std::fs::remove_dir_all(&dir).unwrap();
assert_eq!(e.json(&["session", "list"]).1["error"]["code"], "sessions-not-found");
assert_eq!(e.json(&["log", "tail"]).1["error"]["code"], "log-not-found");
let _ = std::fs::remove_dir_all(&e.root);
}
#[test]
fn targets_are_kept_chosen_listed_replaced_and_removed() {
let e = Env::new("targets");
minimal_project(&e);
let (url, rx) = service(vec![(200, json!({"a": 1})), (200, json!({"b": 2})), (200, json!({"enrolments": []}))]);
// One in the project, one of the same name in the person's own list, which it hides.
assert_eq!(e.json(&["target", "add", "staging", "--url", &url]).0, Some(0));
assert_eq!(e.json(&["target", "add", "staging", "--url", "https://elsewhere.example", "--user"]).0, Some(0));
assert_eq!(e.json(&["target", "add", "fleet", "--url", &format!("{url}/"), "--user"]).0, Some(0));
let (code, v, _) = e.json(&["target", "list"]);
assert_eq!(code, Some(0), "{v}");
let rows = v["data"]["targets"].as_array().unwrap();
assert_eq!(rows.len(), 3, "{v}");
assert_eq!((rows[0]["name"].as_str(), rows[0]["kept_in"].as_str(), rows[0]["hidden"].as_bool()), (Some("staging"), Some("wai.json"), Some(false)), "{v}");
let mine = rows.iter().find(|r| r["name"] == "staging" && r["kept_in"] != "wai.json").unwrap();
assert_eq!(mine["hidden"], true, "{v}");
assert_eq!(rows.iter().find(|r| r["name"] == "fleet").unwrap()["url"], json!(url), "kept without its final /");
// In use: an operation that needs a service and names none takes it, the project's of its name.
assert_eq!(v["data"]["use"], Value::Null);
let (code, v, _) = e.json(&["target", "use", "staging"]);
assert_eq!((code, v["data"]["use"].as_str()), (Some(0), Some("staging")), "{v}");
let (code, v, _) = e.json(&["room", "show"]);
assert_eq!((code, v["data"]["target"]["name"].as_str()), (Some(0), Some("staging")), "{v}");
assert_eq!(got(&rx).line, "GET /room HTTP/1.0");
// Replacing one waits for --yes, with what it would replace.
let (code, v, _) = e.json(&["target", "add", "staging", "--url", "https://other.example"]);
assert_eq!((code, v["proposed"][0]["url"].as_str()), (Some(4), Some(url.as_str())), "{v}");
assert!(v["confirm"].as_str().unwrap().ends_with("--yes"), "{v}");
assert!(std::fs::read_to_string(e.project().join("wai.json")).unwrap().contains(&url), "nothing changed");
// The fleet's operations take the target's name, as every --target does.
let (code, v, _) = e.json(&["enrolment", "list", "--target", "fleet"]);
assert_eq!(code, Some(0), "{v}");
let _ = got(&rx);
// Removed from the project, the file is as it was; removing the last of the name ends its use.
let (code, _, _) = e.json(&["target", "remove", "staging", "--yes"]);
assert_eq!(code, Some(0));
assert_eq!(std::fs::read_to_string(e.project().join("wai.json")).unwrap(), "{\n \"$schema\": \"https://wai.transaction.science/schema/wai.json/1\",\n \"wai\": 1,\n \"name\": \"p\"\n}\n");
let (_, v, _) = e.json(&["target", "list"]);
assert_eq!(v["data"]["use"], "staging", "the person's own staging is still there: {v}");
assert_eq!(e.json(&["target", "remove", "staging", "--yes"]).0, Some(0));
let (_, v, _) = e.json(&["target", "list"]);
assert_eq!((v["data"]["use"].clone(), v["data"]["targets"].as_array().unwrap().len()), (Value::Null, 1), "{v}");
let _ = std::fs::remove_dir_all(&e.root);
}
#[test]
fn an_operation_of_a_services_api_is_sent_to_its_route() {
let e = Env::new("route");
let (url, rx) = service(vec![(200, json!({"granted": true})), (404, json!({"error": "no such room"})), (503, json!({"error": "busy"})), (400, json!({"error": "a bad body"})), (200, json!({"log": []}))]);
let body = r#"{"principal":"0000000000000000000000000000000000000000000000000000000000000000","role":"viewer"}"#;
// A change waits for --yes: exit 4, with the request it would send, and nothing sent.
let (code, v, _) = e.json(&["room", "grant", "--target", &url, "--body", body]);
assert_eq!((code, v["proposed"][0]["request"].as_str()), (Some(4), Some("POST /grant")), "{v}");
assert_eq!(v["confirm"].as_str(), Some(format!("wai room grant --target {url} --body '{body}' --yes").as_str()), "{v}");
nothing_sent(&rx);
let (code, v, _) = e.json(&["room", "grant", "--target", &url, "--body", body, "--yes"]);
assert_eq!((code, v["data"]["status"].as_u64(), v["data"]["body"]["granted"].as_bool(), v["data"]["key"].clone()), (Some(0), Some(200), Some(true), Value::Null), "{v}");
let s = got(&rx);
assert_eq!((s.line.as_str(), s.header("content-type"), s.body.as_str()), ("POST /grant HTTP/1.0", Some("application/json"), body));
assert_eq!(s.header("authorization"), None, "no key is kept or set: none is sent");
// The service's refusals, as the toolchain's exits.
for (want, code_name) in [(5, "not-found"), (6, "target-unavailable"), (2, "invalid-input")] {
let (code, v, _) = e.json(&["room", "show", "--target", &url]);
let _ = got(&rx);
assert_eq!((code, v["error"]["code"].as_str()), (Some(want), Some(code_name)), "{v}");
}
// Query parameters on the route, each encoded.
let (code, v, _) = e.json(&["mls", "tail", "--target", &url, "--query", "room=ab cd", "--query", "from=2"]);
assert_eq!(code, Some(0), "{v}");
assert_eq!(got(&rx).line, "GET /mls?room=ab%20cd&from=2 HTTP/1.0");
// Usage: a body on a route that takes none, JSON that is not, and a query with no name.
for args in [&["room", "show", "--target", &url, "--body", "{}"][..], &["room", "grant", "--target", &url, "--body", "{not json", "--yes"], &["mls", "tail", "--target", &url, "--query", "=x"], &["room", "show", "--target", &url, "extra"]] {
assert_eq!(e.json(args).0, Some(2), "{args:?}");
}
nothing_sent(&rx);
// No service there: unavailable, and worth a retry.
let (code, v, _) = e.json(&["service", "check", "--target", "http://127.0.0.1:9"]);
assert_eq!((code, v["error"]["retryable"].as_bool()), (Some(6), Some(true)), "{v}");
// The help names its route and the shared flag.
let o = e.text(&["room", "grant", "--help"]);
assert!(String::from_utf8_lossy(&o.stdout).contains("POST /grant") && String::from_utf8_lossy(&o.stdout).contains("--target"), "{}", text(&o));
let _ = std::fs::remove_dir_all(&e.root);
}
/// A `wai mcp` process and its protocol channel.
struct Mcp {
child: std::process::Child,
stdin: std::process::ChildStdin,
out: BufReader<std::process::ChildStdout>,
next: u64,
}
impl Mcp {
fn start(e: &Env, extra: &[&str]) -> Mcp {
let mut c = e.cmd(&["mcp"]);
let mut child = c.args(extra).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().expect("wai mcp starts");
let stdin = child.stdin.take().unwrap();
let out = BufReader::new(child.stdout.take().unwrap());
let mut m = Mcp { child, stdin, out, next: 1 };
let init = m.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "cli_target", "version": "1"}}));
assert!(init["result"]["instructions"].as_str().unwrap().contains("the target this server was started with"), "{init}");
m
}
fn request(&mut self, method: &str, params: Value) -> Value {
let id = self.next;
self.next += 1;
writeln!(self.stdin, "{}", json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).unwrap();
self.stdin.flush().unwrap();
let mut l = String::new();
assert!(self.out.read_line(&mut l).unwrap() > 0, "the server closed its output");
serde_json::from_str(&l).unwrap()
}
fn finish(mut self) -> String {
drop(self.stdin);
let mut err = String::new();
let _ = self.child.stderr.take().unwrap().read_to_string(&mut err);
let _ = self.child.wait();
err
}
}
#[test]
fn wai_mcp_serves_a_targets_operations_reading_unless_it_may_write() {
let e = Env::new("mcp");
let (url, rx) = service(vec![(200, json!({"room": "abc"})), (401, json!({"error": format!("refused {KEY}")})), (200, json!({"granted": true}))]);
assert_eq!(e.json(&["target", "add", "here", "--url", &url]).0, Some(0));
assert!(e.with_input(&["auth", "login", "--url", &url, "--store", "file"], KEY).status.success());
// A target on this machine may be changed; each tool is an operation of the service's API.
let mut m = Mcp::start(&e, &["--target", "here"]);
let list = m.request("tools/list", json!({}));
let names: Vec<&str> = list["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
assert!(names.contains(&"room_show") && names.contains(&"room_grant") && names.contains(&"envelope_inspect"), "{names:?}");
let show = list["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "room_show").unwrap();
assert_eq!((show["annotations"]["readOnlyHint"].as_bool(), show["annotations"]["openWorldHint"].as_bool(), show["_meta"]["wai/operation"].as_str()), (Some(true), Some(true), Some("room.show")), "{show}");
let r = m.request("tools/call", json!({"name": "room_show", "arguments": {"query": {"from": "1"}}}));
assert_eq!((r["result"]["isError"].as_bool(), r["result"]["structuredContent"]["status"].as_u64(), r["result"]["structuredContent"]["key"].as_str()), (Some(false), Some(200), Some("kept")), "{r}");
let s = got(&rx);
assert_eq!((s.line.as_str(), s.header("authorization")), ("GET /room?from=1 HTTP/1.0", Some(format!("Bearer {KEY}").as_str())));
let r = m.request("tools/call", json!({"name": "room_show", "arguments": {}}));
let _ = got(&rx);
assert_eq!(r["result"]["isError"], true, "{r}");
assert!(!r.to_string().contains(KEY) && r.to_string().contains("<key>"), "the key repeated by the service is not passed on: {r}");
let r = m.request("tools/call", json!({"name": "room_grant", "arguments": {"body": {"principal": "00", "role": "viewer"}}}));
assert_eq!(r["result"]["structuredContent"]["status"], 200, "{r}");
assert_eq!(got(&rx).body, r#"{"principal":"00","role":"viewer"}"#);
let r = m.request("tools/call", json!({"name": "room_show", "arguments": {"body": {}}}));
assert!(r["result"]["isError"] == true || r["error"].is_object(), "a body on a route that takes none: {r}");
let err = m.finish();
assert!(!err.contains(KEY), "{err}");
nothing_sent(&rx);
// A target on another machine is read alone, unless the server may write; --read-only wins.
for (extra, writes) in [(&[][..], false), (&["--allow-write"][..], true), (&["--allow-write", "--read-only"][..], false)] {
let mut args = vec!["mcp", "--target", "https://service.example", "--dry-run"];
args.extend_from_slice(extra);
let (code, v, _) = e.json(&args);
assert_eq!(code, Some(0), "{v}");
let tools: Vec<&str> = v["data"]["tools"].as_array().unwrap().iter().map(|t| t.as_str().unwrap()).collect();
assert!(tools.contains(&"room_show"), "{tools:?}");
assert_eq!((tools.contains(&"room_grant"), v["data"]["target"]["writes"].as_bool()), (writes, Some(writes)), "{extra:?}: {tools:?}");
}
// No target, no service tools; the development tools are local's alone.
let (_, v, _) = e.json(&["mcp", "--dry-run"]);
assert!(!v["data"]["tools"].as_array().unwrap().iter().any(|t| t == "room_show"), "{v}");
for args in [&["mcp", "--target", "here", "--toolset", "dev", "--dry-run"][..], &["mcp", "--allow-write", "--dry-run"]] {
assert_eq!(e.json(args).0, Some(2), "{args:?}");
}
let _ = std::fs::remove_dir_all(&e.root);
}