WAI for coding agents on the toolchain's names: wai mcp (toolsets core and dev, tools named by their operations), the room service's API with catalogue operationIds, wai setup-agent #505

Merged
dcharlot merged 7 commits from wai/dx-agent-mcp-2 into main 2026-10-27 10:14:04 -04:00
24 changed files with 8126 additions and 39 deletions

View file

@ -26,7 +26,16 @@ name: joule-moq-room room service
# members keying through real MLS groups, messaging across a restart of the service from its
# history store, a thread, reactions, an edit and a retraction, a removed member served nothing
# past its removal, every record verified offline; and a call (tests/call_room.rs) rung, accepted
# and carrying audio and video through the relay, and another missed.
# and carrying audio and video through the relay, and another missed. tests/openapi_contract.rs
# holds openapi.json to the service: every operationId is the operation's name in the toolchain's
# catalogue (noun.verb, by wai::ops's rule), the routes the routers match are exactly its
# operations, each status it lists is one its handlers return and each status they return is
# listed, and the service, run in each of its modes, answers every operation with a status it
# lists. tests/mcp_dev_room.rs builds `wai` with the feature `mcp` (a target directory of its own)
# and drives `wai mcp --toolset core,dev` with an MCP client: the tool room_grant and POST /grant
# named alike (room.grant), a development room (`joule-moq-room dev`) started on loopback, grants
# for two test principals, each joining and publishing, the session verified as live-rooms §9
# verifies a room, a changed record refused, and a grant from a service off the machine refused.
#
# It builds on wai-rs's room, event, composition, co-watch and remote desktop records, on the
# relay, on joule-moq-mls and on the remote desktop host agent, so a change to any of them runs

View file

@ -612,6 +612,31 @@ jobs:
run: |
cargo test --no-default-features --features http,video_manifest,key_discovery,cli_tls --bin wai --test cli_http --test dash_read_bounds
cargo test --no-default-features --features http,video_manifest,key_discovery --test cli_http -- receipts_never_anchors_over_plaintext
# The wai commands for coding agents. tests/cli_mcp.rs starts `wai mcp` (the feature `mcp`)
# and drives it with an MCP client over its standard input and output: the handshake, the
# tool list (every tool an operation of the catalogue named by it, `core` by default and
# `dev` when asked, an unknown toolset refused) with each tool's schema and safety hints,
# and every tool. receipt_verify agrees vector by vector with jwp-conformance (group and
# object receipts), claim-conformance (each claim with the context the tool takes),
# live-conformance/rooms and live-conformance/audit, and a receipt changed by one character
# is refused; package_build writes only inside the project directory and never through a
# link; conformance_run runs live_rooms_verify.py; --read-only offers no tool that writes,
# serves or asks for a grant; the dev tools refuse any service off this machine before
# connecting, and any target but local (the room itself, joined, published to and verified
# through the tools, is joule-moq-room's tests/mcp_dev_room.rs). `--bin wai` runs the
# binary's unit tests: every tool's operation named by the rule, the loopback guard and
# setup-agent's merges and diff; `--lib ops::` the rule itself. tests/cli_setup_agent.rs
# runs `wai setup-agent` in scratch projects as an agent does (no terminal, the JSON
# envelope out): a fresh project, a second run that changes nothing, a CLAUDE.md made only
# where one is, a file that exists changed only with --confirm (exit 4 and the proposed
# changes otherwise), a dry run, --toolset registered, a JSON file with comments left
# alone, nothing written through a link out of the project, and the table's exit statuses.
# Cargo.toml declares cli_mcp with required-features = ["mcp"].
- name: The wai commands for coding agents (mcp, setup-agent)
working-directory: wai/wai-rs
run: |
cargo test --no-default-features --features mcp --bin wai --test cli_mcp --test cli_setup_agent
cargo test --no-default-features --lib ops::
# tests/staged_moq.rs runs the MoQ trace of staged-conformance/moq/ (moq-streaming-format
# §4.1) with signed receipts: a chain for each track, a receipt held for its parent and
# admitted when it arrives, the relay's per-hop delivery timeout, dropped objects checked

View file

@ -38,7 +38,7 @@ wai = { path = "../../../../wai/wai-rs", default-features = false
hyper = { version = "=1.11.1", features = ["server", "http1"] }
hyper-util = { version = "=0.1.20", default-features = false, features = ["tokio"] }
http-body-util = "=0.1.5"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal", "fs"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "io-std", "signal", "fs"] }
bytes = "1"
tracing = "0.1"
tracing-subscriber = "0.3"

View file

@ -183,6 +183,8 @@ joule-moq-room relay-lines <keys.toml> those lines again
joule-moq-room invite <keys.toml> [<s>] an invitation to host a remote desktop room, signed by
the room's authority, good for <s> seconds (3600)
joule-moq-room --config <room.toml> serve (room.example.toml shows every field)
joule-moq-room dev [--publishers <n>] a development room: the relay and the service in one
process on loopback, driven by JSON control lines
```
The keys file must be readable by its owner alone; the service refuses it otherwise.
@ -214,8 +216,37 @@ prefix = ["*", "*", "video"]
trust_field = 1
```
### A development room
`joule-moq-room dev` (`src/dev.rs`) runs a relay and the service together in one process, for
a developer's machine and for tests, and never for anyone else. It binds 127.0.0.1 alone, on
ports the system picks; its keys are made at start and kept only in the process; its relay
holds media tracks to no receipts, so a test principal publishes plain objects; and its rate
limits are open. It says so on standard error when it starts. Its first line on standard output
is `{"ready": true, "development_only": true, "service", "relay", "room", "authority", "clock"}`,
and it answers JSON control lines on standard input, each with an `op` and an optional `id`:
| `op` | Fields | Answer |
|---|---|---|
| `join` | `seed` (a test principal's Ed25519 seed, 64 hex), `grant` (a publisher's grant for that key, hex), `objects` (per track, 1 to 64) | the principal enters with its grant and a proof, publishes `audio` and `video` with that many objects on each, and the answer comes once the clock has signed its `join` and both `publish`es: `{"principal", "room", "tracks", "objects_published", "objects_served", "events"}` |
| `leave` | `principal` (64 hex) | its session closes; the answer comes once the clock has signed its `leave` |
| `session` | | the room as live-rooms §9 gives it to a verifier: `{"room", "t0_us", "descriptor", "grants", "key_log_head": null, "chains"}` |
| `stop` | | the room stops and the process exits; so it does when standard input closes |
Grants come from the service itself (`POST /grant`). `wai mcp --toolset dev` (wai-rs, the
feature `mcp`) runs this mode for its development tools.
## HTTP
[`openapi.json`](openapi.json) describes this API (OpenAPI 3.1): every path, its requests,
answers and statuses, in every mode the service keeps. Each operation's `operationId` is its
name in the WAI toolchain's catalogue, `noun.verb` (`room.show` is `GET /room`, `room.grant` is
`POST /grant`), the name the other doors derive theirs from (`wai mcp`'s tool `room_grant`).
`tests/openapi_contract.rs` holds it to the code: every operation is named as the catalogue
names it, the routes the routers match are exactly its operations, every status it lists for an
operation is one that operation's handlers return and every status they return is listed, and
the service, run in each mode, answers every operation with a status it lists.
| Request | Answer |
|---|---|
| `GET /`, `GET /health` | `{"ok", "relay": the clock's session is open, "room"}` |
@ -265,6 +296,13 @@ descriptor, its digest and the caller's grant, `409` `busy` while a call has its
## Tests
`tests/openapi_contract.rs` holds `openapi.json` to the service (above). `tests/mcp_dev_room.rs`
builds `wai` with the feature `mcp`, in a target directory of its own, and drives
`wai mcp --toolset core,dev` with an MCP client: the tool `room_grant` and the API's `POST /grant`
named alike (`room.grant`), a development room started on loopback, grants for two test
principals, each joining and publishing, the session verified as live-rooms §9 verifies a room,
a changed record refused, a leave verified, and a grant from a service off the machine refused.
`cargo test` (this crate is a workspace of its own, with its own lock file, since it builds
on `wai-rs`): the keys file, the grants checked against the descriptor and read by the
relay's reader, the places, the limiter, and `tests/room_service.rs`, the service against a

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,434 @@
//! A **development room**: the relay and the room service in one process, on loopback, for a
//! developer's machine and for tests. `joule-moq-room dev` runs one and takes its control
//! lines on standard input; `wai mcp` drives it that way.
//!
//! It is not a deployment, and differs from one on purpose:
//!
//! - it binds 127.0.0.1 alone, the service and the relay's WebSocket endpoint both on ports
//! the system picks;
//! - its keys are made at start and live only in this process; nothing is written to disk;
//! - its relay holds publishers' media tracks to no receipts (no `[[receipts.door]]`), so a
//! test principal publishes plain objects;
//! - its rate limits are open.
//!
//! Every line it prints on standard output is one JSON object. The first is
//! `{"ready": true, "development_only": true, "service", "relay", "room", "authority", "clock"}`.
//! Each control line is a JSON object with an `op` and an optional `id`, which its answer
//! repeats; an answer is `{"ok": true, ...}` or `{"ok": false, "error": <why>}`:
//!
//! | `op` | Fields | Answer |
//! |---|---|---|
//! | `join` | `seed` (64 hex: the test principal's Ed25519 seed), `grant` (hex: a publisher's grant for that key), `objects` (per track, default 1, at most 64) | the principal enters the relay with its grant and a proof, publishes its `audio` and `video` tracks with `objects` objects on each, and the answer comes once the room's clock has signed its `join` and both `publish`es: `{"principal", "room", "tracks", "objects_published", "objects_served", "events"}` |
//! | `leave` | `principal` (64 hex) | its session closes; the answer comes once the clock has signed its `leave` |
//! | `session` | | the room as a verifier is given it (live-rooms §9): `{"room", "t0_us", "descriptor", "grants", "key_log_head": null, "chains": [[<record hex>, ...]]}`, the one chain the clock's events track holds |
//! | `stop` | | the room stops and the process exits |
//!
//! Standard input closing stops it too.
use crate::keys::Keys;
use crate::{http, now_s, Config, Service};
use ed25519_dalek::SigningKey;
use http_body_util::BodyExt;
use joule_moq::draft22::data::{FetchEntry, SubgroupCodec, SubgroupHeader, SubgroupId, SubgroupObject};
use joule_moq::draft22::kvp::{prop, KeyValuePair, Properties};
use joule_moq::draft22::Parameters;
use joule_moq::{TrackName, TrackNamespace};
use joule_moq_relay::client::{Client, ClientOptions};
use joule_moq_relay::config::{Authority, Grant};
use joule_moq_relay::transport::mux::MuxConfig;
use joule_moq_relay::transport::websocket;
use joule_moq_relay::wire::{ReqRx, ReqTx};
use joule_moq_relay::{RelayConfig, RelayServer};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashMap};
use std::net::SocketAddr;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
use tokio::sync::Mutex;
use tokio::time::timeout;
/// How long a step waits for the clock to sign what it follows.
const STEP: Duration = Duration::from_secs(20);
/// The most objects a `join` writes on each track.
const MAX_OBJECTS: u64 = 64;
/// What `stderr` says first: the room is for development only.
pub const BANNER: &str = "joule-moq-room dev: a DEVELOPMENT room on loopback. Its keys exist only in this process, its relay \
holds media tracks to no receipts, and its rate limits are open. Never serve it to anyone.";
/// A test principal that has entered: its session and the tracks it holds open.
struct Joined {
client: Client,
_held: Vec<(ReqTx, ReqRx)>,
}
/// The relay and the service, running, and the sessions of the principals driven through it.
pub struct DevRoom {
pub relay: RelayServer,
pub svc: Arc<Service>,
/// The service's HTTP address (127.0.0.1).
pub http: SocketAddr,
/// The relay's WebSocket endpoint, `ws://127.0.0.1:<port><path>`.
pub relay_url: String,
joined: Mutex<HashMap<[u8; 32], Joined>>,
/// A viewer's session, and the room its grant is for, from which the room's chain is read.
observer: Mutex<Option<([u8; 32], Client)>>,
tasks: Vec<tokio::task::JoinHandle<()>>,
}
fn hex32(s: &str) -> Option<[u8; 32]> {
hex::decode(s).ok().filter(|_| s.len() == 64).and_then(|b| b.try_into().ok())
}
impl DevRoom {
/// Start a relay and a room service on loopback with fresh keys. `extra` is TOML the
/// service's configuration ends with (its own sections: `[co]`, `[remote]`, ...), for a
/// test that needs a room of another kind; `max_publishers` sets `[room]`'s.
pub async fn start(max_publishers: u16, extra: &str) -> Result<DevRoom, String> {
let keys = Keys::generate();
let mut c = RelayConfig::loopback();
c.quic_listen = None;
c.auth.required = true;
c.auth.authorities = vec![Authority { key: hex::encode(keys.authority.verifying_key().to_bytes()), rooms: vec![] }];
let t = hex::encode(Sha256::digest(keys.clock_token));
let clock = hex::encode(keys.clock.verifying_key().to_bytes());
c.auth.grants = vec![
Grant { prefix: vec!["*".into(), clock, "events".into()], token_sha256: t.clone(), publish: true, subscribe: false, proof: false, unlimited: true, seek: false },
Grant { prefix: vec![], token_sha256: t, publish: false, subscribe: true, proof: false, unlimited: false, seek: false },
];
c.receipts.door = vec![];
let relay = RelayServer::start(c).await?;
let ws = relay.websocket_addr.ok_or("the relay has no WebSocket endpoint")?;
let relay_url = format!("ws://{ws}{}", relay.relay().config().path);
let room_section = if extra.contains("[room]") { String::new() } else { format!("[room]\nmax_publishers = {max_publishers}\njoin_wait_s = 30\n") };
let rate_section = if extra.contains("[rate]") { "" } else { "[rate]\nclient_burst = 10000\nclient_every_s = 1\nglobal_burst = 10000\nglobal_every_s = 1\n" };
let cfg = Config::from_toml(&format!("listen = \"127.0.0.1:0\"\nkeys = \"/nonexistent\"\nrelay_url = \"{relay_url}\"\n{room_section}{rate_section}{extra}"))?;
let svc = Service::try_new(Keys::from_toml(&keys.to_toml())?, &cfg)?;
let listener = tokio::net::TcpListener::bind(cfg.listen).await.map_err(|e| format!("binding the service: {e}"))?;
let http_addr = listener.local_addr().map_err(|e| e.to_string())?;
svc.start();
let tasks = vec![tokio::spawn(http::serve(svc.clone(), listener))];
if svc.messaging.is_none() {
timeout(STEP, async {
while !svc.connected.load(Ordering::Acquire) {
tokio::time::sleep(Duration::from_millis(25)).await;
}
})
.await
.map_err(|_| "the room's clock did not open its session on the relay")?;
}
Ok(DevRoom { relay, svc, http: http_addr, relay_url, joined: Mutex::new(HashMap::new()), observer: Mutex::new(None), tasks })
}
/// The room's endpoints and public keys: the `ready` line.
pub fn describe(&self) -> Value {
let room = hex::encode(self.svc.state.lock().unwrap().epoch.id);
json!({
"ready": true,
"development_only": true,
"service": format!("http://{}", self.http),
"relay": self.relay_url,
"room": room,
"authority": hex::encode(self.svc.keys.authority.verifying_key().to_bytes()),
"clock": hex::encode(self.svc.keys.clock.verifying_key().to_bytes()),
})
}
async fn connect(&self, opts: ClientOptions) -> Result<Client, String> {
let conn = websocket::connect(&self.relay_url, MuxConfig::default()).await.map_err(|e| format!("the relay: {e}"))?;
Client::connect(Arc::new(conn), opts).await
}
fn target(&self) -> String {
format!("//{}{}", self.relay.websocket_addr.expect("a WebSocket endpoint"), self.relay.relay().config().path)
}
/// A viewer's session in the current room, made (with a viewer's grant the service
/// issues) when there is none for it.
async fn observer(&self) -> Result<(), String> {
let room = self.svc.state.lock().unwrap().epoch.id;
let mut obs = self.observer.lock().await;
if obs.as_ref().is_some_and(|(r, _)| *r == room) {
return Ok(());
}
let key = SigningKey::from_bytes(&Sha256::digest(b"joule-moq-room dev observer").into());
let body = json!({"principal": hex::encode(key.verifying_key().to_bytes()), "role": "viewer"}).to_string();
let resp = http::grant(&self.svc, "dev-observer", None, None, body.as_bytes());
let status = resp.status();
let bytes = resp.into_body().collect().await.map_err(|e| e.to_string())?.to_bytes();
let v: Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
if !status.is_success() {
return Err(format!("the observer's grant: {status} {v}"));
}
let grant = hex::decode(v["grant"].as_str().unwrap_or_default()).map_err(|e| e.to_string())?;
let client = self.connect(ClientOptions { grants: vec![(grant, key)], target: self.target(), ..ClientOptions::default() }).await?;
if let Some((_, old)) = obs.replace((room, client)) {
old.close(0, "a new room");
}
Ok(())
}
/// The clock's chain as the relay serves its events track: every record once, in `seq`
/// order (live-events §8.1).
async fn chain(&self) -> Result<(String, u64, Vec<Vec<u8>>), String> {
self.observer().await?;
let (room, t0) = {
let st = self.svc.state.lock().unwrap();
(hex::encode(st.epoch.id), st.epoch.t0_us)
};
let clock = hex::encode(self.svc.keys.clock.verifying_key().to_bytes());
let obs = self.observer.lock().await;
let (_, c) = obs.as_ref().ok_or("no observer")?;
let ns = TrackNamespace::from_strs([room.as_str(), clock.as_str(), "events"]);
let mut by_seq = BTreeMap::new();
if let Ok((_, entries)) = c.fetch(ns, TrackName::from_str("0"), Parameters::new()).await {
for e in entries {
let FetchEntry::Object(o) = e else { continue };
let arr: Vec<Value> = serde_json::from_slice(&o.payload).unwrap_or_default();
for r in arr {
if let Ok(rec) = wai::live_events::from_msf_record(&r.to_string(), t0)
&& let Ok(ev) = wai::live_events::Event::parse(&rec)
{
by_seq.insert(ev.seq, rec);
}
}
}
}
Ok((room, t0, by_seq.into_values().collect()))
}
/// The lifecycle the clock has signed for `principal`, by kind, in its chain's order.
async fn events_of(&self, principal: &[u8; 32]) -> Result<Vec<String>, String> {
let (_, _, chain) = self.chain().await?;
Ok(chain
.iter()
.filter_map(|r| {
let ev = wai::live_events::Event::parse(r).ok()?;
let wai::live_events::Payload::Lifecycle { subject, .. } = wai::live_events::parse_payload(ev.kind, &ev.payload, 1024).ok()? else { return None };
(subject == *principal).then(|| wai::live_events::kind_name(ev.kind).unwrap_or("?").to_string())
})
.collect())
}
async fn wait_for(&self, principal: &[u8; 32], done: impl Fn(&[String]) -> bool) -> Result<Vec<String>, String> {
let mut last = Vec::new();
let r = timeout(STEP, async {
loop {
if let Ok(k) = self.events_of(principal).await {
if done(&k) {
return k;
}
last = k;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
})
.await;
r.map_err(|_| format!("the clock has not signed it within {} s; it has signed {last:?} for this principal", STEP.as_secs()))
}
/// Enter as the principal of `seed` with `grant`, publish `audio` and `video` with
/// `objects` objects on each, and wait for the clock's `join` and `publish`es.
pub async fn join(&self, seed: [u8; 32], grant: Vec<u8>, objects: u64) -> Result<Value, String> {
let key = SigningKey::from_bytes(&seed);
let principal = key.verifying_key().to_bytes();
let (_, named) = joule_moq::grant_proof::grant_digest_and_principal(&grant).ok_or("the grant does not read")?;
if named != principal {
return Err("the grant is for another key than the seed's".into());
}
if self.joined.lock().await.contains_key(&principal) {
return Err("this principal is in the room already".into());
}
let objects = objects.clamp(1, MAX_OBJECTS);
let room = hex::encode(self.svc.state.lock().unwrap().epoch.id);
let client = self.connect(ClientOptions { grants: vec![(grant, key)], target: self.target(), ..ClientOptions::default() }).await?;
let mut held = Vec::new();
let mut tracks = Vec::new();
for (alias, m) in (10u64..).zip(["audio", "video"]) {
let ns = TrackNamespace::from_strs([room.as_str(), hex::encode(principal).as_str(), m]);
let mut p = Properties::new();
p.push(KeyValuePair::varint(prop::DEFAULT_PUBLISHER_PRIORITY, 64));
held.push(client.publish(ns, TrackName::from_str("0"), alias, Parameters::new(), p).await.map_err(|e| format!("PUBLISH {m}: {e}"))?);
for g in 0..objects {
let header = SubgroupHeader { track_alias: alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(64), properties: false, end_of_group: false, first_object: true };
let mut b = Vec::new();
header.encode(&mut b);
let mut codec = SubgroupCodec::new(header);
codec.encode_object(&SubgroupObject::normal(0, format!("{m} {g} of {}", hex::encode(&principal[..4])).into_bytes()), &mut b).map_err(|e| e.to_string())?;
let mut s = client.conn.open_uni().await.map_err(|e| e.to_string())?;
s.write_all(&b).await.map_err(|e| e.to_string())?;
let _ = s.finish();
}
tracks.push(format!("{m}/0"));
}
self.joined.lock().await.insert(principal, Joined { client, _held: held });
let events = self.wait_for(&principal, |k| k.iter().filter(|e| *e == "publish").count() >= 2).await?;
let served = self.served(&room, &principal).await;
Ok(json!({
"principal": hex::encode(principal),
"room": room,
"tracks": tracks,
"objects_published": objects * 2,
"objects_served": served,
"events": events,
}))
}
/// The objects of the principal's tracks the relay serves, by FETCH.
async fn served(&self, room: &str, principal: &[u8; 32]) -> u64 {
let mut n = 0;
let obs = self.observer.lock().await;
let Some((_, c)) = obs.as_ref() else { return 0 };
for m in ["audio", "video"] {
let ns = TrackNamespace::from_strs([room, hex::encode(principal).as_str(), m]);
for _ in 0..50 {
if let Ok((_, entries)) = c.fetch(ns.clone(), TrackName::from_str("0"), Parameters::new()).await {
let k = entries.iter().filter(|e| matches!(e, FetchEntry::Object(_))).count() as u64;
if k > 0 {
n += k;
break;
}
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
n
}
/// Close the principal's session, and wait for the clock's `leave`.
pub async fn leave(&self, principal: [u8; 32]) -> Result<Value, String> {
let j = self.joined.lock().await.remove(&principal).ok_or("this principal has not joined through this room")?;
drop(j._held);
j.client.close(0, "leave");
let events = self.wait_for(&principal, |k| k.iter().any(|e| e == "leave")).await?;
Ok(json!({"principal": hex::encode(principal), "events": events}))
}
/// The room as a verifier is given it (live-rooms §9).
pub async fn session(&self) -> Result<Value, String> {
let (room, t0, chain) = self.chain().await?;
let view = self.svc.state.lock().unwrap().epoch.view(now_s());
if view["room"].as_str() != Some(room.as_str()) {
return Err("the room changed while it was read; ask again".into());
}
Ok(json!({
"room": room,
"t0_us": t0,
"descriptor": view["descriptor"],
"grants": view["grants"],
"key_log_head": null,
"chains": [chain.iter().map(hex::encode).collect::<Vec<_>>()],
}))
}
/// Stop everything.
pub async fn stop(self) {
for (_, j) in self.joined.lock().await.drain() {
j.client.close(0, "stop");
}
if let Some((_, c)) = self.observer.lock().await.take() {
c.close(0, "stop");
}
for t in &self.tasks {
t.abort();
}
self.relay.shutdown().await;
}
/// One control line's answer.
pub async fn control(&self, line: &Value) -> Value {
let r = match line["op"].as_str() {
Some("join") => match (line["seed"].as_str().and_then(hex32), line["grant"].as_str().and_then(|g| hex::decode(g).ok())) {
(Some(seed), Some(grant)) => self.join(seed, grant, line["objects"].as_u64().unwrap_or(1)).await,
_ => Err("join: seed (64 hexadecimal digits) and grant (hexadecimal)".into()),
},
Some("leave") => match line["principal"].as_str().and_then(hex32) {
Some(p) => self.leave(p).await,
None => Err("leave: principal (64 hexadecimal digits)".into()),
},
Some("session") => self.session().await,
Some("describe") => Ok(self.describe()),
Some(other) => Err(format!("no op {other}: join, leave, session, describe or stop")),
None => Err("a control line is a JSON object with an op".into()),
};
let mut v = match r {
Ok(Value::Object(mut m)) => {
m.insert("ok".into(), json!(true));
Value::Object(m)
}
Ok(other) => json!({"ok": true, "value": other}),
Err(e) => json!({"ok": false, "error": e}),
};
if let Some(id) = line.get("id") {
v["id"] = id.clone();
}
v
}
}
/// `joule-moq-room dev [--publishers <n>]`: run a development room and answer control lines
/// until `stop` or until standard input closes.
pub async fn run(args: &[String]) -> std::process::ExitCode {
let mut publishers: u16 = 4;
let mut it = args.iter();
while let Some(a) = it.next() {
match (a.as_str(), it.clone().next()) {
("--publishers", Some(n)) => {
publishers = match n.parse() {
Ok(n) if (1..=64).contains(&n) => n,
_ => {
eprintln!("joule-moq-room dev: --publishers takes 1 to 64");
return std::process::ExitCode::from(2);
}
};
it.next();
}
_ => {
eprintln!("usage: joule-moq-room dev [--publishers <n>]");
return std::process::ExitCode::from(2);
}
}
}
eprintln!("{BANNER}");
let room = match DevRoom::start(publishers, "").await {
Ok(r) => r,
Err(e) => {
eprintln!("joule-moq-room dev: {e}");
println!("{}", json!({"ready": false, "error": e}));
return std::process::ExitCode::FAILURE;
}
};
let mut out = tokio::io::stdout();
let say = |v: Value| format!("{v}\n");
if out.write_all(say(room.describe()).as_bytes()).await.is_err() || out.flush().await.is_err() {
room.stop().await;
return std::process::ExitCode::FAILURE;
}
let mut lines = tokio::io::BufReader::new(tokio::io::stdin()).lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.trim().is_empty() {
continue;
}
let a = match serde_json::from_str::<Value>(&line) {
Err(e) => json!({"ok": false, "error": format!("not JSON: {e}")}),
Ok(v) if v["op"].as_str() == Some("stop") => {
let mut a = json!({"ok": true, "stopped": true});
if let Some(id) = v.get("id") {
a["id"] = id.clone();
}
let _ = out.write_all(say(a).as_bytes()).await;
let _ = out.flush().await;
break;
}
Ok(v) => room.control(&v).await,
};
if out.write_all(say(a).as_bytes()).await.is_err() || out.flush().await.is_err() {
break;
}
}
room.stop().await;
std::process::ExitCode::SUCCESS
}

View file

@ -41,6 +41,7 @@
pub mod age;
pub mod call;
pub mod co;
pub mod dev;
pub mod e2ee;
pub mod epoch;
pub mod http;

View file

@ -8,6 +8,10 @@
//! (`[remote]` with no `host`), signed by the
//! room's authority, good for <s> seconds (3600)
//! joule-moq-room --config <room.toml> serve
//! joule-moq-room dev [--publishers <n>] a development room on loopback: the relay and the
//! service in one process, fresh keys held in memory,
//! driven by JSON control lines on standard input
//! (`joule_moq_room::dev`); never for deployment
//! ```
//!
//! It stops on Ctrl-C or SIGTERM. With `max_resident_mb` set it exits with status 70 once
@ -19,15 +23,22 @@ use std::path::Path;
use std::process::ExitCode;
fn usage() -> ExitCode {
eprintln!("usage: joule-moq-room (keygen <keys.toml> | relay-lines <keys.toml> | invite <keys.toml> [<seconds>] | --config <room.toml>)");
eprintln!("usage: joule-moq-room (keygen <keys.toml> | relay-lines <keys.toml> | invite <keys.toml> [<seconds>] | --config <room.toml> | dev [--publishers <n>])");
ExitCode::from(2)
}
#[tokio::main]
async fn main() -> ExitCode {
let level = std::env::var("JOULE_MOQ_ROOM_LOG").ok().and_then(|l| l.parse().ok()).unwrap_or(tracing::Level::INFO);
tracing_subscriber::fmt().with_max_level(level).with_target(false).init();
let args: Vec<String> = std::env::args().skip(1).collect();
// A development room answers on standard output, one JSON line at a time: its log goes to
// standard error, at warnings unless JOULE_MOQ_ROOM_LOG asks for more.
if args.first().map(String::as_str) == Some("dev") {
let level = std::env::var("JOULE_MOQ_ROOM_LOG").ok().and_then(|l| l.parse().ok()).unwrap_or(tracing::Level::WARN);
tracing_subscriber::fmt().with_max_level(level).with_target(false).with_writer(std::io::stderr).init();
return joule_moq_room::dev::run(&args[1..]).await;
}
tracing_subscriber::fmt().with_max_level(level).with_target(false).init();
match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
["keygen", path] => match Keys::create(Path::new(path)) {
Ok(k) => {

View file

@ -0,0 +1,180 @@
//! End to end, as a coding agent works: an MCP client drives `wai mcp --toolset core,dev`, which
//! runs this crate's development room (`joule-moq-room dev`) as a child, and through its tools
//!
//! - finds the one operation the two doors share named alike: the tool `room_grant` is the
//! operation `room.grant`, which `openapi.json` describes as the service's `POST /grant`;
//! - starts a relay and a room service on loopback, with its room (`dev_start`);
//! - mints publishers' grants for two test principals, and has each join the room with its grant
//! and a proof of its key and publish audio and video;
//! - reads the room's session (its descriptor, grants and the clock's chain) and verifies it as
//! live-rooms §9 verifies a room: accepted, both principals present; a record changed by one
//! byte is refused;
//! - has one leave, and verifies the session again: accepted, one present;
//! - and is refused a grant from a service that is not on this machine.
//!
//! The `wai` binary is built here, from `wai/wai-rs` with the feature `mcp`, into a target
//! directory of its own: the two workspaces build `wai` with different features, and one target
//! directory for both mixes their artifacts.
use serde_json::{json, Value};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
fn wai_bin() -> PathBuf {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../../wai/wai-rs/Cargo.toml");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("wai-mcp");
let cargo = std::env::var("CARGO").ok().or(option_env!("CARGO").map(String::from)).unwrap_or_else(|| "cargo".into());
let status = Command::new(cargo)
.args(["build", "--locked", "--no-default-features", "--features", "mcp", "--bin", "wai", "--manifest-path"])
.arg(&manifest)
.env("CARGO_TARGET_DIR", &target)
.status()
.expect("cargo runs");
assert!(status.success(), "the wai binary builds with the feature mcp");
target.join("debug").join(if cfg!(windows) { "wai.exe" } else { "wai" })
}
struct Agent {
child: Child,
stdin: ChildStdin,
out: BufReader<ChildStdout>,
next: i64,
}
impl Agent {
fn start(wai: &Path, project: &Path) -> Agent {
let mut child = Command::new(wai)
.args(["mcp", "--toolset", "core,dev", "--project"])
.arg(project)
.arg("--room-bin")
.arg(env!("CARGO_BIN_EXE_joule-moq-room"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.expect("wai mcp starts");
let stdin = child.stdin.take().unwrap();
let out = BufReader::new(child.stdout.take().unwrap());
let mut a = Agent { child, stdin, out, next: 1 };
let init = a.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "mcp_dev_room test", "version": "1"}}));
assert_eq!(init["result"]["serverInfo"]["name"], "wai", "{init}");
writeln!(a.stdin, "{}", json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).unwrap();
a
}
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, "wai mcp closed its output");
let v: Value = serde_json::from_str(&l).expect("a JSON-RPC line");
assert_eq!(v["id"], json!(id));
v
}
fn call(&mut self, name: &str, args: Value) -> Result<Value, String> {
let v = self.request("tools/call", json!({"name": name, "arguments": args}));
let r = &v["result"];
if r["isError"] == json!(true) {
return Err(r["content"][0]["text"].as_str().unwrap_or("").to_string());
}
Ok(r["structuredContent"].clone())
}
fn ok(&mut self, name: &str, args: Value) -> Value {
self.call(name, args.clone()).unwrap_or_else(|e| panic!("{name} {args}: {e}"))
}
}
impl Drop for Agent {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[test]
fn an_agent_starts_a_room_joins_publishes_and_verifies_the_session() {
let wai = wai_bin();
let project = std::env::temp_dir().join(format!("joule-moq-room-mcp-{}", std::process::id()));
std::fs::create_dir_all(&project).unwrap();
let mut a = Agent::start(&wai, &project);
// One name for one operation in both doors: the tool and the API's operationId.
let tools = a.request("tools/list", json!({}));
let ops: Vec<String> = tools["result"]["tools"].as_array().unwrap().iter().map(|t| t["_meta"]["wai/operation"].as_str().unwrap().to_string()).collect();
let api: Value = serde_json::from_str(&std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("openapi.json")).unwrap()).unwrap();
assert!(ops.iter().any(|o| o == "room.grant"), "{ops:?}");
assert_eq!(api["paths"]["/grant"]["post"]["operationId"], "room.grant", "the route room_grant asks for is the operation room.grant");
let api_ops: Vec<String> = api["paths"].as_object().unwrap().values().flat_map(|i| i.as_object().unwrap().values().filter_map(|o| o["operationId"].as_str().map(String::from))).collect();
for op in &ops {
if api_ops.contains(op) {
assert_eq!(op, "room.grant", "{op}: a tool that is an API operation too is the grant alone");
}
}
let room = a.ok("dev_start", json!({"publishers": 4}));
assert_eq!(room["development_only"], true, "{room}");
let service = room["service"].as_str().unwrap().to_string();
assert!(service.starts_with("http://127.0.0.1:"), "{service}");
assert!(room["relay"].as_str().unwrap().starts_with("ws://127.0.0.1:"), "{room}");
let id = room["room"].as_str().unwrap().to_string();
// A grant from a service off this machine is refused before anything is sent.
let e = a.call("room_grant", json!({"principal": "alice", "role": "publisher", "service": "http://192.0.2.10:9682"})).unwrap_err();
assert!(e.starts_with("DEVELOPMENT ONLY"), "{e}");
// Two test principals are granted, join and publish.
let mut keys = Vec::new();
for name in ["alice", "bob"] {
let g = a.ok("room_grant", json!({"principal": name, "role": "publisher"}));
assert_eq!((g["role"].as_str(), g["room"].as_str()), (Some("publisher"), Some(id.as_str())), "{g}");
keys.push(g["key"].as_str().unwrap().to_string());
let j = a.ok("room_join", json!({"target": "local", "principal": name, "objects": 2}));
assert_eq!(j["events"], json!(["join", "publish", "publish"]), "{name}: the clock signed its join and publishes: {j}");
assert_eq!(j["objects_published"], 4);
assert!(j["objects_served"].as_u64().unwrap() >= 2, "the relay serves what it published: {j}");
}
// The same name is the same key: a second grant for alice names her key again.
let again = a.ok("room_grant", json!({"principal": "alice", "role": "viewer"}));
assert_eq!(again["key"].as_str(), Some(keys[0].as_str()));
// The session, verified.
let s = a.ok("room_session", json!({}));
assert_eq!(s["room"].as_str(), Some(id.as_str()));
let args = json!({"descriptor": s["descriptor"], "grants": s["grants"], "key_log_head": s["key_log_head"], "chains": s["chains"]});
let v = a.ok("receipt_verify", args.clone());
assert_eq!((v["kind"].as_str(), v["outcome"].as_str()), (Some("room_session"), Some("accept")), "{v}");
assert_eq!(v["room"].as_str(), Some(id.as_str()));
let mut present: Vec<String> = v["present"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect();
present.sort();
let mut want = keys.clone();
want.sort();
assert_eq!(present, want, "both are present");
assert_eq!(v["records_in_order"], 6, "a join and two publishes each");
// A record changed by one byte: refused, at that record.
let mut bad = args.clone();
let rec = bad["chains"][0][1].as_str().unwrap().to_string();
let flipped = format!("{}{}", &rec[..rec.len() - 2], if rec.ends_with("00") { "01" } else { "00" });
bad["chains"][0][1] = json!(flipped);
let r = a.ok("receipt_verify", bad);
assert_eq!(r["outcome"], "refuse", "{r}");
assert_eq!(r["at"], json!(["record", 0, 1]), "{r}");
// Alice leaves; the session verifies with Bob alone present.
let l = a.ok("room_leave", json!({"principal": "alice"}));
assert!(l["events"].as_array().unwrap().iter().any(|e| e == "leave"), "{l}");
let s = a.ok("room_session", json!({}));
let v = a.ok("receipt_verify", json!({"kind": "room_session", "descriptor": s["descriptor"], "grants": s["grants"], "key_log_head": s["key_log_head"], "chains": s["chains"]}));
assert_eq!(v["outcome"], "accept", "{v}");
assert_eq!(v["present"], json!([keys[1]]), "{v}");
assert_eq!(v["records_in_order"], 7);
assert_eq!(a.ok("dev_stop", json!({}))["stopped"], true);
assert!(a.call("room_session", json!({})).is_err(), "no room once stopped");
let _ = std::fs::remove_dir_all(&project);
}

View file

@ -0,0 +1,644 @@
//! The service's OpenAPI description (`openapi.json`) held to the code, in both directions:
//!
//! - **Names.** Every operation's `operationId` is its name in the toolchain's catalogue, the
//! one `HANDLERS` gives it: `noun.verb` by the rule every door keeps (`wai::ops::check_id`),
//! each name once.
//! - **Routes.** The (method, path) pairs the routers match (`src/http.rs`,
//! `src/messaging.rs`, `src/call.rs`) are exactly the description's operations.
//! - **Statuses, from the source.** Each operation names the functions that answer it below
//! (`HANDLERS`). Every status the description lists for an operation is one those functions
//! return, and every status a function returns is listed for an operation it answers (405,
//! the answer to a method a path does not take, is stated once in the description).
//! - **Statuses, from the running service.** The service runs on loopback beside a relay, in
//! each of its modes (a plain room, end-to-end encryption, admission by age, co-performance,
//! a watch room, a remote desktop room, a conversation, calls, and without its relay), and
//! every documented operation is asked at least once in the mode that serves it: every
//! answer's status is one the description lists for that operation, each probe's the one it
//! was written to reach, and a method a documented path does not take is answered 405 (404
//! under `/msg` and the call paths) and never 200.
#[path = "common/rfc_test_keys.rs"]
mod rfc_test_keys;
use ed25519_dalek::SigningKey;
use joule_moq::grant_proof::{self as gp, GrantProof};
use joule_moq_room::dev::DevRoom;
use joule_moq_room::keys::Keys;
use joule_moq_room::{http, Config, Service};
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::net::SocketAddr;
use std::path::Path;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::time::timeout;
const STEP: Duration = Duration::from_secs(20);
const HOST: &str = "room.test";
// ------------------------------------------------------------------------- the description
type Op = (String, String);
fn description() -> Value {
let text = std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("openapi.json")).expect("openapi.json");
serde_json::from_str(&text).expect("openapi.json is JSON")
}
/// Every operation of `openapi.json` and the statuses it lists.
fn documented() -> BTreeMap<Op, BTreeSet<u16>> {
let doc = description();
assert_eq!(doc["openapi"].as_str().map(|v| v.starts_with("3.1")), Some(true), "an OpenAPI 3.1 document");
let mut ops = BTreeMap::new();
for (path, item) in doc["paths"].as_object().expect("paths") {
for (method, o) in item.as_object().expect("a path item") {
let statuses: BTreeSet<u16> = o["responses"].as_object().expect("responses").keys().map(|s| s.parse().expect("a numeric status")).collect();
assert!(!statuses.is_empty(), "{method} {path} lists no status");
ops.insert((method.to_ascii_uppercase(), path.clone()), statuses);
}
}
ops
}
// ------------------------------------------------------------------------------- the source
fn source(file: &str) -> String {
std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src").join(file)).expect("a source file")
}
/// The (method, path) pairs a router's arms match: every `(Method::X, "/path")` in its file.
fn routes_in(file: &str) -> BTreeSet<Op> {
let s = source(file);
let mut out = BTreeSet::new();
let mut rest = s.as_str();
while let Some(i) = rest.find("(Method::") {
rest = &rest[i + "(Method::".len()..];
let Some(comma) = rest.find(", \"") else { break };
let method = &rest[..comma];
if !method.chars().all(|c| c.is_ascii_uppercase()) {
continue;
}
let after = &rest[comma + 3..];
let Some(end) = after.find('"') else { break };
out.insert((method.to_string(), after[..end].to_string()));
}
out
}
const STATUS: &[(&str, u16)] = &[
("OK", 200), ("BAD_REQUEST", 400), ("UNAUTHORIZED", 401), ("FORBIDDEN", 403), ("NOT_FOUND", 404), ("METHOD_NOT_ALLOWED", 405),
("CONFLICT", 409), ("GONE", 410), ("PAYLOAD_TOO_LARGE", 413), ("TOO_MANY_REQUESTS", 429), ("INTERNAL_SERVER_ERROR", 500), ("SERVICE_UNAVAILABLE", 503),
];
/// The statuses a top-level function of `file` names (`StatusCode::X` in its body: from its
/// `fn` line to the next line that is `}` alone).
fn fn_statuses(file: &str, name: &str) -> BTreeSet<u16> {
let s = source(file);
let lines: Vec<&str> = s.lines().collect();
let starts: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| {
let t = l.trim_start_matches("pub(crate) ").trim_start_matches("pub ").trim_start_matches("async ");
!l.starts_with(' ') && t.starts_with(&format!("fn {name}("))
})
.map(|(i, _)| i)
.collect();
assert_eq!(starts.len(), 1, "{file} has one top-level fn {name}");
let body: Vec<&str> = lines[starts[0]..].iter().take_while(|l| **l != "}").copied().collect();
let body = body.join("\n");
let mut out = BTreeSet::new();
let mut rest = body.as_str();
while let Some(i) = rest.find("StatusCode::") {
rest = &rest[i + "StatusCode::".len()..];
let name: String = rest.chars().take_while(|c| c.is_ascii_uppercase() || *c == '_').collect();
let code = STATUS.iter().find(|(n, _)| *n == name).unwrap_or_else(|| panic!("{file}: {name}: a status this test does not know")).1;
out.insert(code);
}
out
}
/// Each operation: its method and path, its name in the toolchain's catalogue, and the functions
/// that answer it (the router that matches it, and every function on its way that answers).
type Handlers = &'static [(&'static str, &'static str)];
const HANDLERS: &[(&str, &str, &str, Handlers)] = &[
("GET", "/", "service.show", &[("http.rs", "handle")]),
("GET", "/health", "service.check", &[("http.rs", "handle")]),
("GET", "/room", "room.show", &[("http.rs", "handle")]),
("POST", "/grant", "room.grant", &[("http.rs", "handle"), ("http.rs", "grant"), ("remote.rs", "grant"), ("remote.rs", "bad"), ("call.rs", "handle")]),
("GET", "/age/receipt", "admission.show", &[("http.rs", "age_receipt")]),
("GET", "/mls", "mls.tail", &[("http.rs", "mls_log")]),
("POST", "/mls/key-package", "mls.import", &[("http.rs", "handle"), ("http.rs", "mls_body"), ("http.rs", "mls_key_package")]),
("POST", "/mls/post", "mls.update", &[("http.rs", "handle"), ("http.rs", "mls_body"), ("http.rs", "mls_post")]),
("GET", "/co/objects", "performance.list", &[("http.rs", "co_objects")]),
("GET", "/co/state", "performance.show", &[("http.rs", "co_state")]),
("POST", "/co/schedule", "schedule.create", &[("http.rs", "co_request"), ("http.rs", "unauthorised"), ("http.rs", "signed_reply")]),
("POST", "/co/handover", "handover.create", &[("http.rs", "co_request"), ("http.rs", "unauthorised"), ("http.rs", "signed_reply")]),
("POST", "/co/window", "window.create", &[("http.rs", "co_request"), ("http.rs", "unauthorised"), ("http.rs", "signed_reply")]),
("POST", "/co/fidelity", "fidelity.create", &[("http.rs", "co_request"), ("http.rs", "unauthorised"), ("http.rs", "signed_reply")]),
("GET", "/watch/time", "time.show", &[("http.rs", "handle")]),
("GET", "/watch/source", "source.fetch", &[("http.rs", "watch_source")]),
("GET", "/watch/challenge", "watchchallenge.create", &[("http.rs", "handle")]),
("GET", "/watch", "watch.tail", &[("http.rs", "watch_log")]),
("GET", "/watch/room", "watch.show", &[("http.rs", "handle")]),
("POST", "/watch/join", "watch.join", &[("http.rs", "handle"), ("http.rs", "watch_join")]),
("POST", "/watch/post", "watch.update", &[("http.rs", "handle"), ("http.rs", "watch_post")]),
("POST", "/remote/control", "control.grant", &[("remote.rs", "control"), ("http.rs", "unauthorised")]),
("GET", "/remote/room", "remote.show", &[("remote.rs", "session"), ("remote.rs", "admit_reader"), ("http.rs", "unauthorised")]),
("GET", "/remote/state", "remote.verify", &[("remote.rs", "state"), ("remote.rs", "admit_reader"), ("http.rs", "unauthorised")]),
("GET", "/msg", "conversation.show", &[("messaging.rs", "handle")]),
("GET", "/msg/challenge", "conversationchallenge.create", &[("messaging.rs", "handle")]),
("POST", "/msg/join", "conversation.join", &[("messaging.rs", "handle"), ("messaging.rs", "join"), ("messaging.rs", "unauthorised")]),
("GET", "/msg/log", "conversation.tail", &[("messaging.rs", "handle"), ("messaging.rs", "log"), ("messaging.rs", "unauthorised")]),
("POST", "/msg/key-package", "conversation.import", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]),
("POST", "/msg/mls", "conversation.update", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]),
("POST", "/msg/post", "message.create", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]),
("POST", "/msg/ack", "acknowledgement.create", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]),
("POST", "/msg/leave", "conversation.leave", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]),
("POST", "/msg/remove", "member.delete", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]),
("GET", "/call/challenge", "callchallenge.create", &[("http.rs", "handle"), ("call.rs", "handle")]),
("POST", "/call/place", "call.create", &[("http.rs", "handle"), ("call.rs", "handle"), ("call.rs", "unauthorised"), ("call.rs", "body"), ("call.rs", "place")]),
("POST", "/call/ring", "call.invite", &[("http.rs", "handle"), ("call.rs", "handle"), ("http.rs", "unauthorised"), ("call.rs", "body"), ("call.rs", "ring")]),
("GET", "/inbox", "inbox.show", &[("http.rs", "handle"), ("call.rs", "handle"), ("call.rs", "unauthorised"), ("call.rs", "inbox")]),
("POST", "/call/answer", "answer.create", &[("http.rs", "handle"), ("call.rs", "handle"), ("call.rs", "unauthorised"), ("call.rs", "body"), ("call.rs", "answer_of")]),
("GET", "/call", "call.show", &[("http.rs", "handle"), ("call.rs", "handle"), ("call.rs", "unauthorised"), ("call.rs", "current")]),
];
#[test]
fn every_route_the_routers_match_is_documented_and_no_other() {
let doc: BTreeSet<Op> = documented().into_keys().collect();
let code: BTreeSet<Op> = ["http.rs", "messaging.rs", "call.rs"].iter().flat_map(|f| routes_in(f)).collect();
let undocumented: Vec<&Op> = code.difference(&doc).collect();
let unserved: Vec<&Op> = doc.difference(&code).collect();
assert!(undocumented.is_empty(), "routes the code serves that openapi.json does not describe: {undocumented:?}");
assert!(unserved.is_empty(), "routes openapi.json describes that no router matches: {unserved:?}");
let mapped: BTreeSet<Op> = HANDLERS.iter().map(|(m, p, _, _)| (m.to_string(), p.to_string())).collect();
assert_eq!(mapped, doc, "HANDLERS names the functions of every documented operation, and of no other");
}
#[test]
fn every_operation_is_named_as_the_catalogue_names_it() {
let doc = description();
let mut seen = BTreeSet::new();
for (m, p, op, _) in HANDLERS {
assert_eq!(wai::ops::check_id(op), Ok(()), "{m} {p}: {op}");
assert!(seen.insert(*op), "{op} names two operations");
let id = doc["paths"][*p][m.to_ascii_lowercase()]["operationId"].as_str();
assert_eq!(id, Some(*op), "{m} {p}: its operationId is its name in the catalogue");
}
// The operations the catalogue names with their routes.
for (m, p, op) in [("GET", "/room", "room.show"), ("POST", "/grant", "room.grant")] {
assert!(HANDLERS.iter().any(|h| (h.0, h.1, h.2) == (m, p, op)), "{op} is {m} {p}");
}
}
#[test]
fn every_documented_status_is_one_its_handlers_return_and_every_returned_status_is_documented() {
let doc = documented();
let mut by_fn: BTreeMap<(&str, &str), BTreeSet<u16>> = BTreeMap::new();
for (m, p, _, fns) in HANDLERS {
let listed = &doc[&(m.to_string(), p.to_string())];
let mut can: BTreeSet<u16> = BTreeSet::new();
for (f, n) in *fns {
can.extend(fn_statuses(f, n));
by_fn.entry((f, n)).or_default().extend(listed.iter().copied());
}
let never: Vec<&u16> = listed.difference(&can).collect();
assert!(never.is_empty(), "{m} {p}: openapi.json lists {never:?}, which none of {fns:?} returns");
}
for ((f, n), listed) in &by_fn {
let returned: BTreeSet<u16> = fn_statuses(f, n).into_iter().filter(|s| *s != 405).collect();
let missing: Vec<&u16> = returned.difference(listed).collect();
assert!(missing.is_empty(), "{f}:{n} returns {missing:?}, which openapi.json lists for none of the operations it answers");
}
}
// --------------------------------------------------------------------------------- requests
struct Answer {
status: u16,
headers: BTreeMap<String, String>,
json: Value,
}
async fn send(addr: SocketAddr, method: &str, path: &str, headers: &[(&str, String)], body: Option<Vec<u8>>) -> Answer {
let mut s = tokio::net::TcpStream::connect(addr).await.expect("the service answers");
let b = body.unwrap_or_default();
let extra: String = headers.iter().map(|(k, v)| format!("{k}: {v}\r\n")).collect();
let head = format!("{method} {path} HTTP/1.1\r\nHost: {HOST}\r\nX-Forwarded-For: 10.20.0.1\r\n{extra}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", b.len());
s.write_all(head.as_bytes()).await.unwrap();
// A body past what the request takes may be refused before it is read: a reset then is fine.
let _ = s.write_all(&b).await;
let mut out = Vec::new();
let _ = timeout(STEP, s.read_to_end(&mut out)).await.expect("an answer in time");
let text = String::from_utf8_lossy(&out).to_string();
let (head, body) = text.split_once("\r\n\r\n").unwrap_or_else(|| panic!("{method} {path}: no response: {text:?}"));
let status: u16 = head.split(' ').nth(1).unwrap().parse().unwrap();
let headers = head.lines().skip(1).filter_map(|l| l.split_once(':')).map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_string())).collect();
Answer { status, headers, json: serde_json::from_str(body).unwrap_or(Value::Null) }
}
/// What the running service answered, held to the description as it is noted.
struct Probes {
doc: BTreeMap<Op, BTreeSet<u16>>,
seen: BTreeMap<Op, BTreeSet<u16>>,
mode: &'static str,
addr: SocketAddr,
}
impl Probes {
/// Ask, require a status the description lists for the operation and, when given, the one
/// the probe was written to reach.
async fn ask(&mut self, method: &str, path: &str, headers: &[(&str, String)], body: Option<Vec<u8>>, want: u16) -> Answer {
let a = send(self.addr, method, path, headers, body).await;
let op = (method.to_string(), path.split('?').next().unwrap().to_string());
let listed = self.doc.get(&op).unwrap_or_else(|| panic!("[{}] {method} {path}: not an operation of openapi.json", self.mode));
assert!(listed.contains(&a.status), "[{}] {method} {path} answered {} {}, which openapi.json does not list ({listed:?})", self.mode, a.status, a.json);
assert_eq!(a.status, want, "[{}] {method} {path}: {}", self.mode, a.json);
self.seen.entry(op).or_default().insert(a.status);
a
}
async fn json(&mut self, method: &str, path: &str, body: Value, want: u16) -> Answer {
self.ask(method, path, &[], Some(body.to_string().into_bytes()), want).await
}
async fn get(&mut self, path: &str, want: u16) -> Answer {
self.ask("GET", path, &[], None, want).await
}
}
fn key(i: u8) -> SigningKey {
SigningKey::from_bytes(&[i; 32])
}
fn pubhex(k: &SigningKey) -> String {
hex::encode(k.verifying_key().to_bytes())
}
fn challenge_from(a: &Answer) -> [u8; 32] {
maybe_challenge(a).unwrap_or_else(|| panic!("no challenge in an answer {} {}", a.status, a.json))
}
fn maybe_challenge(a: &Answer) -> Option<[u8; 32]> {
a.headers.get(&gp::HEADER_CHALLENGE.to_ascii_lowercase()).and_then(|c| gp::from_base64url(c)).and_then(|c| c.try_into().ok())
}
async fn call_challenge(addr: SocketAddr) -> [u8; 32] {
hex_challenge(&send(addr, "GET", "/call/challenge", &[], None).await)
}
fn hex_challenge(a: &Answer) -> [u8; 32] {
hex::decode(a.json["challenge"].as_str().expect("a challenge")).unwrap().try_into().unwrap()
}
/// A grant's `Authorization` and the proof of its key over `challenge` for the request.
fn authorised(grant: Option<&[u8]>, k: &SigningKey, challenge: [u8; 32], method: &str, path: &str) -> Vec<(&'static str, String)> {
let digest = grant.map_or([0; 32], |g| gp::grant_digest_and_principal(g).unwrap().0);
let proof = GrantProof::sign(digest, k, challenge, &gp::context(method, HOST, path)).unwrap();
let mut hs = vec![(gp::HEADER_PROOF, gp::base64url(&proof.to_bytes()))];
if let Some(g) = grant {
hs.push(("Authorization", format!("Bearer {}", gp::base64url(g))));
}
hs
}
fn big(n: usize) -> Vec<u8> {
let mut v = br#"{"pad":""#.to_vec();
v.extend(std::iter::repeat_n(b'a', n));
v.extend(br#""}"#);
v
}
const OTHER_ROOM: &str = "abababababababababababababababababababababababababababababababab";
// ------------------------------------------------------------------------------------- modes
async fn plain(p: &mut Probes) {
let room = p.get("/room", 200).await.json["room"].as_str().unwrap().to_string();
p.get("/", 200).await;
p.get("/health", 200).await;
let a = p.json("POST", "/grant", json!({"principal": pubhex(&key(1)), "role": "publisher"}), 200).await;
assert!(a.json["grant"].as_str().is_some() && a.json["room"]["room"].as_str() == Some(room.as_str()));
p.json("POST", "/grant", json!({"principal": pubhex(&key(2)), "role": "publisher"}), 200).await;
p.json("POST", "/grant", json!({"principal": pubhex(&key(3)), "role": "publisher"}), 409).await;
p.json("POST", "/grant", json!({"principal": pubhex(&key(4)), "role": "viewer"}), 200).await;
p.json("POST", "/grant", json!({"principal": "00", "role": "viewer"}), 400).await;
p.json("POST", "/grant", json!({"principal": pubhex(&key(4)), "role": "owner"}), 400).await;
p.ask("POST", "/grant", &[], Some(b"not json".to_vec()), 400).await;
p.ask("POST", "/grant", &[], Some(big(2048)), 413).await;
p.get("/age/receipt", 404).await;
// The modes this room does not keep.
p.get(&format!("/mls?room={room}"), 404).await;
p.get("/mls", 400).await;
p.get(&format!("/mls?room={OTHER_ROOM}"), 410).await;
for path in ["/mls/key-package", "/mls/post"] {
p.json("POST", path, json!({"room": room, "key_package": "00", "frames": ["00"]}), 404).await;
p.json("POST", path, json!({"room": OTHER_ROOM}), 410).await;
p.json("POST", path, json!({}), 400).await;
p.ask("POST", path, &[], Some(big(140 * 1024)), 413).await;
}
p.get("/co/objects", 404).await;
p.get("/co/state", 404).await;
for path in ["/co/schedule", "/co/handover", "/co/window", "/co/fidelity"] {
p.json("POST", path, json!({}), 404).await;
}
p.get("/watch/time", 200).await;
p.get("/watch/source", 404).await;
p.get("/watch/challenge", 404).await;
p.get(&format!("/watch?room={room}&wait_ms=0"), 404).await;
p.get("/watch", 400).await;
p.get(&format!("/watch?room={OTHER_ROOM}"), 410).await;
p.get("/watch/room", 404).await;
p.json("POST", "/watch/join", json!({"room": room, "grant": "00", "proof": "00"}), 404).await;
p.json("POST", "/watch/join", json!({"room": OTHER_ROOM, "grant": "00", "proof": "00"}), 410).await;
p.json("POST", "/watch/join", json!({"room": room}), 400).await;
p.ask("POST", "/watch/join", &[], Some(big(5000)), 413).await;
p.json("POST", "/watch/post", json!({"room": room, "events": ["00"]}), 404).await;
p.json("POST", "/watch/post", json!({"room": OTHER_ROOM, "events": ["00"]}), 410).await;
p.json("POST", "/watch/post", json!({"room": room, "events": []}), 400).await;
p.ask("POST", "/watch/post", &[], Some(big(17 * 1024)), 413).await;
p.json("POST", "/remote/control", json!({}), 404).await;
p.get("/remote/room", 404).await;
p.get("/remote/state", 404).await;
for path in ["/msg", "/msg/challenge", "/msg/log"] {
p.get(path, 404).await;
}
for path in ["/msg/join", "/msg/key-package", "/msg/mls", "/msg/post", "/msg/ack", "/msg/leave", "/msg/remove"] {
p.json("POST", path, json!({}), 404).await;
}
p.get("/call/challenge", 404).await;
p.get("/inbox", 404).await;
p.get("/call", 404).await;
for path in ["/call/place", "/call/ring", "/call/answer"] {
p.json("POST", path, json!({}), 404).await;
}
// A method a documented path does not take.
for (m, path) in [("GET", "/grant"), ("POST", "/room"), ("POST", "/health"), ("GET", "/mls/post"), ("POST", "/co/state"), ("GET", "/watch/join"), ("POST", "/remote/room"), ("PUT", "/grant"), ("DELETE", "/room")] {
let a = send(p.addr, m, path, &[], None).await;
assert_eq!(a.status, 405, "{m} {path}");
}
let a = send(p.addr, "GET", "/no/such/path", &[], None).await;
assert_eq!(a.status, 404);
}
async fn without_relay(p: &mut Probes) {
p.json("POST", "/grant", json!({"principal": pubhex(&key(5)), "role": "viewer"}), 503).await;
let h = p.get("/health", 200).await;
assert_eq!(h.json["relay"], json!(false));
}
async fn rate(p: &mut Probes) {
p.json("POST", "/grant", json!({"principal": pubhex(&key(6)), "role": "viewer"}), 200).await;
p.json("POST", "/grant", json!({"principal": pubhex(&key(7)), "role": "viewer"}), 429).await;
p.get("/age/receipt", 429).await;
}
async fn e2ee(p: &mut Probes) {
let room = p.get("/room", 200).await.json["room"].as_str().unwrap().to_string();
let a = p.get(&format!("/mls?room={room}&wait_ms=0"), 200).await;
assert_eq!(a.json["room"].as_str(), Some(room.as_str()));
p.json("POST", "/mls/key-package", json!({"room": room, "key_package": "zz"}), 400).await;
p.json("POST", "/mls/key-package", json!({"room": room, "key_package": "00"}), 400).await;
p.json("POST", "/mls/post", json!({"room": room, "frames": ["00"]}), 400).await;
}
async fn age(p: &mut Probes) {
let a = p.json("POST", "/grant", json!({"principal": pubhex(&key(8)), "role": "viewer"}), 401).await;
assert_eq!(a.json["error"], json!("token_required"));
assert!(a.headers.contains_key("www-authenticate"), "a challenge for a token");
let a = p.ask("POST", "/grant", &[("Authorization", "PrivateToken token=\"!!\"".into())], Some(json!({"principal": pubhex(&key(8)), "role": "viewer"}).to_string().into_bytes()), 403).await;
assert_eq!(a.json["error"], json!("token_malformed"));
let r = p.get("/age/receipt", 200).await;
assert!(r.json["receipt"].is_object() || r.json["receipt"].is_string(), "{}", r.json);
}
async fn co(p: &mut Probes) {
let objects = p.get("/co/objects?from=0", 200).await;
assert!(objects.json["objects"].is_array());
p.get("/co/state", 200).await;
let lead = key(0x21);
let g = p.json("POST", "/grant", json!({"principal": pubhex(&lead), "role": "publisher"}), 200).await;
let grant = hex::decode(g.json["grant"].as_str().unwrap()).unwrap();
let viewer = key(0x22);
let v = p.json("POST", "/grant", json!({"principal": pubhex(&viewer), "role": "viewer"}), 200).await;
let vgrant = hex::decode(v.json["grant"].as_str().unwrap()).unwrap();
// No proof: 401 with a challenge; a proof then passes.
let a = p.json("POST", "/co/fidelity", json!({}), 401).await;
let mut c = challenge_from(&a);
let call = async |p: &mut Probes, who: &SigningKey, g: &[u8], path: &str, body: Vec<u8>, want: u16, c: &mut [u8; 32]| {
let hs = authorised(Some(g), who, *c, "POST", path);
let a = p.ask("POST", path, &hs, Some(body), want).await;
if let Some(n) = maybe_challenge(&a) {
*c = n;
} else {
*c = challenge_from(&p.json("POST", "/co/fidelity", json!({}), 401).await);
}
a
};
let a = call(p, &lead, &grant, "/co/fidelity", b"{}".to_vec(), 200, &mut c).await;
assert!(a.json["ok"].is_boolean(), "{}", a.json);
call(p, &viewer, &vgrant, "/co/schedule", json!({"mode": "mark", "segments": []}).to_string().into_bytes(), 403, &mut c).await;
call(p, &viewer, &vgrant, "/co/window", json!({"rule": "plurality", "options": 3, "seconds": 5}).to_string().into_bytes(), 403, &mut c).await;
call(p, &viewer, &vgrant, "/co/fidelity", b"{}".to_vec(), 403, &mut c).await;
call(p, &lead, &grant, "/co/schedule", json!({"mode": "neither", "segments": []}).to_string().into_bytes(), 400, &mut c).await;
call(p, &lead, &grant, "/co/handover", json!({"to": "00"}).to_string().into_bytes(), 400, &mut c).await;
call(p, &lead, &grant, "/co/window", json!({"rule": "sometimes"}).to_string().into_bytes(), 400, &mut c).await;
call(p, &lead, &grant, "/co/fidelity", b"not json".to_vec(), 400, &mut c).await;
call(p, &lead, &grant, "/co/window", big(5000), 413, &mut c).await;
let w = call(p, &lead, &grant, "/co/window", json!({"rule": "plurality", "options": 3, "seconds": 30, "question": "which"}).to_string().into_bytes(), 200, &mut c).await;
assert_eq!(w.json["ok"], json!(true), "{}", w.json);
let s = call(p, &lead, &grant, "/co/schedule", json!({"mode": "mark", "beat_us": 500000, "beats_per_bar": 4, "segments": [{"length": 8, "turns": 2, "holders": [pubhex(&lead)]}]}).to_string().into_bytes(), 200, &mut c).await;
assert_eq!(s.json["ok"], json!(true), "{}", s.json);
call(p, &lead, &grant, "/co/handover", json!({"to": pubhex(&viewer)}).to_string().into_bytes(), 400, &mut c).await;
let h = call(p, &lead, &grant, "/co/handover", json!({"to": pubhex(&lead)}).to_string().into_bytes(), 200, &mut c).await;
assert_eq!(h.json["ok"], json!(true), "{}", h.json);
}
async fn watch(p: &mut Probes) {
let r = p.get("/room", 200).await;
let room = r.json["room"].as_str().unwrap().to_string();
let room_id: [u8; 32] = hex::decode(&room).unwrap().try_into().unwrap();
let t0 = r.json["t0_us"].as_u64().unwrap();
let src = p.get("/watch/source", 200).await;
assert_eq!(src.json, Value::Null, "the source's bytes, not JSON");
p.get("/watch/room", 200).await;
p.get(&format!("/watch?room={room}&wait_ms=0"), 200).await;
let viewer = key(0x31);
let g = p.json("POST", "/grant", json!({"principal": pubhex(&viewer), "role": "viewer"}), 200).await;
let grant = hex::decode(g.json["grant"].as_str().unwrap()).unwrap();
let digest = gp::grant_digest_and_principal(&grant).unwrap().0;
let join = |c: [u8; 32], target: &str| json!({"room": room, "grant": hex::encode(&grant), "proof": hex::encode(wai::live_room::Proof::sign(digest, &viewer, c, &format!("POST {target}")).to_bytes())});
let c = hex_challenge(&p.get("/watch/challenge", 200).await);
p.json("POST", "/watch/join", join(c, "//elsewhere.test/watch/join"), 401).await;
let c = hex_challenge(&p.get("/watch/challenge", 200).await);
let j = p.json("POST", "/watch/join", join(c, &format!("//{HOST}/watch/join")), 200).await;
let joined = j.json["t"].as_u64().unwrap();
// A control in the past is refused; one ahead is carried.
use wai::co_watch::{action, Control};
let now_t = (std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_micros() as u64).saturating_sub(t0).max(joined);
let ev = |seq: u64, prev: [u8; 32], payload: Vec<u8>| wai::live_events::Event::sign(&viewer, room_id, seq, prev, now_t, None, wai::live_events::kind::META, payload);
let past = ev(0, [0; 32], Control { action: action::PLAY, at: now_t.saturating_sub(1), arg: 0 }.encode());
p.json("POST", "/watch/post", json!({"room": room, "events": [hex::encode(past.to_bytes())]}), 409).await;
let ahead = ev(0, [0; 32], Control { action: action::PLAY, at: now_t + 700_000, arg: 0 }.encode());
let a = p.json("POST", "/watch/post", json!({"room": room, "events": [hex::encode(ahead.to_bytes())]}), 200).await;
assert_eq!(a.json["carried"], json!(1));
}
async fn remote(p: &mut Probes) {
p.json("POST", "/grant", json!({"principal": pubhex(&key(0x41)), "role": "host"}), 403).await;
p.json("POST", "/grant", json!({"principal": pubhex(&key(0x41)), "role": "publisher"}), 400).await;
let ctl = key(0x42);
let g = p.json("POST", "/grant", json!({"principal": pubhex(&ctl), "role": "controller"}), 200).await;
let grant = hex::decode(g.json["grant"].as_str().unwrap()).unwrap();
let c = challenge_from(&p.get("/remote/room", 401).await);
let room = p.ask("GET", "/remote/room", &authorised(Some(&grant), &ctl, c, "GET", "/remote/room"), None, 200).await;
assert!(room.json["descriptor"].is_string(), "{}", room.json);
let c = challenge_from(&p.get("/remote/state", 401).await);
let state = p.ask("GET", "/remote/state", &authorised(Some(&grant), &ctl, c, "GET", "/remote/state"), None, 200).await;
assert!(state.json["verified"].is_object(), "{}", state.json);
let a = p.json("POST", "/remote/control", json!({}), 401).await;
let mut c = challenge_from(&a);
let body = json!({"to": pubhex(&ctl), "control": 3}).to_string().into_bytes();
let a = p.ask("POST", "/remote/control", &authorised(Some(&grant), &ctl, c, "POST", "/remote/control"), Some(body), 403).await;
c = challenge_from(&a);
p.ask("POST", "/remote/control", &authorised(Some(&grant), &ctl, c, "POST", "/remote/control"), Some(b"not json".to_vec()), 400).await;
}
async fn messaging(p: &mut Probes) {
p.get("/msg", 200).await;
// Join on a proof of the key; the first member owns the conversation.
let owner = key(0x51);
let c = hex_challenge(&p.get("/msg/challenge", 200).await);
p.ask("POST", "/msg/join", &[], None, 401).await;
let j = p.ask("POST", "/msg/join", &authorised(None, &owner, c, "POST", "/msg/join"), None, 200).await;
assert_eq!(j.json["role"], json!("owner"));
let grant = hex::decode(j.json["grant"].as_str().unwrap()).unwrap();
let mut c = challenge_from(&j);
let member = async |p: &mut Probes, method: &str, path: &str, body: Option<Vec<u8>>, want: u16, c: &mut [u8; 32]| {
let a = p.ask(method, path, &authorised(Some(&grant), &owner, *c, method, path.split('?').next().unwrap()), body, want).await;
*c = match maybe_challenge(&a) {
Some(n) => n,
None => hex_challenge(&send(p.addr, "GET", "/msg/challenge", &[], None).await),
};
a
};
let log = member(p, "GET", "/msg/log?from=0&wait_ms=0", None, 200, &mut c).await;
assert!(log.json["entries"].is_array());
member(p, "POST", "/msg/key-package", Some(b"{}".to_vec()), 400, &mut c).await;
member(p, "POST", "/msg/mls", Some(json!({"frames": []}).to_string().into_bytes()), 400, &mut c).await;
member(p, "POST", "/msg/post", Some(b"{}".to_vec()), 400, &mut c).await;
member(p, "POST", "/msg/ack", Some(json!({"ack": "00"}).to_string().into_bytes()), 409, &mut c).await;
member(p, "POST", "/msg/remove", Some(json!({"principal": pubhex(&key(0x52))}).to_string().into_bytes()), 409, &mut c).await;
member(p, "POST", "/msg/remove", Some(json!({"principal": "00"}).to_string().into_bytes()), 400, &mut c).await;
member(p, "POST", "/msg/leave", Some(b"not json".to_vec()), 400, &mut c).await;
member(p, "POST", "/msg/leave", Some(b"{}".to_vec()), 200, &mut c).await;
// Once it has left, it is no member.
member(p, "POST", "/msg/key-package", Some(json!({"key_package": "00"}).to_string().into_bytes()), 403, &mut c).await;
for path in ["/msg/key-package", "/msg/mls", "/msg/post", "/msg/ack", "/msg/leave", "/msg/remove"] {
p.json("POST", path, json!({}), 401).await;
}
p.get("/msg/log", 401).await;
let a = send(p.addr, "GET", "/msg/nothing", &[], None).await;
assert_eq!(a.status, 404);
let a = send(p.addr, "GET", "/msg/join", &[], None).await;
assert_eq!(a.status, 404, "a method a conversation's path does not take");
}
async fn calls(p: &mut Probes) {
let caller = key(0x61);
let callee = key(0x62);
p.json("POST", "/grant", json!({"principal": pubhex(&caller), "role": "publisher"}), 403).await;
let c = hex_challenge(&p.get("/call/challenge", 200).await);
p.json("POST", "/call/place", json!({"invitees": [pubhex(&callee)]}), 401).await;
p.ask("POST", "/call/place", &authorised(None, &caller, c, "POST", "/call/place"), Some(json!({"invitees": []}).to_string().into_bytes()), 400).await;
let placed = p.ask("POST", "/call/place", &authorised(None, &caller, call_challenge(p.addr).await, "POST", "/call/place"), Some(json!({"invitees": [pubhex(&callee)]}).to_string().into_bytes()), 200).await;
let grant = hex::decode(placed.json["grant"].as_str().unwrap()).unwrap();
let a = p.ask("GET", "/call", &authorised(None, &caller, call_challenge(p.addr).await, "GET", "/call"), None, 200).await;
assert!(a.json.is_object());
p.ask("GET", "/call", &authorised(None, &key(0x63), call_challenge(p.addr).await, "GET", "/call"), None, 403).await;
let a = p.ask("GET", "/inbox", &authorised(None, &callee, call_challenge(p.addr).await, "GET", "/inbox"), None, 200).await;
assert!(a.json["invitations"].is_array());
p.get("/inbox", 401).await;
p.get("/call", 401).await;
p.ask("POST", "/call/ring", &authorised(Some(&grant), &caller, call_challenge(p.addr).await, "POST", "/call/ring"), Some(json!({"invitations": []}).to_string().into_bytes()), 400).await;
p.ask("POST", "/call/answer", &authorised(None, &callee, call_challenge(p.addr).await, "POST", "/call/answer"), Some(json!({"answer": "00"}).to_string().into_bytes()), 400).await;
p.json("POST", "/call/ring", json!({}), 401).await;
p.json("POST", "/call/answer", json!({}), 401).await;
}
// ----------------------------------------------------------------------------------- running
/// A service with no relay to reach: its clock never opens a session.
async fn start_without_relay(extra: &str) -> SocketAddr {
let cfg = Config::from_toml(&format!("listen = \"127.0.0.1:0\"\nkeys = \"/nonexistent\"\nrelay_url = \"ws://127.0.0.1:9/moq\"\n{extra}")).unwrap();
let svc = Service::new(Keys::generate(), &cfg);
let listener = tokio::net::TcpListener::bind(cfg.listen).await.unwrap();
let addr = listener.local_addr().unwrap();
svc.start();
tokio::spawn(http::serve(svc, listener));
addr
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_running_service_answers_every_documented_route_with_a_documented_status() {
timeout(Duration::from_secs(300), run()).await.expect("the probes finish in time");
}
async fn run() {
let doc = documented();
let dir = std::env::temp_dir().join(format!("joule-moq-room-openapi-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let source = dir.join("source.wav");
std::fs::write(&source, (0..2048u32).map(|i| (i % 251) as u8).collect::<Vec<_>>()).unwrap();
let k16 = rfc_test_keys::k16();
let modes: Vec<(&'static str, String)> = vec![
("plain", String::new()),
("e2ee", "[room]\nmax_publishers = 3\njoin_wait_s = 30\ne2ee = true\n".into()),
("age", format!("[age]\ntoken_window_s = 600\n[[age.issuers]]\nname = \"issuer.example\"\nkeys = [{{ threshold = 16, spki = \"{}\" }}]\n[[age.ratings]]\nscheme = \"https://ratings.example/scheme\"\nvalue = \"16\"\nmin_age = 16\n", hex::encode(&k16.spki))),
("co", "[room]\nmax_publishers = 3\njoin_wait_s = 30\n[co]\naudience_input = 2\nlatency_budget_ms = 1500\nlead = \"first\"\nai = true\nfidelity_every_s = 0\ncommit_after_ms = 800\nlead_ms = 200\n".into()),
("watch", format!("[room]\nwatch_source = \"{}\"\nwatch_duration_ms = 60000\nwatch_window_ms = 2000\n", source.display())),
("remote", "[room]\nmax_present = 8\njoin_wait_s = 30\n[remote]\nwidth = 640\nheight = 360\nfps = 20\nallow = \"all\"\ncontrol = 3\nmax_age_ms = 3000\n".into()),
("messaging", format!("[messaging]\nstore = \"{}\"\nmax_members = 8\n", dir.join("conversation").display())),
("calls", "[call]\nring_ms = 30000\n".into()),
("rate", "[rate]\nclient_burst = 1\nclient_every_s = 600\n".into()),
];
let mut seen: BTreeMap<Op, BTreeSet<u16>> = BTreeMap::new();
for (mode, extra) in modes {
let room = DevRoom::start(2, &extra).await.unwrap_or_else(|e| panic!("[{mode}] the room starts: {e}"));
let mut p = Probes { doc: doc.clone(), seen: BTreeMap::new(), mode, addr: room.http };
match mode {
"plain" => plain(&mut p).await,
"e2ee" => e2ee(&mut p).await,
"age" => age(&mut p).await,
"co" => co(&mut p).await,
"watch" => watch(&mut p).await,
"remote" => remote(&mut p).await,
"messaging" => messaging(&mut p).await,
"calls" => calls(&mut p).await,
"rate" => rate(&mut p).await,
_ => unreachable!(),
}
for (op, s) in p.seen {
seen.entry(op).or_default().extend(s);
}
room.stop().await;
}
let addr = start_without_relay("").await;
let mut p = Probes { doc: doc.clone(), seen: BTreeMap::new(), mode: "without its relay", addr };
without_relay(&mut p).await;
for (op, s) in p.seen {
seen.entry(op).or_default().extend(s);
}
let _ = std::fs::remove_dir_all(&dir);
// Every documented operation was asked, and answered with a status it lists.
let unasked: Vec<&Op> = doc.keys().filter(|op| !seen.contains_key(*op)).collect();
assert!(unasked.is_empty(), "documented operations no probe asked: {unasked:?}");
let pairs: usize = doc.values().map(BTreeSet::len).sum();
let reached: usize = seen.values().map(BTreeSet::len).sum();
let ok_reached = seen.values().filter(|s| s.contains(&200)).count();
eprintln!("openapi contract: {} operations asked; {reached} of {pairs} documented (operation, status) pairs reached by a running service, {ok_reached} operations answered 200; every other listed status is one its handler returns (the source check)", doc.len());
}

View file

@ -6,6 +6,42 @@ wire format may change and such changes are recorded here.
## v0.4.0 — current
### Coding agents: `wai mcp`, `wai setup-agent`, and the room service's API described
- **`wai mcp`** (`agent.mcp`; the feature `mcp`, which the quickstart's `wai` holds): WAI's
operations as tools for a coding assistant, a Model Context Protocol server (revision
2025-06-18) on standard input and output. Each tool is an operation of the toolchain's
catalogue, named by it with `_` for the dot, in toolsets. `core`, the default:
`envelope_inspect` (an envelope or a package), `registry_list` and `registry_show` (the
registered capabilities), `receipt_verify` (a delivery receipt, a claim with the context the
tool takes, a live-room session or a stream audit bundle, by `kind` or read from the
arguments; each answer the verifier's verdict and reason code), `package_build` (a media file
under `wai wrap`'s rules, inside the project directory only), `conformance_list` and
`conformance_run` (a corpus's independent verifier), and `docs_search` (the specification's
text). `dev`, marked development only, on the target `local` alone: `dev_start` and `dev_stop`
(a relay and a room service on loopback), `room_grant` (grants for test principals, refusing
any service whose address is not a loopback address, before connecting), `room_join`,
`room_leave` and `room_session`. `--toolset` chooses; `--read-only` offers only the tools that
read or run a verifier. The verdicts agree vector by vector with jwp-conformance,
claim-conformance, live-conformance/rooms and live-conformance/audit (`tests/cli_mcp.rs`).
- **`wai setup-agent`** (`agent.setup`): writes `AGENTS.md` in a project, adds an import of it to
a `CLAUDE.md` the project already has, and registers `wai mcp` (with `--toolset`, those
toolsets) in its MCP configuration files, by their documented formats; it changes a file that
exists only after its diff is shown and confirmed, has a dry run, and writes nothing outside
the project directory. Off a terminal it answers in the toolchain's JSON envelope
(`envelope/1`), with the table's exit statuses: 2 usage, 3 permission, 4 confirmation
required (each proposed change and the confirming command given), 5 not found
(`tests/cli_setup_agent.rs`).
- **`wai mcp` and `wai setup-agent` are verbs of the one table** (`dx::VERBS`) that holds `hello`,
`create`, `dev` and `verify`. `wai::ops` holds the rule operation names keep: `noun.verb`,
lowercase, the verb from a closed list.
- **The room service's API**: `joule-moq-room/openapi.json` (OpenAPI 3.1) describes every route,
request and status of the room service in each of its modes, each operation's `operationId`
its name in the catalogue (`room.show`, `room.grant`, `watch.join`, `message.create`, ...),
held to the code by a contract test that matches the names, the routers' routes, each
operation's statuses against its handlers', and the running service's answers in every mode.
`joule-moq-room dev` runs a development room on loopback for `wai mcp` and for tests.
### The developer's path: `wai hello`, `create`, `dev` and `verify`; the floor with no system library; the examples
- **`floor`** (wai-rs feature): the mandatory floor of SPEC §5 (PNG, FLAC, zstd) decoded with no

View file

@ -91,6 +91,45 @@ and, on macOS without pkg-config, in the Homebrew "flac" formula prefix
(`/opt/homebrew/opt/flac/lib` or `/usr/local/opt/flac/lib`). Where neither finds
it, set `RUSTFLAGS="-L <the directory holding libFLAC>"`.
## For coding agents
`wai setup-agent` makes a project ready for a coding agent to use WAI. In the project directory
it writes `AGENTS.md`, the instructions agents read, adds an import of it to a `CLAUDE.md` the
project already has, and registers `wai mcp` in the project's MCP configuration: `.mcp.json`,
and the project-level MCP file of each agent whose directory the project already holds (`--all`
writes every one it knows, `--dry-run` lists them, `--toolset core,dev` registers the server
with those toolsets). A file that does not exist is created; one that exists is changed only
after its diff is shown and the change confirmed (asked on a terminal, or `--confirm`); without
either, nothing that exists changes and the command exits 4 with each proposed change and the
command that confirms. It writes nothing outside the project directory. Off a terminal its
output is the toolchain's JSON envelope.
`wai mcp` is a Model Context Protocol server on standard input and output. Each tool is an
operation of the toolchain, named by it with `_` for the dot, and the tools come in toolsets:
| Toolset | Tools |
|---|---|
| `core` (the default) | `envelope_inspect`, `registry_list`, `registry_show`, `receipt_verify`, `package_build`, `conformance_list`, `conformance_run`, `docs_search` |
| `dev` | `dev_start`, `dev_stop`, `room_grant`, `room_join`, `room_leave`, `room_session` |
`receipt_verify` verifies a delivery receipt, a claim, a live-room session or a stream audit
bundle, and answers with the verifier's verdict and reason code. The `dev` tools, marked
development only, act on the target `local` alone: they start a relay and a room service on
loopback (`joule-moq-room dev`), mint grants for test principals, join and publish as them, and
read the session for verification; the grant tool refuses any service that is not on a loopback
address. `--read-only` offers only the tools that read or run a verifier; the packaging tool
writes only inside the project directory. No tool sends anything off the machine.
```bash
cd wai-rs
cargo install --path . --no-default-features --features mcp # `wai` with `wai mcp`, no codec libraries
cd /path/to/your/project && wai setup-agent --toolset core,dev
```
The quickstart's `wai` holds `wai mcp` too. The room service's HTTP API is described, OpenAPI
3.1, in [`joule-moq-room/openapi.json`](../joule-code/joule-code-rs/crates/joule-moq-room/openapi.json),
each operation's `operationId` its name in the toolchain's catalogue.
## SDK consumption
`wai-rs` exposes a stable C ABI in [`src/ffi.rs`](wai-rs/src/ffi.rs) —

View file

@ -151,10 +151,11 @@ ffi = []
# `wai encode` writes PNG and zstd (FLAC encoding goes through libFLAC: a build with `codecs`).
# The quickstart's `wai` is built with it alone (`--no-default-features --features floor`).
floor = ["dep:image", "dep:claxon", "dep:zstd"]
# What the quickstart's `wai` holds: the floor, and `wai hello` and `wai verify` (a receipt
# sealed and checked as http-delivery §8 lays receipts out). Pure Rust and the floor's zstd:
# no system library. `wai create` and `wai dev` need no feature.
quickstart = ["floor", "http", "receipt_claims", "dep:getrandom"]
# What the quickstart's `wai` holds: the floor, `wai hello` and `wai verify` (a receipt
# sealed and checked as http-delivery §8 lays receipts out), and `wai mcp`, the server
# `wai setup-agent` registers. Pure Rust and the floor's zstd: no system library. `wai create`,
# `wai dev` and `wai setup-agent` need no feature.
quickstart = ["floor", "http", "receipt_claims", "mcp", "dep:getrandom"]
# Pulls in the ONNX runtime + zstd unpack so the native sink can
# decode `wai.neural.<...>` envelopes. Off by default — the bare
# container + zeroth codecs build with no ML dep chain.
@ -706,6 +707,13 @@ pq_signatures = ["key_discovery", "dep:ml-dsa"]
# http-delivery §8.4): rustls with the operating system's trust store. Built into the
# binary only; without it the CLI refuses to anchor rather than fetch over plaintext.
cli_tls = ["http", "key_discovery", "dep:rustls", "dep:rustls-platform-verifier"]
# `wai mcp`: WAI's operations as tools for a coding agent, a Model Context Protocol server on
# standard input and output (src/bin/wai_cli/mcp/), each tool named by its operation, in toolsets
# (`core`, `dev`). Built into the binary only: the verifiers it
# serves (delivery receipts and claims, live rooms, stream audit bundles) and Ed25519 for the
# development room's test principals. It opens no connection but to a room service on a loopback
# address; no new dependency.
mcp = ["receipt_claims", "live_room", "stream_audit", "ed25519"]
# Emit a REAL C2PA manifest (not just the assertion shapes). Off by default:
# the SDK is a large dependency chain and most WAI consumers only need the
# binding types, which `provenance` already gives them.
@ -1491,6 +1499,12 @@ name = "cli_http"
path = "tests/cli_http.rs"
required-features = ["http"]
# `wai mcp` driven by an MCP client over its standard input and output: every tool.
[[test]]
name = "cli_mcp"
path = "tests/cli_mcp.rs"
required-features = ["mcp"]
[[test]]
name = "int_kernels_vectors"
path = "tests/int_kernels_vectors.rs"

View file

@ -54,8 +54,8 @@ mod score;
#[path = "wai_cli/encode.rs"]
mod platform_encode_cli;
// The developer's path: `hello`, `create`, `dev`, `verify`, one module a verb, named in one
// table (dx::VERBS).
// Every verb kept in a module of its own, named in one table (dx::VERBS): the developer's
// path (`hello`, `create`, `dev`, `verify`) and the agent door (`mcp`, `setup-agent`).
#[path = "wai_cli/dx/mod.rs"]
mod dx;
@ -634,7 +634,7 @@ fn license_str(cap: &str) -> &'static str {
/// than `create`: `replicate` is refused over it (§7), and so is any other, so its
/// envelope is written with `--intent create`.
fn wrap(cap: &str, input: &str, out: &str, fallback: Option<&str>,
intent: Option<&str>, companions: &[(String, String)]) -> Result<(), String> {
intent: Option<&str>, companions: &[(String, String)]) -> Result<Wrapped, String> {
if !wai::codecs::is_known_capability(cap) {
return Err(format!("'{cap}' is not a registered capability (see `wai capabilities`)"));
}
@ -694,10 +694,25 @@ fn wrap(cap: &str, input: &str, out: &str, fallback: Option<&str>,
})?;
let n = envelope.write(out).map_err(|e| e.to_string())?;
let tier = wai::codecs::capability_tier(cap).map(|t| format!("{t:?}")).unwrap_or_else(|| "?".into());
println!("wrapped → {out} ({n} B envelope, {n_in} B payload)");
println!(" capability {cap} · media {} · {tier} · {} · intent {intent}",
media_for(cap), license_str(cap));
Ok(())
Ok(Wrapped {
lines: vec![
format!("wrapped → {out} ({n} B envelope, {n_in} B payload)"),
format!(" capability {cap} · media {} · {tier} · {} · intent {intent}", media_for(cap), license_str(cap)),
],
facts: serde_json::json!({
"output": out, "format": "WAI1", "envelope_bytes": n, "intent": intent, "media": media_for(cap),
"components": [{"index": 0, "capability": cap, "role": "rendition", "bytes": n_in, "licence": license_str(cap), "tier": tier}],
"fallback": fallback,
}),
})
}
/// What `wrap` wrote: the lines the command prints, and the same facts as JSON for a caller
/// that reads them (`wai mcp`'s packaging tool).
struct Wrapped {
lines: Vec<String>,
#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
facts: serde_json::Value,
}
/// SPEC §5, *Payload of `wai.generate.spectral`*: the payload of that string is an
@ -758,7 +773,7 @@ fn generate_lines(found: &[String]) -> Vec<String> {
/// The `WAI2` half of [`wrap`]: the base payload as the one selectable
/// rendition, each companion bound to it.
fn wrap_with_companions(cap: &str, payload: Vec<u8>, out: &str, fallback: Option<&str>,
intent: &str, companions: &[(String, String)]) -> Result<(), String> {
intent: &str, companions: &[(String, String)]) -> Result<Wrapped, String> {
if fallback.is_some() {
return Err("--fallback names a WAI1 alternative; a WAI2 envelope carries alternatives \
as renditions (SPEC \u{a7}7.1)".into());
@ -790,23 +805,41 @@ fn wrap_with_companions(cap: &str, payload: Vec<u8>, out: &str, fallback: Option
envelope.check_component_roles()
.map_err(|e| format!("{e} (SPEC \u{a7}5, \u{a7}7.1)"))?;
let n = envelope.write(out).map_err(|e| e.to_string())?;
println!("wrapped → {out} ({n} B WAI2 envelope, {} components, intent {intent})",
envelope.renditions.len());
print_components(&envelope);
Ok(())
let mut lines = vec![format!("wrapped → {out} ({n} B WAI2 envelope, {} components, intent {intent})",
envelope.renditions.len())];
lines.extend(component_lines(&envelope));
let components: Vec<serde_json::Value> = envelope.manifest.renditions.iter().zip(envelope.renditions.iter()).enumerate()
.map(|(i, (meta, p))| serde_json::json!({
"index": i, "capability": meta.capability, "bytes": p.len(), "licence": license_str(&meta.capability),
"role": if meta.is_companion() { format!("companion of {}", meta.companion_of.as_deref().unwrap_or("?")) } else { "rendition".into() },
}))
.collect();
Ok(Wrapped {
lines,
facts: serde_json::json!({"output": out, "format": "WAI2", "envelope_bytes": n, "intent": intent, "media": media_for(cap), "components": components}),
})
}
/// One line per `WAI2` component: index, capability, role, size, licence.
fn print_components(m: &wai::container::WaiMulti) {
for l in component_lines(m) {
println!("{l}");
}
}
/// [`print_components`]'s lines.
fn component_lines(m: &wai::container::WaiMulti) -> Vec<String> {
let mut lines = Vec::new();
for (i, (meta, p)) in m.manifest.renditions.iter().zip(m.renditions.iter()).enumerate() {
let role = match (meta.is_companion(), meta.companion_of.as_deref()) {
(true, Some(of)) => format!("companion of {of}"),
(true, None) => "companion (unbound)".into(),
(false, _) => "rendition".into(),
};
println!(" [{i}] {:<26} {:<30} {:>10} B {}", meta.capability, role, p.len(),
license_str(&meta.capability));
lines.push(format!(" [{i}] {:<26} {:<30} {:>10} B {}", meta.capability, role, p.len(),
license_str(&meta.capability)));
}
lines
}
/// `wai unwrap <in.wai> [-o payload] [--component <i>]` — parse the envelope,
@ -976,8 +1009,7 @@ fn main() -> ExitCode {
}
if let Some(r) = dx::dispatch(&args) {
return match r {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::from(1),
Ok(code) => ExitCode::from(code),
Err(e) => die(e),
};
}
@ -1008,7 +1040,8 @@ fn main() -> ExitCode {
let out = opt(&args[3..], "-o").map(String::from).unwrap_or("out.wai".into());
match companion_args(&args[3..]) {
Ok(comps) => wrap(&args[1], &args[2], &out, opt(&args[3..], "--fallback"),
opt(&args[3..], "--intent"), &comps),
opt(&args[3..], "--intent"), &comps)
.map(|w| w.lines.iter().for_each(|l| println!("{l}"))),
Err(e) => Err(e),
}
}

View file

@ -213,8 +213,9 @@ fn find_web(flag_value: Option<&str>, exe_dir: Option<&Path>) -> Result<PathBuf,
))
}
/// A binary: the flag, the environment variable, beside this binary, then `$PATH`.
fn find_bin(name: &str, flag_value: Option<&str>, env: &str, exe_dir: Option<&Path>) -> Result<PathBuf, String> {
/// A binary: the flag, the environment variable, beside this binary, then `$PATH`. `wai mcp`'s
/// development room finds the room service the same way.
pub(super) fn find_bin(name: &str, flag_value: Option<&str>, env: &str, exe_dir: Option<&Path>) -> Result<PathBuf, String> {
if let Some(p) = flag_value.map(PathBuf::from).or_else(|| std::env::var_os(env).map(PathBuf::from)) {
return if p.is_file() { Ok(p) } else { Err(format!("{name}: {} is not a file", p.display())) };
}

View file

@ -1,9 +1,12 @@
//! The developer's path through WAI, one verb a module, every verb in [`VERBS`]:
//! Every verb kept in a module of its own, one verb a module, every verb in [`VERBS`]:
//!
//! - `hello`: the four steps of WAI (name, dispatch, rebuild, receipt) on a bundled sample;
//! - `create`: a new project from one of the examples (`wai/examples/`);
//! - `dev`: a relay, a room service and the room page on one origin, on this machine;
//! - `verify`: the receipts under a directory, checked offline.
//! - `verify`: the receipts under a directory, checked offline;
//! - `mcp` (`agent.mcp`): WAI's operations as tools for a coding agent, a Model Context
//! Protocol server on standard input and output, in toolsets (`../mcp/`);
//! - `setup-agent` (`agent.setup`): a project made ready for coding agents (`../setup_agent.rs`).
//!
//! A verb is named in this table and nowhere else: renaming or regrouping one is a line
//! here. The project file a template writes (`wai.json`) is read by [`project`] alone.
@ -15,25 +18,62 @@ mod dev;
mod hello;
mod verify;
/// One verb: its name, its one-line usage, and what runs it. `run` returns `Ok(true)` when
/// everything it checked held, `Ok(false)` when it ran and something did not (exit 1).
#[cfg(feature = "mcp")]
#[path = "../mcp/mod.rs"]
mod mcp;
#[path = "../setup_agent.rs"]
mod setup_agent;
/// The verb `wai mcp` is called by; `setup-agent` registers the server under it.
pub const MCP: &str = "mcp";
/// How a verb ends.
pub enum Run {
/// `Ok(true)` when everything it checked held (exit 0), `Ok(false)` when it ran and
/// something did not (exit 1); an error is a usage error (exit 2).
Checks(fn(&[String]) -> Result<bool, String>),
/// An exit status of the toolchain's table (0 done, 1 a check failed, 2 usage, 3
/// permission, 4 confirmation required, 5 not found, 6 unavailable, 7 internal), the
/// verb's output already written.
Status(fn(&[String]) -> u8),
}
/// One verb: its name, its usage, and what runs it.
pub struct Verb {
pub name: &'static str,
pub usage: &'static str,
pub run: fn(&[String]) -> Result<bool, String>,
pub run: Run,
}
pub const VERBS: &[Verb] = &[
Verb { name: "hello", usage: hello::USAGE, run: hello::main },
Verb { name: "create", usage: create::USAGE, run: create::main },
Verb { name: "dev", usage: dev::USAGE, run: dev::main },
Verb { name: "verify", usage: verify::USAGE, run: verify::main },
Verb { name: "hello", usage: hello::USAGE, run: Run::Checks(hello::main) },
Verb { name: "create", usage: create::USAGE, run: Run::Checks(create::main) },
Verb { name: "dev", usage: dev::USAGE, run: Run::Checks(dev::main) },
Verb { name: "verify", usage: verify::USAGE, run: Run::Checks(verify::main) },
Verb { name: MCP, usage: MCP_USAGE, run: Run::Checks(mcp_main) },
Verb { name: "setup-agent", usage: setup_agent::USAGE, run: Run::Status(setup_agent::main) },
];
/// The verb `args[0]` names, run on the rest; `None` when it names none of these.
pub fn dispatch(args: &[String]) -> Option<Result<bool, String>> {
const MCP_USAGE: &str = "wai mcp [--toolset core,dev] [--read-only] [--project DIR] [--spec-root DIR] [--room-bin BIN]\n WAI's operations as tools for a coding agent: a Model Context Protocol server on standard\n input and output (a build with `mcp`)";
#[cfg(feature = "mcp")]
fn mcp_main(args: &[String]) -> Result<bool, String> {
mcp::main(args)
}
#[cfg(not(feature = "mcp"))]
fn mcp_main(_: &[String]) -> Result<bool, String> {
Err(format!("`wai {MCP}` needs a build with the feature `mcp` (cargo install --path wai/wai-rs --no-default-features --features mcp)"))
}
/// The verb `args[0]` names, run on the rest, and its exit status; `None` when it names none
/// of these.
pub fn dispatch(args: &[String]) -> Option<Result<u8, String>> {
let v = VERBS.iter().find(|v| Some(v.name) == args.first().map(String::as_str))?;
Some((v.run)(&args[1..]))
Some(match v.run {
Run::Checks(run) => run(&args[1..]).map(|held| if held { 0 } else { 1 }),
Run::Status(run) => Ok(run(&args[1..])),
})
}
/// Each verb's usage line, for `wai`'s own usage.

View file

@ -0,0 +1,335 @@
//! The `dev` toolset: a relay and a room service on loopback, run as the room service's `dev`
//! mode (`joule-moq-room dev`, a child process driven by JSON control lines) by `dev_start` and
//! `dev_stop`; and on that room, the target `local`, grants minted for test principals
//! (`room_grant`), those principals joining, publishing and leaving (`room_join`, `room_leave`),
//! and the room's session as a verifier is given it (`room_session`).
//!
//! **Development only.** The room binds 127.0.0.1 alone and its keys live only in the child
//! process. A grant is asked for over HTTP from a service whose address is a loopback address,
//! and from no other: the guard is [`loopback_service`], applied before any connection is made,
//! with no name resolved. A test principal's key is SHA-256 of its name under a fixed label, so
//! the same name is the same key in every run, and is never a key anyone should hold.
use super::tools::{name_of, opt_str, str_arg, unhex};
use super::Server;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::mpsc::{channel, Receiver};
use std::time::Duration;
/// How long the room has to start, and each control line to be answered (a join waits for the
/// clock's records, up to 20 s, inside the child).
const START: Duration = Duration::from_secs(60);
const ANSWER: Duration = Duration::from_secs(45);
/// A test principal: its name's key, and the grant last minted for it.
pub(crate) struct Principal {
seed: [u8; 32],
public: [u8; 32],
grant: Option<(String, Vec<u8>)>,
}
impl Principal {
fn named(name: &str) -> Principal {
let mut h = Sha256::new();
h.update(b"wai mcp development principal\x00");
h.update(name.as_bytes());
let seed: [u8; 32] = h.finalize().into();
let public = ed25519_dalek::SigningKey::from_bytes(&seed).verifying_key().to_bytes();
Principal { seed, public, grant: None }
}
}
/// The running development room: the child, its control channel and its `ready` line.
pub(crate) struct Dev {
child: Child,
stdin: ChildStdin,
lines: Receiver<String>,
ready: Value,
next: u64,
}
impl Dev {
fn spawn(bin: &PathBuf, publishers: u64) -> Result<Dev, String> {
let mut child = Command::new(bin)
.arg("dev")
.arg("--publishers")
.arg(publishers.to_string())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| format!("{}: {e}", bin.display()))?;
let stdin = child.stdin.take().expect("piped");
let stdout = child.stdout.take().expect("piped");
let (tx, lines) = channel();
std::thread::spawn(move || {
for line in BufReader::new(stdout).lines() {
let Ok(line) = line else { break };
if tx.send(line).is_err() {
break;
}
}
});
let mut d = Dev { child, stdin, lines, ready: Value::Null, next: 1 };
let first = d.lines.recv_timeout(START).map_err(|_| {
let _ = d.child.kill();
format!("{} dev did not start within {} s", bin.display(), START.as_secs())
})?;
let ready: Value = serde_json::from_str(&first).map_err(|e| format!("the room's first line is not JSON: {e}"))?;
if ready["ready"] != json!(true) {
let _ = d.child.kill();
return Err(format!("the room did not start: {}", ready["error"].as_str().unwrap_or(&first)));
}
d.ready = ready;
Ok(d)
}
/// One control line and its answer.
fn ask(&mut self, mut line: Value) -> Result<Value, String> {
let id = self.next;
self.next += 1;
line["id"] = json!(id);
writeln!(self.stdin, "{line}").and_then(|_| self.stdin.flush()).map_err(|e| format!("the development room has stopped: {e}"))?;
loop {
let got = self.lines.recv_timeout(ANSWER).map_err(|_| "the development room did not answer in time".to_string())?;
let v: Value = serde_json::from_str(&got).map_err(|e| format!("the room answered with a line that is not JSON: {e}"))?;
if v["id"] == json!(id) {
return if v["ok"] == json!(true) {
let mut v = v;
if let Some(o) = v.as_object_mut() {
o.remove("id");
o.remove("ok");
}
Ok(v)
} else {
Err(v["error"].as_str().unwrap_or("the room refused it").to_string())
};
}
}
}
/// Stop the room and wait for the child.
pub(crate) fn stop(mut self) {
let _ = writeln!(self.stdin, "{}", json!({"op": "stop"}));
let _ = self.stdin.flush();
for _ in 0..100 {
if let Ok(Some(_)) = self.child.try_wait() {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn dev_mut(server: &mut Server) -> Result<&mut Dev, String> {
server.dev.as_mut().ok_or_else(|| format!("no development room is running: {} starts one", name_of(start)))
}
/// The room service program: the argument, `--room-bin` or `WAI_ROOM_BIN`, or `joule-moq-room`
/// beside this binary, then on `PATH`, as `wai dev` finds it.
fn room_bin(server: &Server, args: &Value) -> Result<PathBuf, String> {
let given = opt_str(args, "room_bin").map(String::from).or_else(|| server.room_bin.as_ref().map(|p| p.display().to_string()));
let exe_dir = std::env::current_exe().ok().and_then(|e| std::fs::canonicalize(e).ok()).and_then(|e| e.parent().map(Path::to_path_buf));
super::super::dev::find_bin("joule-moq-room", given.as_deref(), "WAI_ROOM_BIN", exe_dir.as_deref()).map_err(|e| {
format!("{e}. The development room runs the room service joule-moq-room (built from joule-code/joule-code-rs/crates/joule-moq-room): give room_bin, --room-bin or WAI_ROOM_BIN")
})
}
/// The tools of this set act on the target `local` alone: a target named, if any, must be it.
fn local_target(args: &Value) -> Result<(), String> {
match args.get("target") {
None => Ok(()),
Some(Value::String(t)) if t == "local" => Ok(()),
Some(t) => Err(format!("target {t}: the development tools act on the target local alone (this machine's development room)")),
}
}
pub(crate) fn start(server: &mut Server, args: &Value) -> Result<Value, String> {
if let Some(d) = &server.dev {
let mut v = d.ready.clone();
v["already_running"] = json!(true);
return Ok(v);
}
let bin = room_bin(server, args)?;
let publishers = args.get("publishers").and_then(Value::as_u64).unwrap_or(4).clamp(1, 64);
let d = Dev::spawn(&bin, publishers)?;
let mut v = d.ready.clone();
if let Some(o) = v.as_object_mut() {
o.remove("ready");
}
v["development_only"] = json!(true);
v["warning"] = json!("a development room on loopback: keys in memory, media held to no receipts, rate limits open; never serve it");
server.dev = Some(d);
server.principals.clear();
Ok(v)
}
pub(crate) fn stop(server: &mut Server, _: &Value) -> Result<Value, String> {
let d = server.dev.take().ok_or("no development room is running")?;
d.stop();
server.principals.clear();
Ok(json!({"stopped": true}))
}
/// The address and path prefix of a room service URL whose host is a loopback address, or
/// the refusal. `localhost` is taken as 127.0.0.1 and no name is resolved: a service on any
/// other host, by any name, is refused before a connection is made.
pub(crate) fn loopback_service(url: &str) -> Result<(SocketAddr, String), String> {
let refuse = |why: &str| {
Err(format!(
"DEVELOPMENT ONLY: development grants are minted on a room service on this machine alone, and {url} {why}. \
A deployed service grants by its own rules; ask its operator."
))
};
let Some(rest) = url.strip_prefix("http://") else { return refuse("is not an http:// address on loopback") };
let (authority, prefix) = match rest.find('/') {
Some(i) => (&rest[..i], rest[i..].trim_end_matches('/').to_string()),
None => (rest, String::new()),
};
if authority.contains('@') {
return refuse("carries credentials");
}
let (host, port) = match authority.rsplit_once(':') {
Some((h, p)) if !h.is_empty() && !p.contains(']') => (h, p.parse::<u16>().map_err(|_| format!("{url}: a port"))?),
_ => (authority, 80),
};
let ip: IpAddr = match host {
"localhost" => IpAddr::V4(Ipv4Addr::LOCALHOST),
h if h.starts_with('[') && h.ends_with(']') => match h[1..h.len() - 1].parse::<Ipv6Addr>() {
Ok(a) => IpAddr::V6(a),
Err(_) => return refuse("does not name a loopback address"),
},
h => match h.parse::<Ipv4Addr>() {
Ok(a) => IpAddr::V4(a),
Err(_) => return refuse("does not name a loopback address"),
},
};
if !ip.is_loopback() {
return refuse("is not on a loopback address");
}
Ok((SocketAddr::new(ip, port), prefix))
}
/// One HTTP/1.1 POST of JSON to a loopback service: its status and its JSON.
fn post(addr: SocketAddr, path: &str, body: &Value) -> Result<(u16, Value), String> {
let mut s = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| format!("{addr}: {e}"))?;
s.set_read_timeout(Some(Duration::from_secs(20))).map_err(|e| e.to_string())?;
let b = body.to_string();
write!(s, "POST {path} HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{b}", b.len()).map_err(|e| e.to_string())?;
let mut out = Vec::new();
s.read_to_end(&mut out).map_err(|e| format!("{addr}: {e}"))?;
let text = String::from_utf8_lossy(&out);
let (head, body) = text.split_once("\r\n\r\n").ok_or("the service's answer is not HTTP")?;
let status = head.split(' ').nth(1).and_then(|s| s.parse().ok()).ok_or("the service's answer has no status")?;
Ok((status, serde_json::from_str(body).unwrap_or(Value::Null)))
}
pub(crate) fn grant(server: &mut Server, args: &Value) -> Result<Value, String> {
local_target(args)?;
let name = str_arg(args, "principal")?.to_string();
let role = str_arg(args, "role")?;
if !matches!(role, "publisher" | "viewer") {
return Err("role: publisher or viewer".into());
}
let service = match opt_str(args, "service") {
Some(s) => s.to_string(),
None => dev_mut(server)?.ready["service"].as_str().unwrap_or_default().to_string(),
};
// The guard: nothing is sent unless the service is on this machine.
let (addr, prefix) = loopback_service(&service)?;
let p = server.principals.entry(name.clone()).or_insert_with(|| Principal::named(&name));
let (status, v) = post(addr, &format!("{prefix}/grant"), &json!({"principal": super::tools::hex(&p.public), "role": role}))?;
if status != 200 {
return Err(format!("the service refused the grant: {status} {v}"));
}
let g = v["grant"].as_str().and_then(unhex).ok_or("the service's answer holds no grant")?;
p.grant = Some((role.to_string(), g));
Ok(json!({
"development_only": true,
"target": "local",
"principal": name,
"key": super::tools::hex(&p.public),
"role": role,
"grant": v["grant"],
"digest": v["digest"],
"exp": v["exp"],
"room": v["room"]["room"],
"service": service,
"note": "a test principal's key is derived from its name: it is never a key to hold",
}))
}
pub(crate) fn join(server: &mut Server, args: &Value) -> Result<Value, String> {
local_target(args)?;
let name = str_arg(args, "principal")?.to_string();
let objects = args.get("objects").and_then(Value::as_u64).unwrap_or(1).clamp(1, 64);
let (seed, grant) = {
let p = server.principals.get(&name).ok_or_else(|| format!("{name} holds no grant: {} mints one", name_of(grant)))?;
match &p.grant {
Some((role, g)) if role == "publisher" => (p.seed, g.clone()),
_ => return Err(format!("{name} holds no publisher's grant: {} mints one with role publisher", name_of(grant))),
}
};
let mut v = dev_mut(server)?.ask(json!({"op": "join", "seed": super::tools::hex(&seed), "grant": super::tools::hex(&grant), "objects": objects}))?;
v["development_only"] = json!(true);
v["name"] = json!(name);
Ok(v)
}
pub(crate) fn leave(server: &mut Server, args: &Value) -> Result<Value, String> {
local_target(args)?;
let name = str_arg(args, "principal")?.to_string();
let public = server.principals.get(&name).map(|p| p.public).ok_or_else(|| format!("no test principal {name}"))?;
let mut v = dev_mut(server)?.ask(json!({"op": "leave", "principal": super::tools::hex(&public)}))?;
v["development_only"] = json!(true);
v["name"] = json!(name);
Ok(v)
}
pub(crate) fn session(server: &mut Server, args: &Value) -> Result<Value, String> {
local_target(args)?;
let mut v = dev_mut(server)?.ask(json!({"op": "session"}))?;
v["development_only"] = json!(true);
Ok(v)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_a_loopback_service_is_taken() {
for ok in ["http://127.0.0.1:9682", "http://127.0.0.1:9682/room-api", "http://localhost:1/", "http://[::1]:80", "http://127.9.9.9"] {
assert!(loopback_service(ok).is_ok(), "{ok}");
}
assert_eq!(loopback_service("http://127.0.0.1:9682/room-api/").unwrap().1, "/room-api");
for no in [
"https://127.0.0.1:9682",
"http://10.0.0.5:9682",
"http://192.168.4.37",
"http://room.example",
"http://localhost.example:80",
"http://127.0.0.1.example",
"http://[email protected]:80",
"http://[2001:db8::1]:80",
"ws://127.0.0.1:1",
"127.0.0.1:9682",
] {
let e = loopback_service(no).expect_err(no);
assert!(e.starts_with("DEVELOPMENT ONLY"), "{e}");
}
}
#[test]
fn a_name_is_the_same_key_every_time() {
assert_eq!(Principal::named("alice").public, Principal::named("alice").public);
assert_ne!(Principal::named("alice").public, Principal::named("bob").public);
}
}

View file

@ -0,0 +1,240 @@
//! `wai mcp` (catalogue `agent.mcp`): WAI's operations as tools for a coding assistant, served
//! over the Model Context Protocol (revision 2025-06-18) on standard input and output, one
//! JSON-RPC message per line. Standard output carries the protocol alone; anything else goes to
//! standard error.
//!
//! A tool is an operation of the toolchain's catalogue, named by it: the operation's name with
//! `_` for the dot (`receipt.verify` is `receipt_verify`), never a name of its own. The tools
//! (`tools.rs`, one table: an operation is one line there) come in toolsets, and a server offers
//! the toolsets it is started with:
//!
//! - `core` (the default): inspect envelopes and packages, read the capability registry, verify
//! receipts, claims, live-room sessions and audit bundles, package a media file, list and run
//! conformance corpora, search the specification's text;
//! - `dev`: a development room on loopback (`dev_room.rs`): start and stop it, mint grants for
//! test principals, join and publish as them, and read the session, each on the target
//! `local` alone.
//!
//! Each answers with a structured result. Nothing leaves the machine, and the only tool that
//! takes a service's address refuses every one that is not on this machine.
//!
//! ```text
//! wai mcp [--toolset <set>[,<set>...]] [--read-only] [--project <dir>] [--spec-root <wai-dir>] [--room-bin <path>]
//! ```
//!
//! `--toolset` names the toolsets to offer (`core` when none is named; repeated or
//! comma-separated). `--spec-root` (or `WAI_SPEC_ROOT`) is the `wai/` directory whose
//! specification text and conformance corpora the tools read; by default the one this binary
//! was built from, when it is still there. `--room-bin` (or `WAI_ROOM_BIN`) is the room service
//! program the development room runs (`joule-moq-room`); by default the one beside this binary,
//! then on `PATH`. `--project` is the directory the server may write in (the packaging tool's
//! envelopes), by default the one it starts in; it writes nowhere else. `--read-only` offers only
//! the tools that read or run a verifier: nothing that writes a file, starts a process that
//! serves, or asks a service for a grant.
use serde_json::{json, Value};
use std::io::{BufRead, Write};
use std::path::PathBuf;
#[path = "dev_room.rs"]
mod dev_room;
#[path = "tools.rs"]
mod tools;
/// The protocol revisions this server speaks, newest first.
const PROTOCOLS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
/// What the server tells an assistant when it connects, a sentence for each toolset it offers.
/// It names no tool: the tools' table is the one place their names are written.
fn instructions(toolsets: &[tools::Toolset]) -> String {
let mut s = String::from("WAI's operations as tools, on this machine; each tool is an operation of the WAI toolchain, named by it.");
if toolsets.contains(&tools::Toolset::Core) {
s.push_str(" Inspect a .wai envelope or a package directory; list and show the registered capabilities and their licence \
classes; verify a delivery receipt, a claim, a live-room session or an audit bundle (each answer is the verifier's verdict and its \
reason code: quote it rather than judging the input yourself); package a media file into a WAI envelope; list and run conformance \
corpora through their independent verifiers; and search the specification's text, which is the authority on every rule.");
}
if toolsets.contains(&tools::Toolset::Dev) {
s.push_str(" The development tools, each marked DEVELOPMENT ONLY, start a relay and a room service on loopback with keys \
held in memory, mint grants for test principals, join and publish as them, and read the room's session for verification; they act \
on the target local alone and refuse any service that is not on this machine.");
}
s.push_str(" No tool sends anything off the machine.");
s
}
/// The server's state across calls: where it reads the specification, the room service it
/// runs, the development room while one runs, and the name the client gave.
pub(crate) struct Server {
pub(crate) toolsets: Vec<tools::Toolset>,
pub(crate) read_only: bool,
pub(crate) project: PathBuf,
pub(crate) spec_root: Option<PathBuf>,
pub(crate) spec_root_from: &'static str,
pub(crate) room_bin: Option<PathBuf>,
pub(crate) dev: Option<dev_room::Dev>,
pub(crate) principals: std::collections::BTreeMap<String, dev_room::Principal>,
pub(crate) client: Option<String>,
}
impl Server {
fn new(toolsets: Vec<tools::Toolset>, spec_root: Option<PathBuf>, room_bin: Option<PathBuf>, project: PathBuf, read_only: bool) -> Server {
let (spec_root, spec_root_from) = match spec_root {
Some(p) => (Some(p), "--spec-root"),
None => match std::env::var_os("WAI_SPEC_ROOT") {
Some(p) => (Some(PathBuf::from(p)), "WAI_SPEC_ROOT"),
None => {
let built = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
if built.join("SPEC.md").is_file() { (Some(built), "the tree this binary was built from") } else { (None, "none") }
}
},
};
let room_bin = room_bin.or_else(|| std::env::var_os("WAI_ROOM_BIN").map(PathBuf::from));
Server { toolsets, read_only, project, spec_root, spec_root_from, room_bin, dev: None, principals: Default::default(), client: None }
}
/// `p` (relative to the project directory) as a path to write, when it is inside the
/// project directory, its parent directory exists, and it is not a link.
pub(crate) fn inside_project(&self, p: &std::path::Path) -> Result<PathBuf, String> {
let abs = if p.is_absolute() { p.to_path_buf() } else { self.project.join(p) };
let name = abs.file_name().ok_or_else(|| format!("{}: not a file name", p.display()))?;
let parent = abs.parent().unwrap_or(&self.project).canonicalize().map_err(|e| format!("{}: {e}", p.display()))?;
if !parent.starts_with(&self.project) {
return Err(format!("{} is outside the project directory {}; this server writes only inside it", p.display(), self.project.display()));
}
let target = parent.join(name);
if std::fs::symlink_metadata(&target).is_ok_and(|m| m.file_type().is_symlink()) {
return Err(format!("{} is a link; this server writes only plain files inside the project directory", target.display()));
}
Ok(target)
}
/// The specification's root, or why there is none.
pub(crate) fn spec_root(&self) -> Result<PathBuf, String> {
let root = self.spec_root.as_ref().ok_or(
"no specification tree: start the server with --spec-root <the repository's wai/ directory>, or set WAI_SPEC_ROOT",
)?;
if !root.join("SPEC.md").is_file() {
return Err(format!("{} (from {}) holds no SPEC.md: it is not a wai/ directory", root.display(), self.spec_root_from));
}
root.canonicalize().map_err(|e| format!("{}: {e}", root.display()))
}
/// One JSON-RPC message's answer; `None` for a notification.
pub(crate) fn handle(&mut self, msg: &Value) -> Option<Value> {
if msg.is_array() {
return Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "a batch is not taken (MCP 2025-06-18): send one message at a time"}}));
}
// A response or a notification from the client (no id): nothing to answer.
let id = msg.get("id").cloned()?;
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
let params = msg.get("params").cloned().unwrap_or(json!({}));
if method.is_empty() {
return None;
}
let ok = |result: Value| Some(json!({"jsonrpc": "2.0", "id": id, "result": result}));
let err = |code: i64, message: String| Some(json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}));
match method {
"initialize" => {
self.client = params.pointer("/clientInfo/name").and_then(Value::as_str).map(String::from);
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOLS[0]);
let version = PROTOCOLS.iter().find(|v| **v == asked).copied().unwrap_or(PROTOCOLS[0]);
ok(json!({
"protocolVersion": version,
"capabilities": {"tools": {"listChanged": false}},
"serverInfo": {"name": "wai", "title": "WAI", "version": env!("CARGO_PKG_VERSION")},
"instructions": instructions(&self.toolsets),
}))
}
"ping" => ok(json!({})),
"tools/list" => ok(json!({"tools": tools::list(self)})),
"tools/call" => {
let name = params.get("name").and_then(Value::as_str).unwrap_or("");
let args = params.get("arguments").cloned().unwrap_or(json!({}));
if !args.is_object() {
return err(-32602, "arguments: an object".into());
}
match tools::call(self, name, &args) {
None => err(-32602, format!("no tool {name}; tools/list names them")),
Some(Ok(v)) => {
let text = serde_json::to_string_pretty(&v).unwrap_or_default();
ok(json!({"content": [{"type": "text", "text": text}], "structuredContent": v, "isError": false}))
}
Some(Err(e)) => ok(json!({"content": [{"type": "text", "text": e}], "isError": true})),
}
}
other => err(-32601, format!("no method {other}")),
}
}
}
impl Drop for Server {
fn drop(&mut self) {
if let Some(d) = self.dev.take() {
d.stop();
}
}
}
fn usage() -> String {
format!(
"usage: wai mcp [--toolset <set>[,<set>...]] [--read-only] [--project <dir>] [--spec-root <wai-dir>] [--room-bin <path>]\n\
toolsets: {} (core when none is named)",
tools::Toolset::ALL.iter().map(|t| t.name()).collect::<Vec<_>>().join(", ")
)
}
/// `wai mcp`: serve until standard input closes.
pub fn main(args: &[String]) -> Result<bool, String> {
let mut toolsets: Vec<tools::Toolset> = Vec::new();
let mut spec_root = None;
let mut room_bin = None;
let mut project = None;
let mut read_only = false;
let mut i = 0;
while i < args.len() {
match (args[i].as_str(), args.get(i + 1)) {
("--read-only", _) => {
read_only = true;
i += 1;
continue;
}
("--toolset", Some(v)) => {
for name in v.split(',').map(str::trim).filter(|n| !n.is_empty()) {
let t = tools::Toolset::named(name).ok_or_else(|| format!("--toolset {name}: no such toolset\n{}", usage()))?;
if !toolsets.contains(&t) {
toolsets.push(t);
}
}
}
("--spec-root", Some(v)) => spec_root = Some(PathBuf::from(v)),
("--room-bin", Some(v)) => room_bin = Some(PathBuf::from(v)),
("--project", Some(v)) => project = Some(PathBuf::from(v)),
_ => return Err(usage()),
}
i += 2;
}
if toolsets.is_empty() {
toolsets.push(tools::Toolset::Core);
}
let project = project.map_or_else(std::env::current_dir, Ok).and_then(|p| p.canonicalize()).map_err(|e| format!("the project directory: {e}"))?;
let mut server = Server::new(toolsets, spec_root, room_bin, project, read_only);
let stdin = std::io::stdin();
let mut out = std::io::stdout().lock();
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => server.handle(&msg),
Err(e) => Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": format!("not JSON: {e}")}})),
};
if let Some(r) = reply
&& writeln!(out, "{r}").and_then(|_| out.flush()).is_err()
{
break;
}
}
Ok(true)
}

View file

@ -0,0 +1,920 @@
//! The tools `wai mcp` serves, in one table ([`TOOLS`]): each tool's operation (its name in the
//! toolchain's catalogue), its toolset, title, one-line description, input schema, its safety
//! hints and the function that runs it. A tool's name is its operation's, with `_` for the dot
//! (`wai::ops::mcp_tool_name`); nothing else names it.
use super::dev_room;
use super::Server;
use serde_json::{json, Map, Value};
use std::path::{Path, PathBuf};
/// The toolsets a server can offer. `quantum` is not among them: the quantum command line's
/// own server (`wai-quantum mcp`) serves those tools.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Toolset {
/// Inspect, the registry, verify, package, conformance, the specification's text.
Core,
/// The development room on loopback, on the target `local` alone.
Dev,
}
impl Toolset {
pub(crate) const ALL: &'static [Toolset] = &[Toolset::Core, Toolset::Dev];
pub(crate) fn name(self) -> &'static str {
match self {
Toolset::Core => "core",
Toolset::Dev => "dev",
}
}
pub(crate) fn named(name: &str) -> Option<Toolset> {
Toolset::ALL.iter().copied().find(|t| t.name() == name)
}
}
/// What a tool does to the machine, for the client's safety prompts and for `--read-only`.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Effect {
/// Reads only.
Reads,
/// Runs a local program that writes nothing (a conformance verifier).
Runs,
/// Writes a file inside the project directory.
Writes,
/// Starts or drives the development room on loopback.
DevRoom,
}
pub(crate) struct Tool {
/// The operation, as the catalogue names it (`noun.verb`).
pub op: &'static str,
pub toolset: Toolset,
pub title: &'static str,
pub description: &'static str,
pub schema: fn() -> Value,
pub effect: Effect,
pub run: fn(&mut Server, &Value) -> Result<Value, String>,
}
impl Tool {
/// The tool's name: its operation's, with `_` for the dot.
pub(crate) fn name(&self) -> String {
wai::ops::mcp_tool_name(self.op)
}
}
/// Every tool, in the order `tools/list` gives them.
pub(crate) const TOOLS: &[Tool] = &[
Tool { op: "envelope.inspect", toolset: Toolset::Core, title: "Inspect a WAI envelope or package", effect: Effect::Reads, schema: s_path, run: envelope_inspect,
description: "What a .wai envelope (WAI1 or WAI2) or a package directory holds: its media and intent, each component's capability with its licence class, fidelity tier and role, its pin, and the envelope's conformance checks." },
Tool { op: "registry.list", toolset: Toolset::Core, title: "List registered capabilities", effect: Effect::Reads, schema: s_list_caps, run: registry_list,
description: "The capability registry, filtered by media, licence class, kind, status or a substring: each entry's capability string, media, role, tier, licence and replicate class, and the SPEC section that defines it." },
Tool { op: "registry.show", toolset: Toolset::Core, title: "Show one capability", effect: Effect::Reads, schema: s_cap, run: registry_show,
description: "One capability's registry entry, whether this build decodes it natively, whether it is dispatched standalone, companion-only or a candidate, and the specification lines that name it." },
Tool { op: "receipt.verify", toolset: Toolset::Core, title: "Verify a receipt, a claim, a room session or an audit bundle", effect: Effect::Reads, schema: s_verify, run: receipt_verify,
description: "Verify, by kind (given, or read from the arguments): a JWP delivery receipt (jwp-receipts §2.2 to §3, a group receipt or an object receipt against its group), a claim receipt (§2.4, §3.1, with the context given), a live-room session (live-rooms §9) or a stream audit bundle (stream-audit §9, §10). The verifier's verdict, and the step and reason code it refuses at." },
Tool { op: "package.build", toolset: Toolset::Core, title: "Package a media file", effect: Effect::Writes, schema: s_package, run: package_build,
description: "Carry a media file's bytes in a WAI envelope under a registered capability, with an optional fallback or companions, as `wai wrap` does and under the same rules; writes the envelope inside the project directory and returns what it holds." },
Tool { op: "conformance.list", toolset: Toolset::Core, title: "List conformance corpora", effect: Effect::Reads, schema: s_none, run: conformance_list,
description: "The conformance corpora in the specification tree and the independent verifier that checks each, as CI runs them." },
Tool { op: "conformance.run", toolset: Toolset::Core, title: "Run a conformance corpus", effect: Effect::Runs, schema: s_corpus, run: conformance_run,
description: "Run a corpus's independent verifier (python3 -I <verifier> <corpus>) on this machine and return whether it passed, its exit code, its time and the end of its output." },
Tool { op: "docs.search", toolset: Toolset::Core, title: "Search the specification", effect: Effect::Reads, schema: s_search, run: docs_search,
description: "Search SPEC.md, REGISTRY.md and every extension for lines holding all the query's words: each hit's file, line, section heading and text, for quoting with its section." },
Tool { op: "dev.start", toolset: Toolset::Dev, title: "Start a development room", effect: Effect::DevRoom, schema: s_dev_start, run: dev_room::start,
description: "DEVELOPMENT ONLY. Start a relay and a room service on loopback (127.0.0.1) with keys held in memory, with its room; returns its service and relay addresses and the room id. Never for deployment." },
Tool { op: "dev.stop", toolset: Toolset::Dev, title: "Stop the development room", effect: Effect::DevRoom, schema: s_none, run: dev_room::stop,
description: "DEVELOPMENT ONLY. Stop the development relay and room service this server started." },
Tool { op: "room.grant", toolset: Toolset::Dev, title: "Mint a development grant", effect: Effect::DevRoom, schema: s_room_grant, run: dev_room::grant,
description: "DEVELOPMENT ONLY. On the target local: ask a room service on this machine for a grant for a named test principal, whose key is derived from its name; refuses any service that is not on a loopback address." },
Tool { op: "room.join", toolset: Toolset::Dev, title: "Join and publish as a test principal", effect: Effect::DevRoom, schema: s_room_join, run: dev_room::join,
description: "DEVELOPMENT ONLY. On the target local: enter the development room as a test principal with its grant and a proof of its key, publish its audio and video tracks with a few objects, and wait until the room's clock has signed its join and publishes." },
Tool { op: "room.leave", toolset: Toolset::Dev, title: "Leave as a test principal", effect: Effect::DevRoom, schema: s_room_principal, run: dev_room::leave,
description: "DEVELOPMENT ONLY. On the target local: close a test principal's session in the development room and wait for the clock's leave." },
Tool { op: "room.session", toolset: Toolset::Dev, title: "Read the development room's session", effect: Effect::DevRoom, schema: s_target, run: dev_room::session,
description: "DEVELOPMENT ONLY. On the target local: the development room as a verifier is given it (descriptor, grants, key-log head and the clock's event chain), ready for receipt_verify." },
];
/// The tools a server offers: those of its toolsets, or with `--read-only` those of them that
/// only read or run a verifier.
pub(crate) fn offered(server: &Server) -> impl Iterator<Item = &'static Tool> + '_ {
let read_only = server.read_only;
TOOLS.iter().filter(move |t| server.toolsets.contains(&t.toolset) && (!read_only || matches!(t.effect, Effect::Reads | Effect::Runs)))
}
/// `tools/list`'s entries.
pub(crate) fn list(server: &Server) -> Vec<Value> {
offered(server)
.map(|t| {
json!({
"name": t.name(),
"title": t.title,
"description": t.description,
"inputSchema": (t.schema)(),
"annotations": {
"title": t.title,
"readOnlyHint": matches!(t.effect, Effect::Reads | Effect::Runs),
"destructiveHint": false,
"idempotentHint": matches!(t.effect, Effect::Reads | Effect::Runs),
"openWorldHint": false,
},
"_meta": {"wai/operation": t.op, "wai/toolset": t.toolset.name()},
})
})
.collect()
}
/// Run the tool `name`; `None` when no tool offered has that name.
pub(crate) fn call(server: &mut Server, name: &str, args: &Value) -> Option<Result<Value, String>> {
let t = offered(server).find(|t| t.name() == name)?;
Some((t.run)(server, args))
}
/// The name a table entry runs under, for messages that point at another tool.
pub(crate) fn name_of(run: fn(&mut Server, &Value) -> Result<Value, String>) -> String {
TOOLS.iter().find(|t| std::ptr::fn_addr_eq(t.run, run)).map_or_else(|| "?".into(), Tool::name)
}
// ------------------------------------------------------------------------------- schemas
fn obj(props: Value, required: &[&str]) -> Value {
json!({"type": "object", "properties": props, "required": required, "additionalProperties": false})
}
const HEX64: &str = "64 lowercase hexadecimal digits";
fn s_none() -> Value {
obj(json!({}), &[])
}
fn s_path() -> Value {
obj(json!({"path": {"type": "string", "description": "A .wai file, or a directory whose .wai files make a package."}}), &["path"])
}
fn s_list_caps() -> Value {
obj(
json!({
"media": {"type": "string", "description": "image, audio, video, text, 3d, ... (the registry's media class)."},
"licence": {"type": "string", "description": "A licence class, e.g. royalty-free."},
"kind": {"type": "string", "description": "payload, record, companion, ..."},
"status": {"type": "string", "description": "registered or candidate."},
"contains": {"type": "string", "description": "A substring of the capability string."}
}),
&[],
)
}
fn s_cap() -> Value {
obj(json!({"capability": {"type": "string", "description": "A capability string, e.g. wai.image.avif."}}), &["capability"])
}
/// The kinds `receipt_verify` takes, and the argument that names each when no kind is given.
const VERIFY_KINDS: &[(&str, &str)] = &[("delivery", "receipt"), ("claim", "claim"), ("room_session", "descriptor"), ("audit_bundle", "bundle")];
fn s_verify() -> Value {
obj(
json!({
"kind": {"type": "string", "enum": VERIFY_KINDS.iter().map(|(k, _)| *k).collect::<Vec<_>>(), "description": "What is verified: a JWP delivery receipt, a claim receipt, a live-room session or a stream audit bundle. When left out, it is read from the arguments given (receipt, claim, descriptor or bundle)."},
"receipt": {"description": "delivery: the receipt's JSON (object or text), a group receipt (kind group) or an object receipt (kind object)."},
"group": {"description": "delivery, for an object receipt: its group receipt's JSON."},
"previous": {"description": "delivery, for a group receipt: the previous group receipt's JSON, to check the parent link. claim: the signer's previous claim's wire text, for the chain (§3.1 step 5); a claim that names a parent is refused without it."},
"envelope_hex": {"type": "string", "description": "delivery, for an object receipt: the object's bytes in hexadecimal, to check its content hash."},
"envelope_path": {"type": "string", "description": "delivery, for an object receipt: a file holding the object's bytes."},
"claim": {"description": "claim: the claim receipt's wire text (a string), or its JSON object."},
"object": {"type": "object", "description": "claim, for an energy claim: the delivery receipt's object, {content_hash, joules_micro} and its leaf's class where labelled."},
"oer": {"type": "object", "description": "claim, for an energy claim naming an OER receipt: {receipt, keys}."},
"gap_object": {"type": "object", "description": "claim, for a delivery-gap claim: the object receipt at its coordinates, {track_alias, group_id, object_id, content_hash}."},
"descriptor": {"type": "string", "description": "room_session: the signed WRD1 descriptor, hexadecimal."},
"grants": {"type": "array", "items": {"type": "string"}, "description": "room_session: the WRG1 grants, hexadecimal, in order."},
"key_log_head": {"type": ["string", "null"], "description": "room_session: the authority's key-log head (its JSON text), or null."},
"chains": {"type": "array", "items": {"type": "array", "items": {"type": "string"}}, "description": "room_session: each principal's WEV1 records in stream order, hexadecimal."},
"bundle": {"description": "audit_bundle: the bundle's JSON (object or text)."},
"owner": {"type": "string", "description": format!("audit_bundle: the owner's key, {HEX64}.")},
"channels": {"type": "array", "items": {"type": "string"}, "description": "audit_bundle: the channels the owner serves."}
}),
&[],
)
}
fn s_package() -> Value {
obj(
json!({
"input": {"type": "string", "description": "The media file whose bytes the envelope carries."},
"capability": {"type": "string", "description": "The registered capability the bytes are, e.g. wai.image.png."},
"output": {"type": "string", "description": "Where to write the envelope, inside the project directory; default <input>.wai."},
"intent": {"type": "string", "description": "replicate (the default where a sink accepts it unpinned), improve or create."},
"fallback": {"type": "string", "description": "A capability a sink without the first decodes the same payload through."},
"companions": {"type": "array", "items": {"type": "object", "properties": {"capability": {"type": "string"}, "input": {"type": "string"}}, "required": ["capability", "input"]}, "description": "Companion payloads bound to the base (a WAI2 envelope)."},
"overwrite": {"type": "boolean", "description": "Replace an existing output file (default false)."}
}),
&["input", "capability"],
)
}
fn s_corpus() -> Value {
obj(
json!({
"corpus": {"type": "string", "description": "The corpus directory, relative to the wai/ directory, as conformance_list names it."},
"verifier": {"type": "string", "description": "The verifier script, when the corpus has several or none listed."},
"timeout_s": {"type": "integer", "minimum": 1, "maximum": 1800, "description": "Default 600."}
}),
&["corpus"],
)
}
fn s_search() -> Value {
obj(
json!({
"query": {"type": "string", "description": "Words that must all appear on a line (case-insensitive)."},
"limit": {"type": "integer", "minimum": 1, "maximum": 200, "description": "Default 20."}
}),
&["query"],
)
}
/// The target a development tool acts on: `local`, this machine's development room, the one
/// target these tools take.
fn target_prop() -> Value {
json!({"type": "string", "enum": ["local"], "description": "Where the operation runs: local (the development room on this machine), the default and the only target these tools take."})
}
fn s_target() -> Value {
obj(json!({"target": target_prop()}), &[])
}
fn s_dev_start() -> Value {
obj(
json!({
"room_bin": {"type": "string", "description": "The room service program (joule-moq-room); default --room-bin, WAI_ROOM_BIN, beside wai, then PATH."},
"publishers": {"type": "integer", "minimum": 1, "maximum": 64, "description": "Publishers at once (default 4)."}
}),
&[],
)
}
fn s_room_grant() -> Value {
obj(
json!({
"target": target_prop(),
"principal": {"type": "string", "description": "A test principal's name; its Ed25519 key is derived from it."},
"role": {"type": "string", "enum": ["publisher", "viewer"]},
"service": {"type": "string", "description": "The room service's URL; default the development room this server started. Only a loopback address is accepted."}
}),
&["principal", "role"],
)
}
fn s_room_join() -> Value {
obj(
json!({
"target": target_prop(),
"principal": {"type": "string", "description": "A test principal granted the publisher role by room_grant."},
"objects": {"type": "integer", "minimum": 1, "maximum": 64, "description": "Objects on each track (default 1)."}
}),
&["principal"],
)
}
fn s_room_principal() -> Value {
obj(json!({"target": target_prop(), "principal": {"type": "string", "description": "The test principal's name."}}), &["principal"])
}
// ------------------------------------------------------------------------------- helpers
pub(crate) fn str_arg<'a>(args: &'a Value, k: &str) -> Result<&'a str, String> {
args.get(k).and_then(Value::as_str).ok_or_else(|| format!("{k}: a string is required"))
}
pub(crate) fn opt_str<'a>(args: &'a Value, k: &str) -> Option<&'a str> {
args.get(k).and_then(Value::as_str)
}
pub(crate) fn unhex(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect()
}
pub(crate) fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
/// A JSON value given as an object or as its text.
fn json_arg(args: &Value, k: &str) -> Result<Value, String> {
match args.get(k) {
Some(Value::String(s)) => serde_json::from_str(s).map_err(|e| format!("{k}: not JSON: {e}")),
Some(v @ Value::Object(_)) => Ok(v.clone()),
_ => Err(format!("{k}: a JSON object, or its text, is required")),
}
}
fn label<T: Copy>(v: Option<T>, f: fn(T) -> &'static str) -> Value {
v.map_or(Value::Null, |x| json!(f(x)))
}
/// One capability as the registry and this build know it.
fn cap_facts(c: &str) -> Value {
use wai::codecs as cx;
json!({
"capability": c,
"registered": cx::is_known_capability(c),
"media": label(cx::capability_media(c), cx::Media::label),
"role": cx::capability_role(c),
"tier": cx::capability_tier(c).map(|t| format!("{t:?}")),
"licence": label(cx::capability_license(c), cx::LicenseClass::label),
"replicate": label(cx::capability_replicate_class(c), cx::ReplicateClass::label),
})
}
// ---------------------------------------------------------------------------- inspect
fn envelope_inspect(_: &mut Server, args: &Value) -> Result<Value, String> {
let path = PathBuf::from(str_arg(args, "path")?);
let meta = std::fs::metadata(&path).map_err(|e| format!("{}: {e}", path.display()))?;
if meta.is_dir() {
return inspect_package(&path);
}
let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut v = inspect_envelope(&bytes)?;
v["path"] = json!(path.display().to_string());
Ok(v)
}
fn check<E: std::fmt::Display>(r: Result<(), E>) -> Value {
match r {
Ok(()) => json!("ok"),
Err(e) => json!(e.to_string()),
}
}
/// An envelope's structure, its components' capabilities and its static checks.
pub(crate) fn inspect_envelope(bytes: &[u8]) -> Result<Value, String> {
use wai::container::{detect_version, Wai, WaiMulti};
let native = native_capabilities();
let version = detect_version(bytes).map_err(|e| format!("not a WAI envelope: {e}"))?;
if version == 1 {
let w = Wai::from_bytes(bytes).map_err(|e| e.to_string())?;
let cap = w.capability();
let mut c = cap_facts(cap);
c["index"] = json!(0);
c["bytes"] = json!(w.payload.len());
c["decoded_natively_here"] = json!(native.contains(&cap));
let fallback = w.fallback().map(|f| {
let mut x = cap_facts(f);
x["decoded_natively_here"] = json!(native.contains(&f));
x
});
Ok(json!({
"kind": "envelope",
"format": "WAI1",
"bytes": bytes.len(),
"media": w.media(),
"intent": w.intent(),
"conditioning": w.kind(),
"pin": w.prior().map(wai::pin::describe_pin),
"components": [c],
"fallback": fallback,
"checks": {
"pin": check(w.check_pin()),
"component_roles": check(w.check_component_roles()),
"candidates": check(w.check_candidates()),
"integer_payload": check(w.check_int_payload_framing()),
},
}))
} else {
let m = WaiMulti::from_bytes(bytes).map_err(|e| e.to_string())?;
let comps: Vec<Value> = m
.manifest
.renditions
.iter()
.zip(m.renditions.iter())
.enumerate()
.map(|(i, (meta, payload))| {
let mut c = cap_facts(&meta.capability);
c["index"] = json!(i);
c["bytes"] = json!(payload.len());
c["kind"] = json!(meta.kind);
c["companion_of"] = json!(meta.companion_of);
c["id"] = json!(meta.id);
c["pin"] = json!(meta.prior.as_ref().map(wai::pin::describe_pin));
c["sha256_matches"] = json!(meta.sha256.as_ref().map(|_| meta.payload_digest_matches(payload)));
c["decoded_natively_here"] = json!(native.contains(&meta.capability.as_str()));
c
})
.collect();
Ok(json!({
"kind": "envelope",
"format": "WAI2",
"bytes": bytes.len(),
"media": m.media(),
"intent": m.intent(),
"components": comps,
"checks": {
"pins": match m.check_pins() { Ok(()) => json!("ok"), Err((i, f)) => json!(format!("[{i}] {f}")) },
"component_roles": check(m.check_component_roles()),
"candidates": check(m.check_candidates()),
"integer_payload": match m.check_int_payload_framing() { Ok(()) => json!("ok"), Err((i, e)) => json!(format!("[{i}] {e}")) },
},
}))
}
}
/// The capabilities this binary decodes natively, as `wai inspect` counts them.
fn native_capabilities() -> &'static [&'static str] {
crate::native_capabilities()
}
/// A package directory: every `.wai` file below it, read as an envelope, and the SHA-256 of
/// its listing (SPEC §3.1's form, the pin of a parameter set).
fn inspect_package(dir: &Path) -> Result<Value, String> {
let mut files = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let mut entries: Vec<_> = std::fs::read_dir(&d).map_err(|e| format!("{}: {e}", d.display()))?.filter_map(Result::ok).collect();
entries.sort_by_key(|e| e.path());
for e in entries {
let p = e.path();
let ft = e.file_type().map_err(|e| e.to_string())?;
if ft.is_dir() {
stack.push(p);
} else if ft.is_file() && p.extension().is_some_and(|x| x == "wai") {
files.push(p);
}
if files.len() > 10_000 {
return Err("more than 10000 .wai files: inspect a smaller directory".into());
}
}
}
files.sort();
let mut caps: Map<String, Value> = Map::new();
let envelopes: Vec<Value> = files
.iter()
.map(|p| {
let rel = p.strip_prefix(dir).unwrap_or(p).display().to_string();
match std::fs::read(p).map_err(|e| e.to_string()).and_then(|b| inspect_envelope(&b)) {
Ok(mut v) => {
for c in v["components"].as_array().into_iter().flatten() {
if let Some(name) = c["capability"].as_str() {
caps.insert(name.to_string(), json!({"licence": c["licence"], "media": c["media"]}));
}
}
v["path"] = json!(rel);
v
}
Err(e) => json!({"path": rel, "error": e}),
}
})
.collect();
let listing = wai::pin::listing_of_dir(dir).map_err(|e| e.to_string())?;
Ok(json!({
"kind": "package",
"path": dir.display().to_string(),
"listing_sha256": wai::pin::sha256_hex(&listing),
"envelopes": envelopes,
"capabilities": caps,
}))
}
// ------------------------------------------------------------------------- the registry
fn registry() -> Result<Vec<Value>, String> {
let v: Value = serde_json::from_str(&wai::codecs::registry::capabilities_json()).map_err(|e| e.to_string())?;
Ok(v["capabilities"].as_array().cloned().unwrap_or_default())
}
fn registry_list(_: &mut Server, args: &Value) -> Result<Value, String> {
let want = |k: &str, e: &Value| opt_str(args, k).is_none_or(|w| e[k].as_str().is_some_and(|v| v.eq_ignore_ascii_case(w)));
let contains = opt_str(args, "contains").map(str::to_ascii_lowercase);
let caps: Vec<Value> = registry()?
.into_iter()
.filter(|e| want("media", e) && want("licence", e) && want("kind", e) && want("status", e))
.filter(|e| contains.as_deref().is_none_or(|c| e["capability"].as_str().is_some_and(|s| s.to_ascii_lowercase().contains(c))))
.collect();
Ok(json!({"count": caps.len(), "capabilities": caps}))
}
fn registry_show(server: &mut Server, args: &Value) -> Result<Value, String> {
use wai::codecs as cx;
let c = str_arg(args, "capability")?;
let entry = registry()?.into_iter().find(|e| e["capability"].as_str() == Some(c));
let Some(entry) = entry else {
return Err(format!("{c} is not in the registry; {} lists them", name_of(registry_list)));
};
let lines = server.spec_root().ok().map(|root| spec_hits(&root, &[c.to_ascii_lowercase()], 10)).transpose()?.map(|(h, _)| h);
Ok(json!({
"registry": entry,
"this_build": cap_facts(c),
"decoded_natively_here": native_capabilities().contains(&c),
"dispatched_standalone": cx::is_dispatchable(c),
"companion_only": cx::is_companion_only(c),
"candidate": cx::is_candidate(c),
"spec_lines": lines,
}))
}
// ------------------------------------------------------------------------------ verifiers
/// `receipt_verify`: the kind given, or the one the arguments name; the verifier's answer with
/// the kind beside it.
fn receipt_verify(_: &mut Server, args: &Value) -> Result<Value, String> {
let kind = match opt_str(args, "kind") {
Some(k) => VERIFY_KINDS.iter().find(|(name, _)| *name == k).map(|(name, _)| *name).ok_or_else(|| {
format!("kind: one of {}", VERIFY_KINDS.iter().map(|(k, _)| *k).collect::<Vec<_>>().join(", "))
})?,
None => {
let named: Vec<&'static str> = VERIFY_KINDS.iter().filter(|(_, arg)| args.get(*arg).is_some()).map(|(k, _)| *k).collect();
match named.as_slice() {
[one] => *one,
[] => return Err("give what to verify: receipt (a delivery receipt), claim, descriptor (a live-room session) or bundle (an audit bundle)".into()),
many => return Err(format!("the arguments name several kinds ({}): give kind", many.join(", "))),
}
}
};
let mut v = match kind {
"delivery" => verify_delivery(args),
"claim" => verify_claim(args),
"room_session" => verify_room_session(args),
_ => verify_audit_bundle(args),
}?;
if let Some(o) = v.as_object_mut() {
o.insert("kind".into(), json!(kind));
}
Ok(v)
}
fn verify_delivery(args: &Value) -> Result<Value, String> {
use wai::jwp_delivery::{GroupReceipt, ObjectReceipt};
let r = json_arg(args, "receipt")?;
let refused = |receipt: &str, e: wai::jwp_delivery::DeliveryError| json!({"receipt": receipt, "verdict": "refuse", "reason": e.reason.label(), "step": e.step});
match r["kind"].as_str() {
Some("group") => {
let g = match GroupReceipt::from_json(&r) {
Ok(g) => g,
Err(e) => return Ok(refused("group", e)),
};
if let Err(e) = g.verify() {
return Ok(refused("group", e));
}
// Step 3, as the reference runs it: a group that names a parent is refused unless
// the previous group is given and is that parent; one given that does not read, or
// is another signer's or track's, is not held.
let previous = args.get("previous").and_then(|_| json_arg(args, "previous").ok()).and_then(|p| GroupReceipt::from_json(&p).ok());
if let Err(e) = g.check_parent(previous.as_ref()) {
return Ok(refused("group", e));
}
Ok(json!({"receipt": "group", "verdict": "accept", "link_hash": hex(&g.link_hash()), "parent_checked": previous.is_some(), "signer_id": r["signer_id"]}))
}
Some("object") => {
let gv = json_arg(args, "group").map_err(|_| "an object receipt is verified against its group receipt: give group".to_string())?;
let g = match GroupReceipt::from_json(&gv) {
Ok(g) => g,
Err(e) => return Ok(refused("group", e)),
};
let o = match ObjectReceipt::from_json(&r) {
Ok(o) => o,
Err(e) => return Ok(refused("object", e)),
};
let envelope = match (opt_str(args, "envelope_hex"), opt_str(args, "envelope_path")) {
(Some(h), _) => Some(unhex(h).ok_or("envelope_hex: hexadecimal")?),
(None, Some(p)) => Some(std::fs::read(p).map_err(|e| format!("{p}: {e}"))?),
(None, None) => None,
};
match o.verify_against(&g, envelope.as_deref()) {
Ok(ev) => Ok(json!({"receipt": "object", "verdict": "accept", "evidence": ev.label(), "content_checked": envelope.is_some(), "group_link_hash": hex(&g.link_hash())})),
Err(e) => Ok(refused("object", e)),
}
}
_ => Err("receipt: a JWP delivery receipt with kind group or object".into()),
}
}
fn verify_claim(args: &Value) -> Result<Value, String> {
use wai::claim_receipt::{verify_claim_json, ClaimReceipt, Rejection};
let wire = match args.get("claim") {
Some(Value::String(s)) => s.clone(),
Some(v @ Value::Object(_)) => serde_json::to_string(v).map_err(|e| e.to_string())?,
_ => return Err("claim: the receipt's wire text, or its JSON object".into()),
};
let refused = |r: Rejection| json!({"verdict": "refuse", "step": r.step, "reason": r.reason.label()});
// Steps 1 to 4: the claim alone.
let c = match verify_claim_json(&wire) {
Ok(c) => c,
Err(r) => return Ok(refused(r)),
};
// Step 5: the chain. A previous claim that does not read, or does not verify, is not held.
let previous = opt_str(args, "previous").and_then(|w| ClaimReceipt::from_json(w).ok());
if let Err(r) = c.check_parent(previous.as_ref()) {
return Ok(refused(r));
}
// Step 6, with the context given.
let mut checked = vec!["claim", "chain"];
if let Some(o) = args.get("object") {
let om = o.as_object().ok_or("object: {content_hash, joules_micro, and the leaf's class where it is labelled}")?;
let h: [u8; 32] = o.get("content_hash").and_then(Value::as_str).and_then(unhex).and_then(|b| b.try_into().ok()).ok_or(format!("object.content_hash: {HEX64}"))?;
let j = o.get("joules_micro").and_then(Value::as_u64).ok_or("object.joules_micro: an integer")?;
let label = wai::energy_class::label_from_json(om).map_err(|e| format!("object: its class does not read: {e}"))?;
if let Err(r) = c.check_energy_against_labelled_object(&h, j, label.as_ref()) {
return Ok(refused(r));
}
checked.push("object");
}
if let Some(o) = args.get("oer") {
let receipt = o.get("receipt").ok_or("oer: {receipt, keys}")?;
let keys = o.get("keys").and_then(Value::as_object).ok_or("oer.keys: an object")?;
if let Err(r) = c.check_energy_against_oer(receipt, keys) {
return Ok(refused(r));
}
checked.push("oer");
}
// A report is its figure signer's only once step 6 has run against its figure, which this
// tool does not take: such a claim is not accepted here.
if c.needs_figure() {
return Ok(json!({"verdict": "refuse", "step": 6, "reason": "figure_not_held", "note": "this claim's figure is in another receipt; a verifier holding it checks the claim against it"}));
}
if let Some(o) = args.get("gap_object") {
let n = |k: &str| o.get(k).and_then(Value::as_u64).ok_or(format!("gap_object.{k}: an integer"));
let h: [u8; 32] = o.get("content_hash").and_then(Value::as_str).and_then(unhex).and_then(|b| b.try_into().ok()).ok_or(format!("gap_object.content_hash: {HEX64}"))?;
if let Err(r) = c.check_gap_against(n("track_alias")?, n("group_id")?, n("object_id")?, &h) {
return Ok(refused(r));
}
checked.push("gap_object");
}
Ok(json!({"verdict": "accept", "class": c.class().label(), "link_hash": hex(&c.link_hash()), "subject": hex(&c.subject), "subject_type": c.subject_type.label(), "checked": checked}))
}
fn hex_list(v: &Value, k: &str) -> Result<Vec<Vec<u8>>, String> {
v.get(k).and_then(Value::as_array).ok_or(format!("{k}: a list of hexadecimal strings"))?.iter().map(|x| x.as_str().and_then(unhex).ok_or(format!("{k}: hexadecimal"))).collect()
}
fn verify_room_session(args: &Value) -> Result<Value, String> {
use wai::live_room::{verify_room, At, RoomOutcome};
let descriptor = unhex(str_arg(args, "descriptor")?).ok_or("descriptor: hexadecimal")?;
let grants = hex_list(args, "grants")?;
let chains: Vec<Vec<Vec<u8>>> = args
.get("chains")
.and_then(Value::as_array)
.ok_or("chains: a list of lists of records")?
.iter()
.map(|c| c.as_array().ok_or("chains: lists").and_then(|c| c.iter().map(|r| r.as_str().and_then(unhex).ok_or("chains: hexadecimal records")).collect::<Result<Vec<_>, _>>()))
.collect::<Result<_, _>>()?;
let head = opt_str(args, "key_log_head");
let run = verify_room(&descriptor, &grants, head, &chains);
let at = |a: &At| match a {
At::Descriptor => json!(["descriptor"]),
At::KeyLog => json!(["key_log"]),
At::Grant(i) => json!(["grant", i]),
At::Record { chain, record } => json!(["record", chain, record]),
};
let (outcome, reason, place, gaps) = match &run.outcome {
RoomOutcome::Accept => ("accept", Value::Null, Value::Null, Value::Null),
RoomOutcome::Gap(g) => ("gap", json!("delivery_gap"), Value::Null, json!(g.iter().map(|x| json!({"principal": hex(&x.principal), "first": x.first, "last": x.last})).collect::<Vec<_>>())),
RoomOutcome::Refuse { at: a, reason } => ("refuse", json!(reason.code()), at(a), Value::Null),
};
Ok(json!({
"outcome": outcome,
"reason": reason,
"at": place,
"gaps": gaps,
"room": run.room.map(|r| hex(&r)),
"present": run.state.present.keys().map(|p| hex(p)).collect::<Vec<_>>(),
"records_in_order": run.order.len(),
"grants": run.grants.len(),
}))
}
fn verify_audit_bundle(args: &Value) -> Result<Value, String> {
// The bundle as its text, or as JSON: anything else given is read as the bundle's JSON, and
// one that is not a bundle is refused by the verifier (audit_bundle_invalid).
let bundle = match args.get("bundle") {
Some(Value::String(s)) => s.clone().into_bytes(),
Some(v) => serde_json::to_vec(v).map_err(|e| e.to_string())?,
None => return Err("bundle: the bundle's JSON, or its text".into()),
};
let owner: [u8; 32] = unhex(str_arg(args, "owner")?).and_then(|b| b.try_into().ok()).ok_or(format!("owner: {HEX64}"))?;
let channels: Vec<String> = args.get("channels").and_then(Value::as_array).ok_or("channels: a list of strings")?.iter().filter_map(|c| c.as_str().map(String::from)).collect();
let out = wai::stream_audit::verify_bundle(&bundle, &owner, &channels);
let mut v = out.to_json();
if let Some(o) = v.as_object_mut() {
o.entry("outcome").or_insert(json!("accept"));
} else {
v = json!({"outcome": v});
}
Ok(v)
}
// ------------------------------------------------------------------------------ packaging
fn package_build(server: &mut Server, args: &Value) -> Result<Value, String> {
let input = str_arg(args, "input")?;
let cap = str_arg(args, "capability")?;
let output = opt_str(args, "output").map(String::from).unwrap_or_else(|| format!("{input}.wai"));
let out = server.inside_project(Path::new(&output))?;
if out.exists() && !args.get("overwrite").and_then(Value::as_bool).unwrap_or(false) {
return Err(format!("{} exists; pass overwrite: true to replace it", out.display()));
}
let companions: Vec<(String, String)> = match args.get("companions").and_then(Value::as_array) {
None => Vec::new(),
Some(a) => a
.iter()
.map(|c| Ok((c["capability"].as_str().ok_or("companions: capability")?.to_string(), c["input"].as_str().ok_or("companions: input")?.to_string())))
.collect::<Result<_, String>>()?,
};
let out_s = out.to_str().ok_or("output: a UTF-8 path")?;
let w = crate::wrap(cap, input, out_s, opt_str(args, "fallback"), opt_str(args, "intent"), &companions)?;
Ok(w.facts)
}
// ---------------------------------------------------------------------------- conformance
/// Every (verifier, corpus) pair: the `python3 [-I] <script.py> <dir>` lines of the conformance
/// workflow, when the tree holds it, and each `*-conformance/*_verify.py` with its directory.
fn corpora(root: &Path) -> Vec<(String, String)> {
let mut pairs = std::collections::BTreeSet::new();
if let Ok(wf) = std::fs::read_to_string(root.join("../.github/workflows/wai-conformance.yml")) {
for line in wf.lines() {
let t = line.trim().trim_start_matches("run:").trim();
let Some(rest) = t.strip_prefix("python3 ") else { continue };
let rest = rest.trim_start_matches("-I ").trim();
let mut it = rest.split_whitespace();
let (Some(script), Some(dir)) = (it.next(), it.next()) else { continue };
let script = script.trim_start_matches("wai/");
let dir = dir.trim_start_matches("wai/");
if script.ends_with("_verify.py") && root.join(script).is_file() && root.join(dir).is_dir() {
pairs.insert((dir.to_string(), script.to_string()));
}
}
}
if let Ok(rd) = std::fs::read_dir(root) {
for e in rd.filter_map(Result::ok) {
let name = e.file_name().to_string_lossy().to_string();
if !name.ends_with("-conformance") || !e.path().is_dir() {
continue;
}
if let Ok(inner) = std::fs::read_dir(e.path()) {
for f in inner.filter_map(Result::ok) {
let fname = f.file_name().to_string_lossy().to_string();
if fname.ends_with("_verify.py") {
pairs.insert((name.clone(), format!("{name}/{fname}")));
}
}
}
}
}
pairs.into_iter().collect()
}
fn conformance_list(server: &mut Server, _: &Value) -> Result<Value, String> {
let root = server.spec_root()?;
let list: Vec<Value> = corpora(&root).into_iter().map(|(c, v)| json!({"corpus": c, "verifier": v})).collect();
Ok(json!({"root": root.display().to_string(), "count": list.len(), "corpora": list}))
}
fn conformance_run(server: &mut Server, args: &Value) -> Result<Value, String> {
let root = server.spec_root()?;
let corpus = str_arg(args, "corpus")?.trim_end_matches('/').to_string();
let known = corpora(&root);
let verifier = match opt_str(args, "verifier") {
Some(v) => v.to_string(),
None => {
let m: Vec<&(String, String)> = known.iter().filter(|(c, _)| *c == corpus).collect();
match m.as_slice() {
[(_, v)] => v.clone(),
[] => return Err(format!("no verifier is listed for {corpus}; {} lists the corpora, or give verifier", name_of(conformance_list))),
many => return Err(format!("{corpus} has several verifiers: {}; give verifier", many.iter().map(|(_, v)| v.as_str()).collect::<Vec<_>>().join(", "))),
}
}
};
// Only a verifier script and a corpus directory inside the tree.
let inside = |p: &str| -> Result<PathBuf, String> {
let c = root.join(p).canonicalize().map_err(|e| format!("{p}: {e}"))?;
if !c.starts_with(&root) {
return Err(format!("{p} is outside {}", root.display()));
}
Ok(c)
};
let script = inside(&verifier)?;
let dir = inside(&corpus)?;
if !script.file_name().is_some_and(|n| n.to_string_lossy().ends_with("_verify.py")) || !dir.is_dir() {
return Err("verifier: a *_verify.py script; corpus: a directory".into());
}
let timeout = args.get("timeout_s").and_then(Value::as_u64).unwrap_or(600).clamp(1, 1800);
let started = std::time::Instant::now();
let mut child = std::process::Command::new("python3")
.arg("-I")
.arg(&script)
.arg(&dir)
.current_dir(&root)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("python3: {e}"))?;
let (mut so, mut se) = (child.stdout.take().expect("piped"), child.stderr.take().expect("piped"));
let out_t = std::thread::spawn(move || {
let mut b = Vec::new();
let _ = std::io::Read::read_to_end(&mut so, &mut b);
b
});
let err_t = std::thread::spawn(move || {
let mut b = Vec::new();
let _ = std::io::Read::read_to_end(&mut se, &mut b);
b
});
let status = loop {
if let Some(s) = child.try_wait().map_err(|e| e.to_string())? {
break Some(s);
}
if started.elapsed().as_secs() >= timeout {
let _ = child.kill();
let _ = child.wait();
break None;
}
std::thread::sleep(std::time::Duration::from_millis(50));
};
let mut text = String::from_utf8_lossy(&out_t.join().unwrap_or_default()).to_string();
text.push_str(&String::from_utf8_lossy(&err_t.join().unwrap_or_default()));
let lines: Vec<&str> = text.lines().collect();
let tail = lines[lines.len().saturating_sub(60)..].join("\n");
Ok(json!({
"corpus": corpus,
"verifier": verifier,
"passed": status.is_some_and(|s| s.success()),
"exit_code": status.and_then(|s| s.code()),
"timed_out": status.is_none(),
"seconds": started.elapsed().as_secs_f64(),
"output_tail": tail,
}))
}
// ------------------------------------------------------------------------- the spec text
/// The specification's Markdown files: SPEC.md, REGISTRY.md, then every file below
/// extensions/ and glossary/, sorted.
fn spec_files(root: &Path) -> Vec<PathBuf> {
let mut out = vec![root.join("SPEC.md"), root.join("REGISTRY.md")];
for sub in ["extensions", "glossary"] {
let mut found = Vec::new();
let mut stack = vec![root.join(sub)];
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else { continue };
for e in rd.filter_map(Result::ok) {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().is_some_and(|x| x == "md") {
found.push(p);
}
}
}
found.sort();
out.extend(found);
}
out.into_iter().filter(|p| p.is_file()).collect()
}
/// The lines holding every word, with their section headings; and how many files were read.
fn spec_hits(root: &Path, words: &[String], limit: usize) -> Result<(Vec<Value>, usize), String> {
let files = spec_files(root);
let mut hits = Vec::new();
for f in &files {
let text = std::fs::read_to_string(f).map_err(|e| format!("{}: {e}", f.display()))?;
let mut section = String::new();
for (i, line) in text.lines().enumerate() {
if line.starts_with('#') {
section = line.trim_start_matches('#').trim().to_string();
}
let low = line.to_ascii_lowercase();
if words.iter().all(|w| low.contains(w.as_str())) {
let rel = f.strip_prefix(root).unwrap_or(f).display().to_string();
let t: String = line.trim().chars().take(400).collect();
hits.push(json!({"file": rel, "line": i + 1, "section": section, "text": t}));
if hits.len() >= limit {
return Ok((hits, files.len()));
}
}
}
}
Ok((hits, files.len()))
}
fn docs_search(server: &mut Server, args: &Value) -> Result<Value, String> {
let root = server.spec_root()?;
let q = str_arg(args, "query")?;
let words: Vec<String> = q.split_whitespace().map(str::to_ascii_lowercase).collect();
if words.is_empty() {
return Err("query: at least one word".into());
}
let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(20).clamp(1, 200) as usize;
let (hits, files) = spec_hits(&root, &words, limit)?;
Ok(json!({"query": q, "root": root.display().to_string(), "files_searched": files, "count": hits.len(), "truncated": hits.len() >= limit, "hits": hits}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_tool_is_an_operation_named_by_the_rule_once() {
let mut seen = std::collections::BTreeSet::new();
for t in TOOLS {
assert_eq!(wai::ops::check_id(t.op), Ok(()), "{}", t.op);
assert!(seen.insert(t.op), "{} twice", t.op);
assert_eq!(t.name(), t.op.replace('.', "_"));
assert!(!t.description.contains('\n'), "{}: one line", t.op);
assert_eq!(t.description.starts_with("DEVELOPMENT ONLY."), t.toolset == Toolset::Dev, "{}: a development tool says so first, and only one", t.op);
}
for t in Toolset::ALL {
assert_eq!(Toolset::named(t.name()), Some(*t));
assert!(TOOLS.iter().any(|x| x.toolset == *t), "{}: a toolset with no tool", t.name());
}
}
}

View file

@ -0,0 +1,568 @@
//! `wai setup-agent` (catalogue `agent.setup`): make a project ready for coding agents to use
//! WAI. In the project directory it writes the instructions agents read (`AGENTS.md`), adds an
//! import of them to a `CLAUDE.md` that is already there (one that is not is left uncreated),
//! and registers `wai mcp` in the project-level MCP configuration files whose formats are
//! documented (`TARGETS`).
//!
//! ```text
//! wai setup-agent [--dir <project>] [--dry-run] [--confirm] [--json] [--all] [--only <file>[,<file>...]]
//! [--toolset <set>[,<set>...]] [--command <program>]
//! ```
//!
//! - It writes nothing outside the project directory: every path is checked against the
//! directory's canonical path, and a link among the files it would write is refused.
//! - A file that does not exist is created. A file that exists is changed only after its diff is
//! shown and the change is accepted: asked for on a terminal, or given with `--confirm`.
//! Without a terminal and without `--confirm`, nothing that exists is changed and the command
//! exits 4, with every proposed change and the command that confirms them.
//! - `--dry-run` shows what would change and writes nothing.
//! - In Markdown it owns one block between two markers, which it adds or replaces; the rest of
//! the file is kept. In JSON it sets one member, `wai`, under the file's server list; the
//! rest is kept, in its order. A JSON file it cannot read as JSON (one with comments, say) is
//! left alone, and the entry to add by hand is printed.
//! - `--toolset` names the toolsets the registered server offers (`wai mcp --toolset ...`);
//! without it the server offers its default, `core`.
//! - Output is text on a terminal, and otherwise (or with `--json`) the toolchain's JSON
//! envelope (`{"wai": "envelope/1", "op": "agent.setup", "ok", ...}`).
//!
//! Exit status, the toolchain's table: 0 done (or a dry run); 2 a usage error; 3 a file it may
//! not read or write; 4 changes to existing files wait for confirmation; 5 the project
//! directory is not there; 7 a file could not be read or written otherwise.
use serde_json::{json, Map, Value};
use std::io::{BufRead, IsTerminal, Write};
use std::path::{Path, PathBuf};
/// The command's usage, for `wai`'s own.
pub const USAGE: &str = "wai setup-agent [--dir D] [--dry-run] [--confirm] [--json] [--all] [--only FILE,...] [--toolset core,dev]\n write AGENTS.md (and an import of it in a CLAUDE.md already there) and register `wai mcp`\n in the project's agent configuration";
/// The operation, as the catalogue names it.
const OP: &str = "agent.setup";
/// The exit statuses of the toolchain's table this command ends with.
const USAGE_ERROR: u8 = 2;
const PERMISSION: u8 = 3;
const CONFIRMATION_REQUIRED: u8 = 4;
const NOT_FOUND: u8 = 5;
const INTERNAL: u8 = 7;
/// The markers of the block this command owns in a Markdown file.
const BEGIN: &str = "<!-- wai agent instructions: begin -->";
const END: &str = "<!-- wai agent instructions: end -->";
/// How a file holds the MCP server registration.
#[derive(Clone, Copy)]
enum Form {
/// `{"mcpServers": {"wai": {"command", "args"}}}`.
McpServers,
/// `{"servers": {"wai": {"type": "stdio", "command", "args"}}}`.
Servers,
/// `{"mcp": {"wai": {"type": "local", "command": [program, args...], "enabled": true}}}`.
McpLocal,
/// `.gemini/settings.json`'s `mcpServers`, and `context.fileName` naming AGENTS.md so that the
/// agent reads it.
McpServersAndContext,
}
/// A configuration file: its path in the project, what it is, its form, and when it is written
/// without `--all` (always, or when the directory or file it lives in already exists).
struct Target {
path: &'static str,
what: &'static str,
form: Form,
when_present: Option<&'static str>,
}
/// The MCP configuration files, by their documented formats.
const TARGETS: &[Target] = &[
Target { path: ".mcp.json", what: "the project's MCP server list (mcpServers), read by several terminal agents", form: Form::McpServers, when_present: None },
Target { path: ".cursor/mcp.json", what: "an editor's project MCP file (mcpServers)", form: Form::McpServers, when_present: Some(".cursor") },
Target { path: ".vscode/mcp.json", what: "an editor workspace's MCP file (servers, type stdio)", form: Form::Servers, when_present: Some(".vscode") },
Target { path: ".gemini/settings.json", what: "a terminal agent's project settings (mcpServers; context.fileName names AGENTS.md)", form: Form::McpServersAndContext, when_present: Some(".gemini") },
Target { path: "opencode.json", what: "a terminal agent's project configuration (mcp, type local)", form: Form::McpLocal, when_present: Some("opencode.json") },
];
struct Opts {
dir: PathBuf,
dry_run: bool,
confirm: bool,
json: bool,
all: bool,
only: Option<Vec<String>>,
toolsets: Option<Vec<String>>,
command: String,
}
/// What would become of one file.
struct Plan {
path: String,
what: String,
old: Option<String>,
new: Option<String>,
/// Set when the file is left alone, and why.
skipped: Option<String>,
}
fn usage() -> &'static str {
"usage: wai setup-agent [--dir <project>] [--dry-run] [--confirm] [--json] [--all] [--only <file>[,<file>...]] [--toolset <set>[,<set>...]] [--command <program>]"
}
/// The toolsets `wai mcp` serves, for `--toolset`.
const TOOLSETS: &[&str] = &["core", "dev"];
fn parse(args: &[String]) -> Result<Opts, String> {
let mut o = Opts { dir: PathBuf::from("."), dry_run: false, confirm: false, json: false, all: false, only: None, toolsets: None, command: "wai".into() };
let mut i = 0;
while i < args.len() {
let v = args.get(i + 1);
match (args[i].as_str(), v) {
("--dry-run", _) => o.dry_run = true,
("--confirm", _) => o.confirm = true,
("--json", _) => o.json = true,
("--all", _) => o.all = true,
("--dir", Some(v)) => {
o.dir = PathBuf::from(v);
i += 1;
}
("--only", Some(v)) => {
o.only = Some(v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect());
i += 1;
}
("--toolset", Some(v)) => {
let sets: Vec<String> = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
if let Some(bad) = sets.iter().find(|s| !TOOLSETS.contains(&s.as_str())) {
return Err(format!("--toolset {bad}: no such toolset ({})", TOOLSETS.join(", ")));
}
o.toolsets = Some(sets);
i += 1;
}
("--command", Some(v)) => {
o.command = v.clone();
i += 1;
}
(a, _) => return Err(format!("{a}: not an option here. {}", usage())),
}
i += 1;
}
if let Some(only) = &o.only {
let known: Vec<&str> = ["AGENTS.md", "CLAUDE.md"].into_iter().chain(TARGETS.iter().map(|t| t.path)).collect();
if let Some(bad) = only.iter().find(|x| !known.contains(&x.as_str())) {
return Err(format!("--only {bad}: not a file this command writes ({})", known.join(", ")));
}
}
Ok(o)
}
/// The arguments the server is registered with: the verb, and the toolsets when named.
fn mcp_args(toolsets: Option<&[String]>) -> Vec<String> {
let mut a = vec![super::MCP.to_string()];
if let Some(t) = toolsets {
a.push("--toolset".into());
a.push(t.join(","));
}
a
}
/// AGENTS.md's block: what WAI is and how an agent uses it here.
fn agents_block(command: &str, args: &[String]) -> String {
let server = std::iter::once(command.to_string()).chain(args.iter().cloned()).collect::<Vec<_>>().join(" ");
format!(
"{BEGIN}\n## WAI\n\n\
This project uses WAI, an open media standard. A `.wai` envelope carries media under a registered capability; \
receipts, claims and live-room records are signed so that a verifier can check them.\n\n\
- The `wai` MCP server (`{server}`, registered in this project's MCP configuration) gives you WAI's operations as \
tools, each named by its operation (`receipt.verify` is the tool `receipt_verify`). The `core` toolset inspects envelopes \
and packages, reads the capability registry, verifies receipts, claims, live-room sessions and audit bundles, packages \
media, lists and runs conformance corpora, and searches the specification. The `dev` toolset (`{command} {mcp} --toolset core,dev`) \
runs a development room on loopback.\n\
- Verify with the tools. Do not judge a signature, a receipt or a session yourself: quote the verifier's verdict and its reason code.\n\
- The specification's text is the authority. Search it, and cite a rule by its section (`SPEC.md` §N, or the extension and its section).\n\
- The development tools are for development only. They run on 127.0.0.1 with keys held in memory and refuse any other \
service; never point a deployment at them.\n\
- No tool of the server sends anything off this machine.\n\
- The same operations are on the command line: `{command} inspect`, `{command} capabilities`, `{command} wrap`, \
`{command} verify`, `{command} dev`, `{command} registry export --check`.\n\
{END}\n",
mcp = super::MCP,
)
}
/// CLAUDE.md's block: it imports AGENTS.md, so the instructions live in one file.
fn claude_block() -> String {
format!("{BEGIN}\nThis project's instructions for coding agents are in AGENTS.md:\n\n@AGENTS.md\n{END}\n")
}
/// `text` with the owned block set to `block`: replaced where the markers are, appended
/// otherwise.
fn with_block(text: Option<&str>, block: &str) -> String {
let Some(t) = text else { return block.to_string() };
if let (Some(b), Some(e)) = (t.find(BEGIN), t.find(END))
&& b < e
{
let end = e + END.len();
let end = if t[end..].starts_with('\n') { end + 1 } else { end };
return format!("{}{block}{}", &t[..b], &t[end..]);
}
let sep = if t.is_empty() || t.ends_with("\n\n") { "" } else if t.ends_with('\n') { "\n" } else { "\n\n" };
format!("{t}{sep}{block}")
}
/// The server's entry in `form`.
fn entry(form: Form, command: &str, args: &[String]) -> Value {
match form {
Form::McpServers | Form::McpServersAndContext => json!({"command": command, "args": args}),
Form::Servers => json!({"type": "stdio", "command": command, "args": args}),
Form::McpLocal => json!({"type": "local", "command": std::iter::once(command.to_string()).chain(args.iter().cloned()).collect::<Vec<_>>(), "enabled": true}),
}
}
/// The file's JSON with the server registered, or why it cannot be.
fn with_server(text: Option<&str>, form: Form, command: &str, args: &[String]) -> Result<String, String> {
let mut v: Value = match text {
None => json!({}),
Some(t) if t.trim().is_empty() => json!({}),
Some(t) => serde_json::from_str(t).map_err(|e| format!("not plain JSON ({e}); add by hand: {}", json!({key(form): {"wai": entry(form, command, args)}})))?,
};
let root = v.as_object_mut().ok_or("its top level is not a JSON object")?;
let list = root.entry(key(form)).or_insert_with(|| json!({}));
let list = list.as_object_mut().ok_or_else(|| format!("its {} is not a JSON object", key(form)))?;
list.insert("wai".into(), entry(form, command, args));
if let Form::McpServersAndContext = form {
let ctx = root.entry("context").or_insert_with(|| json!({}));
let ctx = ctx.as_object_mut().ok_or("its context is not a JSON object")?;
match ctx.get_mut("fileName") {
None => {
ctx.insert("fileName".into(), json!(["AGENTS.md", "GEMINI.md"]));
}
Some(Value::String(s)) if s != "AGENTS.md" => {
let old = s.clone();
ctx.insert("fileName".into(), json!(["AGENTS.md", old]));
}
Some(Value::Array(a)) if !a.iter().any(|x| x == "AGENTS.md") => a.insert(0, json!("AGENTS.md")),
_ => {}
}
}
let mut s = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
s.push('\n');
Ok(s)
}
fn key(form: Form) -> &'static str {
match form {
Form::McpServers | Form::McpServersAndContext => "mcpServers",
Form::Servers => "servers",
Form::McpLocal => "mcp",
}
}
/// `rel` inside the project, checked: every existing ancestor resolves inside the project and
/// the file itself is no link.
fn checked(project: &Path, rel: &str) -> Result<PathBuf, String> {
let p = project.join(rel);
let mut existing = p.parent().unwrap_or(project).to_path_buf();
while !existing.exists() {
existing = existing.parent().map(Path::to_path_buf).ok_or("no ancestor exists")?;
}
let real = existing.canonicalize().map_err(|e| format!("{}: {e}", existing.display()))?;
if !real.starts_with(project) {
return Err(format!("{rel} resolves outside the project directory ({}): left alone", real.display()));
}
if std::fs::symlink_metadata(&p).is_ok_and(|m| m.file_type().is_symlink()) {
return Err(format!("{rel} is a link: left alone"));
}
Ok(p)
}
/// Why the command stops: its exit status, the error's stable code, its message and a hint.
struct Fail {
exit: u8,
code: &'static str,
message: String,
hint: &'static str,
}
impl Fail {
fn usage(message: String) -> Fail {
Fail { exit: USAGE_ERROR, code: "usage", message, hint: "wai setup-agent --dry-run shows what it would write" }
}
/// A file that could not be read or written: permission, or anything else.
fn io(p: &Path, e: &std::io::Error) -> Fail {
if e.kind() == std::io::ErrorKind::PermissionDenied {
Fail { exit: PERMISSION, code: "permission-denied", message: format!("{}: {e}", p.display()), hint: "check the files' permissions, or pass --only to leave a file out" }
} else {
Fail { exit: INTERNAL, code: "io-error", message: format!("{}: {e}", p.display()), hint: "check the file, or pass --only to leave it out" }
}
}
}
fn read(p: &Path) -> Result<Option<String>, Fail> {
match std::fs::read_to_string(p) {
Ok(t) => Ok(Some(t)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(Fail::io(p, &e)),
}
}
fn plans(o: &Opts, project: &Path) -> Result<Vec<Plan>, Fail> {
let selected = |path: &str| o.only.as_ref().is_none_or(|only| only.iter().any(|x| x == path));
let mut out = Vec::new();
let mut add = |path: &str, what: &str, make: &dyn Fn(Option<&str>) -> Result<String, String>| -> Result<(), Fail> {
if !selected(path) {
return Ok(());
}
let p = match checked(project, path) {
Ok(p) => p,
Err(why) => {
out.push(Plan { path: path.into(), what: what.into(), old: None, new: None, skipped: Some(why) });
return Ok(());
}
};
let old = read(&p)?;
match make(old.as_deref()) {
Ok(new) => out.push(Plan { path: path.into(), what: what.into(), old, new: Some(new), skipped: None }),
Err(why) => out.push(Plan { path: path.into(), what: what.into(), old, new: None, skipped: Some(why) }),
}
Ok(())
};
let args = mcp_args(o.toolsets.as_deref());
add("AGENTS.md", "the instructions coding agents read", &|old| Ok(with_block(old, &agents_block(&o.command, &args))))?;
// CLAUDE.md only where the project has one: an agent that reads it reads AGENTS.md when it
// is absent, so it gains nothing from a new one.
if std::fs::symlink_metadata(project.join("CLAUDE.md")).is_ok() {
add("CLAUDE.md", "an instructions file the project has; it imports AGENTS.md", &|old| {
// A CLAUDE.md that imports AGENTS.md already needs nothing.
let imports = old.is_some_and(|t| t.lines().any(|l| l.trim() == "@AGENTS.md"));
Ok(if imports { old.unwrap_or_default().to_string() } else { with_block(old, &claude_block()) })
})?;
}
for t in TARGETS {
let present = t.when_present.is_none_or(|p| project.join(p).exists());
if !(present || o.all || o.only.as_ref().is_some_and(|only| only.iter().any(|x| x == t.path))) {
continue;
}
let (form, command, args) = (t.form, o.command.clone(), args.clone());
add(t.path, t.what, &move |old| with_server(old, form, &command, &args))?;
}
Ok(out)
}
/// A unified diff of one hunk: the lines between the common head and the common tail, with
/// three lines of context.
fn diff(path: &str, old: Option<&str>, new: &str) -> String {
let a: Vec<&str> = old.map(|t| t.lines().collect()).unwrap_or_default();
let b: Vec<&str> = new.lines().collect();
let head = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
let tail = a[head..].iter().rev().zip(b[head..].iter().rev()).take_while(|(x, y)| x == y).count();
let from = head.saturating_sub(3);
let a_end = (a.len() - tail + 3).min(a.len());
let b_end = (b.len() - tail + 3).min(b.len());
let mut s = format!("--- {}\n+++ b/{path}\n", if old.is_some() { format!("a/{path}") } else { "/dev/null".into() });
s.push_str(&format!("@@ -{},{} +{},{} @@\n", from + usize::from(!a.is_empty()), a_end - from, from + 1, b_end - from));
for l in &a[from..head] {
s.push_str(&format!(" {l}\n"));
}
for l in &a[head..a.len() - tail] {
s.push_str(&format!("-{l}\n"));
}
for l in &b[head..b.len() - tail] {
s.push_str(&format!("+{l}\n"));
}
for l in &b[b.len() - tail..b_end] {
s.push_str(&format!(" {l}\n"));
}
s
}
fn ask(path: &str) -> bool {
eprint!("change {path}? [y/N] ");
let _ = std::io::stderr().flush();
let mut line = String::new();
std::io::stdin().lock().read_line(&mut line).is_ok() && matches!(line.trim(), "y" | "Y" | "yes")
}
fn write(project: &Path, path: &str, text: &str) -> Result<(), Fail> {
let refused = |why: String| Fail { exit: PERMISSION, code: "outside-project", message: why, hint: "the command writes inside the project directory alone" };
let p = checked(project, path).map_err(refused)?;
if let Some(dir) = p.parent()
&& !dir.exists()
{
std::fs::create_dir_all(dir).map_err(|e| Fail::io(dir, &e))?;
// Created inside the project: check again that it resolves there.
checked(project, path).map_err(refused)?;
}
std::fs::write(&p, text).map_err(|e| Fail::io(&p, &e))
}
/// The toolchain's JSON envelope for this command (`envelope/1`).
fn envelope(ok: bool, fail: Option<&Fail>) -> Map<String, Value> {
let mut v = Map::new();
v.insert("wai".into(), json!("envelope/1"));
v.insert("op".into(), json!(OP));
v.insert("ok".into(), json!(ok));
if let Some(f) = fail {
v.insert("error".into(), json!({"code": f.code, "message": f.message, "hint": f.hint, "retryable": false}));
}
v
}
/// `wai setup-agent`: its exit status, its output written.
pub fn main(args: &[String]) -> u8 {
let json_out = args.iter().any(|a| a == "--json") || !std::io::stdout().is_terminal();
let fail = |f: Fail| {
if json_out {
println!("{}", Value::Object(envelope(false, Some(&f))));
} else {
eprintln!("error: {}\nhint: {}", f.message, f.hint);
}
f.exit
};
let o = match parse(args) {
Ok(o) => o,
Err(e) => return fail(Fail::usage(e)),
};
let project = match o.dir.canonicalize() {
Ok(p) if p.is_dir() => p,
Ok(p) => return fail(Fail::usage(format!("{}: not a directory", p.display()))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return fail(Fail { exit: NOT_FOUND, code: "not-found", message: format!("{}: {e}", o.dir.display()), hint: "give the project's directory with --dir" });
}
Err(e) => return fail(Fail::io(&o.dir, &e)),
};
let plans = match plans(&o, &project) {
Ok(p) => p,
Err(f) => return fail(f),
};
let interactive = !o.json && !o.confirm && !o.dry_run && std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
let mut files = Vec::new();
let mut proposed = Vec::new();
let mut failed: Option<Fail> = None;
for p in &plans {
let mut f = Map::new();
f.insert("path".into(), json!(p.path));
f.insert("what".into(), json!(p.what));
let action = match (&p.skipped, &p.new) {
(Some(why), _) => {
f.insert("reason".into(), json!(why));
"skipped"
}
(None, Some(new)) if p.old.as_deref() == Some(new.as_str()) => "unchanged",
(None, Some(new)) => {
let d = diff(&p.path, p.old.as_deref(), new);
f.insert("diff".into(), json!(d));
let exists = p.old.is_some();
if o.dry_run {
if exists { "would_update" } else { "would_create" }
} else {
let go = !exists || o.confirm || (interactive && {
println!("{d}");
ask(&p.path)
});
if go {
match write(&project, &p.path, new) {
Ok(()) => if exists { "updated" } else { "created" },
Err(e) => {
f.insert("reason".into(), json!(e.message));
failed.get_or_insert(e);
"failed"
}
}
} else if interactive {
"declined"
} else {
proposed.push(json!({"action": "update", "path": p.path, "diff": d}));
"needs_confirmation"
}
}
}
(None, None) => "skipped",
};
f.insert("action".into(), json!(action));
files.push(Value::Object(f));
}
let confirm = format!("wai setup-agent --dir {} --confirm{}{}{}", shell_word(&project.display().to_string()),
o.only.as_ref().map(|x| format!(" --only {}", x.join(","))).unwrap_or_default(),
o.toolsets.as_ref().map(|x| format!(" --toolset {}", x.join(","))).unwrap_or_default(),
if o.all { " --all" } else { "" });
let waiting = !proposed.is_empty();
let pending = Fail {
exit: CONFIRMATION_REQUIRED,
code: "confirmation-required",
message: format!("{} change{} to existing files", proposed.len(), if proposed.len() == 1 { "" } else { "s" }),
hint: "read each diff, then re-run with --confirm",
};
let stop = failed.as_ref().or(if waiting { Some(&pending) } else { None });
let code = stop.map_or(0, |f| f.exit);
if json_out {
let mut v = envelope(code == 0, stop);
v.insert("result".into(), json!({"project": project.display().to_string(), "dry_run": o.dry_run, "files": files}));
if waiting {
v.insert("proposed".into(), json!(proposed));
v.insert("confirm".into(), json!(confirm));
}
println!("{}", Value::Object(v));
} else {
println!("wai setup-agent — {}", project.display());
for f in &files {
println!(" {:<22} {:<19} {}", f["path"].as_str().unwrap_or(""), f["action"].as_str().unwrap_or(""), f["what"].as_str().unwrap_or(""));
if let Some(r) = f["reason"].as_str() {
println!(" {:<22} {r}", "");
}
if (o.dry_run || f["action"] == "needs_confirmation") && let Some(d) = f["diff"].as_str() {
println!("{d}");
}
}
if waiting {
println!("existing files would change and nothing confirmed it; to apply: {confirm}");
}
if let Some(f) = &failed {
eprintln!("error: {}\nhint: {}", f.message, f.hint);
}
}
code
}
/// A path as one shell word.
fn shell_word(s: &str) -> String {
if s.chars().all(|c| c.is_ascii_alphanumeric() || "/._-+".contains(c)) { s.to_string() } else { format!("'{}'", s.replace('\'', "'\\''")) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_block_is_added_once_and_replaced_in_place() {
let b1 = format!("{BEGIN}\none\n{END}\n");
let b2 = format!("{BEGIN}\ntwo\n{END}\n");
let t = with_block(Some("# Mine\n\nkeep\n"), &b1);
assert_eq!(t, format!("# Mine\n\nkeep\n\n{b1}"));
assert_eq!(with_block(Some(&t), &b1), t, "the same block again changes nothing");
let t2 = with_block(Some(&format!("{t}after\n")), &b2);
assert_eq!(t2, format!("# Mine\n\nkeep\n\n{b2}after\n"), "replaced where it is, the rest kept");
}
#[test]
fn a_server_list_keeps_its_other_members() {
let t = r#"{"mcpServers": {"other": {"command": "x"}}, "z": 1}"#;
let args = mcp_args(None);
let v: Value = serde_json::from_str(&with_server(Some(t), Form::McpServers, "wai", &args).unwrap()).unwrap();
assert_eq!(v["mcpServers"]["other"]["command"], "x");
assert_eq!(v["mcpServers"]["wai"]["args"], json!([super::super::MCP]));
assert_eq!(v["z"], 1);
assert!(with_server(Some("{ // a comment\n}"), Form::Servers, "wai", &args).is_err());
let dev = mcp_args(Some(&["core".to_string(), "dev".to_string()]));
let l: Value = serde_json::from_str(&with_server(None, Form::McpLocal, "wai", &dev).unwrap()).unwrap();
assert_eq!(l["mcp"]["wai"]["command"], json!(["wai", "mcp", "--toolset", "core,dev"]));
let g: Value = serde_json::from_str(&with_server(Some(r#"{"context": {"fileName": "GEMINI.md"}}"#), Form::McpServersAndContext, "wai", &args).unwrap()).unwrap();
assert_eq!(g["context"]["fileName"], json!(["AGENTS.md", "GEMINI.md"]));
}
#[test]
fn a_diff_shows_the_changed_lines() {
let d = diff("x", Some("a\nb\nc\n"), "a\nB\nc\n");
assert!(d.contains("-b\n+B\n"), "{d}");
let d = diff("x", None, "a\n");
assert!(d.starts_with("--- /dev/null\n+++ b/x\n") && d.contains("+a\n"), "{d}");
}
}

View file

@ -43,6 +43,9 @@ pub mod pin;
// The reader limits the extension formats share with SPEC §2: how many samples, counting
// every channel, and how many frames a reader must be willing to render. Always on.
pub mod limits;
// The rule the toolchain's operation names keep (noun.verb, a closed list of verbs) and the
// forms the doors derive from a name: an MCP tool's, an OpenAPI operationId. Always on.
pub mod ops;
#[cfg(feature = "ffi")]
pub mod ffi;
#[cfg(feature = "codecs")]

66
wai/wai-rs/src/ops.rs Normal file
View file

@ -0,0 +1,66 @@
//! The rule the toolchain's operation names keep (WAI toolchain v1, naming rule N3), and the
//! mechanical forms the doors derive from a name.
//!
//! An operation is named `noun.verb`: both parts lowercase ASCII letters, the noun singular
//! (a compound written as one word, as `keylog` is), the verb from a closed list ([`VERBS`]).
//! A near-synonym is not a second verb: `get`, `describe` and `info` are `show`; `keygen`,
//! `init` and `new` are `create`. The catalogue names a few operations whose last word is not on
//! the list ([`NAMED_AS_IS`]); they keep the names it gives them.
//!
//! Each door takes its form from the name, never a name of its own: an OpenAPI operation's
//! `operationId` is the name itself, and an MCP tool's name is the name with `_` for the dot
//! ([`mcp_tool_name`]). `wai mcp`'s tools and the room service's API description are held to
//! this rule by their tests.
/// The verbs an operation's name may end with.
pub const VERBS: &[&str] = &[
"create", "list", "show", "update", "delete", "check", "verify", "run", "build", "export", "import", "start", "stop", "join",
"leave", "grant", "invite", "revoke", "rotate", "tail", "search", "test", "load", "encode", "decode", "inspect", "wrap", "unwrap",
"fetch", "generate", "promote", "rollback", "lock", "upgrade", "setup", "guide", "stream",
];
/// Names the catalogue gives as they are, though their last word is not on the list: the
/// catalogue's table is the authority on its own names.
pub const NAMED_AS_IS: &[&str] = &[
"room.session", "agent.mcp", "toolchain.hello", "toolchain.doctor", "ops.schema", "edge.derive", "meter.calibrate", "audit.report",
];
/// `Ok` when `id` is an operation's name by the rule (or one the catalogue gives as it is), or
/// why it is not.
pub fn check_id(id: &str) -> Result<(), String> {
if NAMED_AS_IS.contains(&id) {
return Ok(());
}
let Some((noun, verb)) = id.split_once('.') else {
return Err(format!("{id:?}: an operation is named noun.verb"));
};
let letters = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_lowercase());
if !letters(noun) || !letters(verb) {
return Err(format!("{id:?}: the noun and the verb are lowercase ASCII letters, one dot between them"));
}
if !VERBS.contains(&verb) {
return Err(format!("{id:?}: {verb} is not one of the verbs an operation ends with ({})", VERBS.join(", ")));
}
Ok(())
}
/// The MCP tool an operation is served as: its name with `_` for the dot.
pub fn mcp_tool_name(id: &str) -> String {
id.replace('.', "_")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_keep_the_rule() {
for ok in ["room.grant", "envelope.inspect", "keylog.verify", "dev.start", "room.session"] {
assert_eq!(check_id(ok), Ok(()), "{ok}");
}
for no in ["room", "room.get", "Room.grant", "room.grant.now", "room_grant", "room.", ".grant", "key-log.verify", "room.describe", "call.session"] {
assert!(check_id(no).is_err(), "{no}");
}
assert_eq!(mcp_tool_name("receipt.verify"), "receipt_verify");
}
}

420
wai/wai-rs/tests/cli_mcp.rs Normal file
View file

@ -0,0 +1,420 @@
//! `wai mcp` as a coding agent meets it: the binary started as an MCP server, and a client in
//! this test speaking the protocol to it over standard input and output (newline-delimited
//! JSON-RPC 2.0, MCP 2025-06-18): the handshake, the tool list, and every tool through
//! `tools/call`.
//!
//! - Every tool is an operation of the toolchain's catalogue, named by it: the tool list is
//! exactly the operations below, each tool's name its operation's with `_` for the dot, each
//! operation's name keeping the rule (`wai::ops::check_id`). `core` is offered by default,
//! `dev` when it is asked for, and a toolset that does not exist is refused.
//! - `receipt_verify` agrees with the committed corpora vector by vector: every group and object
//! vector of jwp-conformance (kind delivery), every claim vector whose context is one the tool
//! takes (none, the previous claim, the object, an OER receipt, a gap's object; kind claim),
//! every room of live-conformance/rooms (kind room_session) and every session of
//! live-conformance/audit (kind audit_bundle); a receipt changed by one character is refused.
//! - `package_build` writes an envelope inside the project directory only, never over a file
//! without `overwrite`; `envelope_inspect` reads it back, and the directory as a package.
//! - `conformance_run` runs a corpus's independent verifier and reports it passed.
//! - The `dev` tools refuse a service that is not on a loopback address, before any
//! connection, and any target but `local`; with no room they say how to start one. (The room
//! itself, joined, published to and verified through these tools, is joule-moq-room's
//! tests/mcp_dev_room.rs.)
//! - `--read-only` offers no tool that writes, serves or asks for a grant.
#![cfg(feature = "mcp")]
use serde_json::{json, Value};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
/// The `wai/` directory: the specification and the corpora.
fn wai_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("..").canonicalize().unwrap()
}
/// An MCP client of a `wai mcp` process.
struct Client {
child: Child,
stdin: ChildStdin,
out: BufReader<ChildStdout>,
next: i64,
}
impl Client {
fn start(project: &Path, extra: &[&str]) -> Client {
let mut child = Command::new(env!("CARGO_BIN_EXE_wai"))
.arg("mcp")
.arg("--spec-root")
.arg(wai_dir())
.arg("--project")
.arg(project)
.args(extra)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.expect("wai mcp starts");
let stdin = child.stdin.take().unwrap();
let out = BufReader::new(child.stdout.take().unwrap());
let mut c = Client { child, stdin, out, next: 1 };
let init = c.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "cli_mcp test", "version": "1"}}));
assert_eq!(init["result"]["protocolVersion"], "2025-06-18", "{init}");
c.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}));
c
}
fn send(&mut self, v: &Value) {
writeln!(self.stdin, "{v}").unwrap();
self.stdin.flush().unwrap();
}
fn line(&mut self) -> Value {
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_or_else(|e| panic!("a line that is not JSON on the protocol channel: {e}: {l:?}"))
}
fn request(&mut self, method: &str, params: Value) -> Value {
let id = self.next;
self.next += 1;
self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
let v = self.line();
assert_eq!(v["id"], json!(id), "answers come in order: {v}");
v
}
/// A tool's structured result, or its error text.
fn call(&mut self, name: &str, args: Value) -> Result<Value, String> {
let v = self.request("tools/call", json!({"name": name, "arguments": args}));
let r = &v["result"];
assert!(r.is_object(), "{name}: {v}");
let text = r["content"][0]["text"].as_str().unwrap_or("").to_string();
if r["isError"] == json!(true) {
return Err(text);
}
let s = r["structuredContent"].clone();
assert!(s.is_object(), "{name}: a structured result: {v}");
assert!(serde_json::from_str::<Value>(&text).is_ok_and(|t| t.is_object()), "{name}: the text block is the result's JSON");
Ok(s)
}
fn ok(&mut self, name: &str, args: Value) -> Value {
self.call(name, args.clone()).unwrap_or_else(|e| panic!("{name} {args}: {e}"))
}
}
impl Drop for Client {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn scratch(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("wai-cli-mcp-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d.canonicalize().unwrap()
}
fn corpus(rel: &str) -> Value {
serde_json::from_str(&std::fs::read_to_string(wai_dir().join(rel)).unwrap()).unwrap()
}
/// The operations of each toolset, in the order the server lists them. A tool is its
/// operation's name with `_` for the dot.
const CORE: &[&str] = &["envelope.inspect", "registry.list", "registry.show", "receipt.verify", "package.build", "conformance.list", "conformance.run", "docs.search"];
const DEV: &[&str] = &["dev.start", "dev.stop", "room.grant", "room.join", "room.leave", "room.session"];
fn tool_names(c: &mut Client) -> Vec<String> {
let list = c.request("tools/list", json!({}));
list["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect()
}
fn names_of(ops: &[&[&str]]) -> Vec<String> {
ops.iter().flat_map(|s| s.iter()).map(|op| wai::ops::mcp_tool_name(op)).collect()
}
#[test]
fn the_handshake_and_the_tool_list() {
let d = scratch("handshake");
// core by default.
let mut c = Client::start(&d, &[]);
assert_eq!(tool_names(&mut c), names_of(&[CORE]));
// core and dev when asked: every tool an operation named by the rule.
let mut c = Client::start(&d, &["--toolset", "core,dev"]);
let list = c.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
let names: Vec<String> = tools.iter().map(|t| t["name"].as_str().unwrap().to_string()).collect();
assert_eq!(names, names_of(&[CORE, DEV]));
for t in tools {
let op = t["_meta"]["wai/operation"].as_str().unwrap();
assert_eq!(wai::ops::check_id(op), Ok(()), "{t}");
assert_eq!(t["name"].as_str().unwrap(), op.replace('.', "_"), "{t}: the tool is its operation, named by it");
let set = t["_meta"]["wai/toolset"].as_str().unwrap();
assert_eq!(set, if DEV.contains(&op) { "dev" } else { "core" }, "{t}");
let d = t["description"].as_str().unwrap();
assert!(!d.is_empty() && !d.contains('\n'), "{t}: a one-line description");
assert_eq!(t["inputSchema"]["type"], "object", "{t}");
assert!(t["annotations"]["readOnlyHint"].is_boolean(), "{t}");
assert_eq!(d.starts_with("DEVELOPMENT ONLY."), set == "dev", "{t}: a development tool is marked so, and only one");
}
let mut only_dev = Client::start(&d, &["--toolset", "dev"]);
assert_eq!(tool_names(&mut only_dev), names_of(&[DEV]));
// A toolset this server does not have is refused before it serves.
let out = Command::new(env!("CARGO_BIN_EXE_wai")).args(["mcp", "--toolset", "core,nonesuch"]).stdin(Stdio::null()).output().unwrap();
assert_eq!(out.status.code(), Some(2));
assert!(String::from_utf8_lossy(&out.stderr).contains("nonesuch"), "{out:?}");
// The protocol's own answers.
assert_eq!(c.request("ping", json!({}))["result"], json!({}));
assert_eq!(c.request("nonsense/method", json!({}))["error"]["code"], -32601);
assert_eq!(c.request("tools/call", json!({"name": "no_such_tool", "arguments": {}}))["error"]["code"], -32602);
c.send(&json!([{"jsonrpc": "2.0", "id": 99, "method": "ping"}]));
assert_eq!(c.line()["error"]["code"], -32600, "a batch is refused");
writeln!(c.stdin, "{{not json").unwrap();
c.stdin.flush().unwrap();
assert_eq!(c.line()["error"]["code"], -32700);
// An older revision a client asks for is answered in it.
let mut old = Client::start(&d, &[]);
assert_eq!(old.request("initialize", json!({"protocolVersion": "2024-11-05"}))["result"]["protocolVersion"], "2024-11-05");
}
#[test]
fn read_only_offers_no_tool_that_writes_serves_or_mints() {
let d = scratch("readonly");
let mut c = Client::start(&d, &["--toolset", "core,dev", "--read-only"]);
let names = tool_names(&mut c);
for gone in ["package_build", "dev_start", "dev_stop", "room_grant", "room_join", "room_leave", "room_session"] {
assert!(!names.iter().any(|n| n == gone), "{gone} is offered read-only");
}
assert!(names.iter().any(|n| n == "receipt_verify"));
let r = c.request("tools/call", json!({"name": "package_build", "arguments": {"input": "x", "capability": "wai.text.zstd"}}));
assert_eq!(r["error"]["code"], -32602, "{r}");
}
#[test]
fn the_registry_and_the_specification() {
let d = scratch("registry");
let mut c = Client::start(&d, &[]);
let all = c.ok("registry_list", json!({}));
let n = all["count"].as_u64().unwrap();
assert!(n > 50 && all["capabilities"].as_array().unwrap().len() as u64 == n);
let rf = c.ok("registry_list", json!({"licence": "royalty-free", "media": "image"}));
assert!(rf["count"].as_u64().unwrap() > 0);
for e in rf["capabilities"].as_array().unwrap() {
assert_eq!((e["licence"].as_str(), e["media"].as_str()), (Some("royalty-free"), Some("image")), "{e}");
}
let png = c.ok("registry_show", json!({"capability": "wai.image.png"}));
assert_eq!(png["registry"]["capability"], "wai.image.png");
assert_eq!(png["this_build"]["licence"], png["registry"]["licence"]);
assert!(!png["spec_lines"].as_array().unwrap().is_empty(), "{png}");
let e = c.call("registry_show", json!({"capability": "wai.image.nothing"})).unwrap_err();
assert!(e.contains("not in the registry") && e.contains("registry_list"), "{e}");
let s = c.ok("docs_search", json!({"query": "capability fallback", "limit": 5}));
let hits = s["hits"].as_array().unwrap();
assert_eq!(hits.len(), 5);
for h in hits {
let t = h["text"].as_str().unwrap().to_ascii_lowercase();
assert!(t.contains("capability") && t.contains("fallback"), "{h}");
assert!(h["line"].as_u64().unwrap() > 0 && h["file"].as_str().unwrap().ends_with(".md"));
}
assert_eq!(hits[0]["file"], "SPEC.md", "SPEC.md is searched first");
assert!(c.call("docs_search", json!({"query": " "})).is_err());
}
#[test]
fn packaging_and_inspecting_stay_inside_the_project() {
let d = scratch("package");
let mut c = Client::start(&d, &[]);
let input = d.join("hello.txt");
std::fs::write(&input, b"hello, world").unwrap();
let p = c.ok("package_build", json!({"input": input, "capability": "wai.text.zstd", "intent": "improve"}));
assert_eq!((p["format"].as_str(), p["components"][0]["capability"].as_str()), (Some("WAI1"), Some("wai.text.zstd")), "{p}");
let out = d.join("hello.txt.wai");
assert!(out.is_file());
assert!(c.call("package_build", json!({"input": input, "capability": "wai.text.zstd", "intent": "improve"})).unwrap_err().contains("exists"));
c.ok("package_build", json!({"input": input, "capability": "wai.text.zstd", "intent": "improve", "overwrite": true}));
let outside = std::env::temp_dir().join(format!("wai-cli-mcp-outside-{}.wai", std::process::id()));
let e = c.call("package_build", json!({"input": input, "capability": "wai.text.zstd", "output": outside})).unwrap_err();
assert!(e.contains("outside the project directory") && !outside.exists(), "{e}");
assert!(c.call("package_build", json!({"input": input, "capability": "wai.text.zstd", "output": "../escape.wai"})).is_err());
assert!(c.call("package_build", json!({"input": input, "capability": "not.a.capability"})).is_err());
#[cfg(unix)]
{
std::os::unix::fs::symlink(&outside, d.join("link.wai")).unwrap();
assert!(c.call("package_build", json!({"input": input, "capability": "wai.text.zstd", "output": "link.wai", "overwrite": true})).unwrap_err().contains("a link"));
assert!(!outside.exists(), "nothing written through a link");
std::fs::remove_file(d.join("link.wai")).unwrap();
}
let i = c.ok("envelope_inspect", json!({"path": out}));
assert_eq!((i["kind"].as_str(), i["format"].as_str(), i["intent"].as_str()), (Some("envelope"), Some("WAI1"), Some("improve")), "{i}");
let comp = &i["components"][0];
assert_eq!(comp["capability"], "wai.text.zstd");
assert_eq!(comp["bytes"], 12);
assert!(comp["licence"].is_string() && comp["tier"].is_string(), "{comp}");
assert_eq!(i["checks"]["component_roles"], "ok");
let pkg = c.ok("envelope_inspect", json!({"path": d}));
assert_eq!(pkg["kind"], "package");
assert_eq!(pkg["envelopes"].as_array().unwrap().len(), 1);
assert_eq!(pkg["listing_sha256"].as_str().unwrap().len(), 64);
assert!(pkg["capabilities"]["wai.text.zstd"].is_object(), "{pkg}");
assert!(c.call("envelope_inspect", json!({"path": input})).unwrap_err().contains("not a WAI envelope"));
let _ = std::fs::remove_dir_all(&d);
}
/// `reject step=N reason=X` or `accept` as the corpus states it.
fn expected_receipt(e: &Value) -> (String, Option<u64>, Option<String>) {
(e["outcome"].as_str().unwrap().replace("reject", "refuse"), e["step"].as_u64(), e["reason"].as_str().map(String::from))
}
#[test]
fn receipts_agree_with_the_jwp_corpus_and_a_changed_one_is_refused() {
let d = scratch("receipts");
let mut c = Client::start(&d, &[]);
let doc = corpus("jwp-conformance/vectors.json");
let mut n = 0;
for v in doc["vectors"].as_array().unwrap() {
let i = &v["input"];
let args = match v["procedure"].as_str() {
Some("group") => {
let mut a = json!({"receipt": i["group"]});
if let Some(p) = i.get("previous") {
a["previous"] = p.clone();
}
a
}
Some("object") => json!({"receipt": i["object"], "group": i["group"], "envelope_hex": i["envelope"]}),
_ => continue,
};
let got = c.ok("receipt_verify", args);
assert_eq!(got["kind"], "delivery", "{}: the kind read from the arguments", v["name"]);
let (want, step, reason) = expected_receipt(&v["expected"]);
assert_eq!(got["verdict"].as_str(), Some(want.as_str()), "{}: {got}", v["name"]);
if want == "refuse" {
assert_eq!((got["step"].as_u64(), got["reason"].as_str().map(String::from)), (step, reason), "{}: {got}", v["name"]);
} else if let Some(h) = v["expected"]["link_hash"].as_str() {
assert_eq!(got["link_hash"], h, "{}", v["name"]);
}
n += 1;
}
assert_eq!(n, 54, "every group and object vector");
// A group receipt that verifies, its figure changed: refused.
let good = doc["vectors"].as_array().unwrap().iter().find(|v| v["procedure"] == "group" && v["expected"]["outcome"] == "accept" && v["input"].get("previous").is_none()).unwrap();
let mut bad = good["input"]["group"].clone();
bad["joules_total"] = json!(bad["joules_total"].as_u64().unwrap() + 1);
let r = c.ok("receipt_verify", json!({"receipt": bad}));
assert_eq!((r["verdict"].as_str(), r["reason"].as_str()), (Some("refuse"), Some("signature_verify_failed")), "{r}");
let r = c.ok("receipt_verify", json!({"kind": "delivery", "receipt": good["input"]["group"].to_string()}));
assert_eq!((r["kind"].as_str(), r["receipt"].as_str(), r["verdict"].as_str()), (Some("delivery"), Some("group"), Some("accept")), "a receipt given as its text: {r}");
assert!(c.call("receipt_verify", json!({"receipt": {"kind": "elsewhere"}})).is_err());
// A kind the tool does not take, arguments naming none, and arguments naming two.
assert!(c.call("receipt_verify", json!({"kind": "letter", "receipt": bad})).unwrap_err().contains("kind"));
assert!(c.call("receipt_verify", json!({})).is_err());
assert!(c.call("receipt_verify", json!({"receipt": bad, "claim": "x"})).unwrap_err().contains("give kind"));
}
#[test]
fn claims_agree_with_the_claim_corpus() {
let d = scratch("claims");
let mut c = Client::start(&d, &[]);
let doc = corpus("claim-conformance/vectors.json");
let mut n = 0;
let taken = ["previous", "object", "oer", "gap_object"];
for v in doc["vectors"].as_array().unwrap() {
let ctx = v.get("context").cloned().unwrap_or(json!({}));
if v["procedure"] != "claim" || ctx.as_object().unwrap().keys().any(|k| !taken.contains(&k.as_str())) {
continue;
}
let mut args = ctx.clone();
args["claim"] = v["receipt"].clone();
let got = c.ok("receipt_verify", args);
assert_eq!(got["kind"], "claim", "{}", v["name"]);
let e = &v["expected"];
if e["outcome"] == "accept" {
assert_eq!(got["verdict"], "accept", "{}: {got}", v["name"]);
if let Some(h) = e["link_hash"].as_str() {
assert_eq!(got["link_hash"], h, "{}", v["name"]);
}
} else {
assert_eq!((got["verdict"].as_str(), got["step"].as_u64(), got["reason"].as_str()), (Some("refuse"), e["step"].as_u64(), e["reason"].as_str()), "{}: {got}", v["name"]);
}
n += 1;
}
assert!(n > 340, "{n} claim vectors, alone or with the context the tool takes");
}
#[test]
fn rooms_agree_with_the_live_rooms_corpus() {
let d = scratch("rooms");
let mut c = Client::start(&d, &[]);
let doc = corpus("live-conformance/rooms/vectors.json");
for v in doc["vectors"].as_array().unwrap() {
let got = c.ok("receipt_verify", json!({"kind": "room_session", "descriptor": v["descriptor"], "grants": v["grants"], "key_log_head": v["key_log_head"], "chains": v["chains"]}));
assert_eq!(got["kind"], "room_session");
assert_eq!(got["outcome"], v["outcome"], "{}: {got}", v["name"]);
match v["outcome"].as_str().unwrap() {
"refuse" => assert_eq!((&got["reason"], &got["at"]), (&v["reason"], &v["at"]), "{}", v["name"]),
"gap" => assert_eq!((&got["gaps"], &got["room"]), (&v["gaps"], &v["room"]), "{}", v["name"]),
_ => assert_eq!(got["room"], v["room"], "{}", v["name"]),
}
}
}
#[test]
fn audit_bundles_agree_with_the_stream_audit_corpus() {
let d = scratch("audit");
let mut c = Client::start(&d, &[]);
let doc = corpus("live-conformance/audit/vectors.json");
for s in doc["sessions"].as_array().unwrap() {
let got = c.ok("receipt_verify", json!({"bundle": s["bundle"], "owner": doc["owner"], "channels": doc["channels"]}));
assert_eq!(got["kind"], "audit_bundle");
assert_eq!(got["outcome"], s["outcome"]["outcome"], "{}: {got}", s["name"]);
if s["outcome"]["outcome"] == "refuse" {
assert_eq!(got["reason"], s["outcome"]["reason"], "{}", s["name"]);
}
}
}
#[test]
fn a_corpus_runs_through_its_independent_verifier() {
let d = scratch("conformance");
let mut c = Client::start(&d, &[]);
let l = c.ok("conformance_list", json!({}));
let pairs: Vec<(String, String)> = l["corpora"].as_array().unwrap().iter().map(|p| (p["corpus"].as_str().unwrap().into(), p["verifier"].as_str().unwrap().into())).collect();
assert!(pairs.contains(&("live-conformance/rooms".into(), "live-conformance/live_rooms_verify.py".into())), "{pairs:?}");
assert!(pairs.contains(&("claim-conformance".into(), "claim-conformance/claim_verify.py".into())), "{pairs:?}");
let r = c.ok("conformance_run", json!({"corpus": "live-conformance/rooms", "timeout_s": 600}));
assert_eq!(r["passed"], true, "{r}");
assert_eq!(r["exit_code"], 0);
assert!(c.call("conformance_run", json!({"corpus": "no-such-conformance"})).unwrap_err().contains("no verifier"));
let e = c.call("conformance_run", json!({"corpus": "live-conformance/rooms", "verifier": "../scripts/check-spec-refs.py"})).unwrap_err();
assert!(e.contains("outside") || e.contains("_verify.py"), "{e}");
}
#[test]
fn development_tools_refuse_any_service_off_this_machine() {
let d = scratch("dev");
let mut c = Client::start(&d, &["--toolset", "core,dev"]);
for remote in ["http://10.0.0.5:9682", "https://127.0.0.1:9682", "http://room.example/room-api", "http://127.0.0.1.example:80"] {
let e = c.call("room_grant", json!({"principal": "alice", "role": "publisher", "service": remote})).unwrap_err();
assert!(e.starts_with("DEVELOPMENT ONLY"), "{remote}: {e}");
}
// A target other than local is refused, whatever the service.
for t in ["room_grant", "room_join", "room_leave", "room_session"] {
let e = c.call(t, json!({"target": "cloud", "principal": "alice", "role": "publisher"})).unwrap_err();
assert!(e.contains("target local alone"), "{t}: {e}");
}
// A loopback service is asked: with nothing listening, the connection is what fails.
let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let e = c.call("room_grant", json!({"target": "local", "principal": "alice", "role": "publisher", "service": format!("http://127.0.0.1:{port}")})).unwrap_err();
assert!(!e.starts_with("DEVELOPMENT ONLY") && e.contains(&port.to_string()), "{e}");
// With no room running, each tool says how to start one.
for t in ["room_join", "room_leave"] {
assert!(c.call(t, json!({"principal": "alice"})).is_err());
}
assert!(c.call("room_session", json!({})).unwrap_err().contains("dev_start"));
assert!(c.call("dev_stop", json!({})).is_err());
assert!(c.call("room_grant", json!({"principal": "alice", "role": "publisher"})).unwrap_err().contains("dev_start"));
let e = c.call("dev_start", json!({"room_bin": d.join("no-such-program")})).unwrap_err();
assert!(e.contains("no-such-program"), "{e}");
}

View file

@ -0,0 +1,192 @@
//! `wai setup-agent` in a scratch project, run as an agent runs it (no terminal, so its output
//! is the toolchain's JSON envelope, `envelope/1`, and it asks nothing):
//!
//! - a fresh project gets AGENTS.md and `.mcp.json`, and the configuration files of the agents
//! whose directories it holds, and no CLAUDE.md; a second run changes nothing;
//! - a CLAUDE.md the project has gains an import of AGENTS.md;
//! - a file that exists is changed only with `--confirm`: without it nothing that exists is
//! written, the command exits 4 (confirmation required) with each proposed change and the
//! command that confirms; with it the block is added and everything else in the file is kept;
//! `--dry-run` writes nothing;
//! - `--toolset` registers the server with those toolsets;
//! - a JSON file it cannot read as JSON is left alone, with the entry to add by hand;
//! - nothing outside the project directory is touched, through a link or otherwise;
//! - its errors carry the table's exit status and a stable code: 2 usage, 5 not found.
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::process::Command;
fn scratch(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("wai-cli-setup-agent-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d.canonicalize().unwrap()
}
/// Run it in `dir`: its exit status and its JSON.
fn run(dir: &Path, extra: &[&str]) -> (i32, Value) {
let out = Command::new(env!("CARGO_BIN_EXE_wai")).arg("setup-agent").arg("--dir").arg(dir).args(extra).output().expect("run wai");
let text = String::from_utf8_lossy(&out.stdout);
let v: Value = serde_json::from_str(text.trim()).unwrap_or_else(|e| panic!("JSON when not on a terminal: {e}: {text}"));
(out.status.code().unwrap(), v)
}
fn files(v: &Value) -> &Vec<Value> {
assert_eq!((v["wai"].as_str(), v["op"].as_str()), (Some("envelope/1"), Some("agent.setup")), "the toolchain's envelope: {v}");
v["result"]["files"].as_array().unwrap()
}
fn action(v: &Value, path: &str) -> String {
files(v).iter().find(|f| f["path"] == path).map(|f| f["action"].as_str().unwrap().to_string()).unwrap_or_else(|| "absent".into())
}
fn read(p: PathBuf) -> String {
std::fs::read_to_string(p).unwrap()
}
#[test]
fn a_fresh_project_is_set_up_and_a_second_run_changes_nothing() {
let d = scratch("fresh");
std::fs::create_dir(d.join(".vscode")).unwrap();
let (code, v) = run(&d, &[]);
assert_eq!(code, 0, "{v}");
assert_eq!(v["ok"], true);
for p in ["AGENTS.md", ".mcp.json", ".vscode/mcp.json"] {
assert_eq!(action(&v, p), "created", "{p}: {v}");
}
assert_eq!(action(&v, ".cursor/mcp.json"), "absent", "an agent whose directory is not there is left out");
assert_eq!(action(&v, "CLAUDE.md"), "absent", "no CLAUDE.md is made where the project has none");
assert!(!d.join("CLAUDE.md").exists());
let agents = read(d.join("AGENTS.md"));
assert!(agents.contains("## WAI") && agents.contains("wai mcp") && agents.contains("receipt_verify"), "{agents}");
let mcp: Value = serde_json::from_str(&read(d.join(".mcp.json"))).unwrap();
assert_eq!(mcp["mcpServers"]["wai"], json!({"command": "wai", "args": ["mcp"]}));
let vs: Value = serde_json::from_str(&read(d.join(".vscode/mcp.json"))).unwrap();
assert_eq!(vs["servers"]["wai"], json!({"type": "stdio", "command": "wai", "args": ["mcp"]}));
let (code, v) = run(&d, &[]);
assert_eq!(code, 0);
assert!(files(&v).iter().all(|f| f["action"] == "unchanged"), "{v}");
// --all writes every agent's file, a directory it needs made inside the project.
let (code, v) = run(&d, &["--all", "--confirm"]);
assert_eq!(code, 0, "{v}");
for p in [".cursor/mcp.json", ".gemini/settings.json", "opencode.json"] {
assert_eq!(action(&v, p), "created", "{p}");
}
let g: Value = serde_json::from_str(&read(d.join(".gemini/settings.json"))).unwrap();
assert_eq!(g["context"]["fileName"][0], "AGENTS.md", "{g}");
let oc: Value = serde_json::from_str(&read(d.join("opencode.json"))).unwrap();
assert_eq!(oc["mcp"]["wai"], json!({"type": "local", "command": ["wai", "mcp"], "enabled": true}));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn an_existing_file_changes_only_when_confirmed() {
let d = scratch("existing");
let mine = "# My project\n\nRun `make test` before you commit.\n";
let claude = "# Notes for one agent\n";
std::fs::write(d.join("AGENTS.md"), mine).unwrap();
std::fs::write(d.join("CLAUDE.md"), claude).unwrap();
std::fs::write(d.join(".mcp.json"), r#"{"mcpServers": {"other": {"command": "other-server"}}}"#).unwrap();
// A dry run writes nothing, existing or not.
let (code, v) = run(&d, &["--dry-run"]);
assert_eq!(code, 0, "{v}");
assert_eq!((action(&v, "AGENTS.md"), action(&v, "CLAUDE.md")), ("would_update".into(), "would_update".into()));
assert_eq!(read(d.join("AGENTS.md")), mine);
// Without --confirm: what exists waits, and the command says how to confirm (exit 4).
let (code, v) = run(&d, &[]);
assert_eq!(code, 4, "confirmation required: {v}");
assert_eq!((v["ok"].as_bool(), v["error"]["code"].as_str(), v["error"]["retryable"].as_bool()), (Some(false), Some("confirmation-required"), Some(false)), "{v}");
for p in ["AGENTS.md", "CLAUDE.md", ".mcp.json"] {
assert_eq!(action(&v, p), "needs_confirmation", "{p}");
}
assert_eq!(read(d.join("AGENTS.md")), mine, "nothing that exists was changed");
assert_eq!(read(d.join("CLAUDE.md")), claude, "nothing that exists was changed");
let proposed = v["proposed"].as_array().unwrap();
assert_eq!(proposed.len(), 3, "{v}");
let diff = proposed.iter().find(|f| f["path"] == "AGENTS.md").unwrap()["diff"].as_str().unwrap().to_string();
assert!(diff.starts_with("--- a/AGENTS.md\n+++ b/AGENTS.md\n") && diff.contains("+## WAI"), "{diff}");
let confirm = v["confirm"].as_str().unwrap();
assert!(confirm.ends_with("--confirm") && confirm.contains(d.to_str().unwrap()), "{confirm}");
// With it, the block is added and the rest kept.
let (code, v) = run(&d, &["--confirm"]);
assert_eq!(code, 0, "{v}");
assert_eq!(action(&v, "AGENTS.md"), "updated");
let c = read(d.join("CLAUDE.md"));
assert!(c.starts_with(claude) && c.lines().any(|l| l == "@AGENTS.md"), "a CLAUDE.md the project has imports AGENTS.md: {c}");
let agents = read(d.join("AGENTS.md"));
assert!(agents.starts_with(mine) && agents.contains("## WAI"), "{agents}");
let mcp: Value = serde_json::from_str(&read(d.join(".mcp.json"))).unwrap();
assert_eq!(mcp["mcpServers"]["other"]["command"], "other-server", "the other server is kept");
assert_eq!(mcp["mcpServers"]["wai"]["args"][0], "mcp");
// Once more: the block is replaced in place, not added twice.
let (code, _) = run(&d, &["--confirm"]);
assert_eq!(code, 0);
assert_eq!(read(d.join("AGENTS.md")).matches("## WAI").count(), 1);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_file_that_is_not_plain_json_is_left_alone() {
let d = scratch("jsonc");
std::fs::create_dir(d.join(".vscode")).unwrap();
let jsonc = "{\n // the user's comment\n \"servers\": {}\n}\n";
std::fs::write(d.join(".vscode/mcp.json"), jsonc).unwrap();
let (code, v) = run(&d, &["--confirm"]);
assert_eq!(code, 0, "{v}");
assert_eq!(action(&v, ".vscode/mcp.json"), "skipped");
let f = files(&v).iter().find(|f| f["path"] == ".vscode/mcp.json").unwrap();
assert!(f["reason"].as_str().unwrap().contains("add by hand"), "{f}");
assert_eq!(read(d.join(".vscode/mcp.json")), jsonc);
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn nothing_outside_the_project_is_touched() {
let d = scratch("links");
let outside = scratch("links-outside");
std::fs::write(outside.join("AGENTS.md"), "outside\n").unwrap();
std::os::unix::fs::symlink(outside.join("AGENTS.md"), d.join("AGENTS.md")).unwrap();
std::os::unix::fs::symlink(&outside, d.join(".cursor")).unwrap();
let (code, v) = run(&d, &["--confirm", "--all"]);
assert_eq!(code, 0, "{v}");
assert_eq!(action(&v, "AGENTS.md"), "skipped");
assert_eq!(action(&v, ".cursor/mcp.json"), "skipped");
assert_eq!(read(outside.join("AGENTS.md")), "outside\n");
assert!(!outside.join("mcp.json").exists(), "nothing written through the linked directory");
let names: Vec<_> = std::fs::read_dir(&outside).unwrap().map(|e| e.unwrap().file_name()).collect();
assert_eq!(names.len(), 1, "{names:?}");
let _ = std::fs::remove_dir_all(&d);
let _ = std::fs::remove_dir_all(&outside);
}
#[test]
fn the_toolsets_named_are_registered() {
let d = scratch("toolsets");
std::fs::write(d.join("opencode.json"), "{}\n").unwrap();
let (code, v) = run(&d, &["--toolset", "core,dev", "--confirm"]);
assert_eq!(code, 0, "{v}");
let mcp: Value = serde_json::from_str(&read(d.join(".mcp.json"))).unwrap();
assert_eq!(mcp["mcpServers"]["wai"], json!({"command": "wai", "args": ["mcp", "--toolset", "core,dev"]}));
let oc: Value = serde_json::from_str(&read(d.join("opencode.json"))).unwrap();
assert_eq!(oc["mcp"]["wai"]["command"], json!(["wai", "mcp", "--toolset", "core,dev"]));
assert!(read(d.join("AGENTS.md")).contains("`wai mcp --toolset core,dev`"));
let (code, v) = run(&d, &["--toolset", "quantum"]);
assert_eq!((code, v["error"]["code"].as_str()), (2, Some("usage")), "{v}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_usage_error_is_structured() {
let d = scratch("usage");
let (code, v) = run(&d, &["--frobnicate"]);
assert_eq!(code, 2);
assert_eq!((v["wai"].as_str(), v["op"].as_str(), v["ok"].as_bool(), v["error"]["code"].as_str()), (Some("envelope/1"), Some("agent.setup"), Some(false), Some("usage")), "{v}");
assert!(v["error"]["message"].as_str().unwrap().contains("--frobnicate") && v["error"]["hint"].is_string(), "{v}");
let (code, v) = run(&d, &["--only", "README.md"]);
assert_eq!((code, v["error"]["code"].as_str()), (2, Some("usage")), "{v}");
let (code, v) = run(&d.join("not-there"), &[]);
assert_eq!((code, v["error"]["code"].as_str()), (5, Some("not-found")), "{v}");
let _ = std::fs::remove_dir_all(&d);
}