WAI for coding agents: wai mcp, the room service's API in OpenAPI 3.1 held to the code, wai setup-agent #500
20 changed files with 7689 additions and 17 deletions
9
.github/workflows/joule-moq-room.yml
vendored
9
.github/workflows/joule-moq-room.yml
vendored
|
|
@ -26,7 +26,14 @@ 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: 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` with an
|
||||
# MCP client: 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
|
||||
|
|
|
|||
19
.github/workflows/wai-conformance.yml
vendored
19
.github/workflows/wai-conformance.yml
vendored
|
|
@ -596,6 +596,25 @@ 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 with each tool's schema and safety hints, and every tool. Its verifiers agree
|
||||
# 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; packaging writes only inside the
|
||||
# project directory and never through a link; run_conformance runs live_rooms_verify.py;
|
||||
# --read-only offers no tool that writes, serves or asks for a grant; the development room
|
||||
# tools refuse any service off this machine before connecting (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: the loopback guard and setup-agent's merges and
|
||||
# diff. tests/cli_setup_agent.rs runs `wai setup-agent` in scratch projects as an agent
|
||||
# does (no terminal, JSON out): a fresh project, a second run that changes nothing, a file
|
||||
# that exists changed only with --confirm (exit 4 and the diffs otherwise), a dry run, a
|
||||
# JSON file with comments left alone, and nothing written through a link out of the
|
||||
# project. 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
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,34 @@ 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` (wai-rs, the feature `mcp`)
|
||||
runs this mode for its development room 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. `tests/openapi_contract.rs` holds it to
|
||||
the code: 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 +293,12 @@ 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` with
|
||||
an MCP client: 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
|
||||
|
|
|
|||
3834
joule-code/joule-code-rs/crates/joule-moq-room/openapi.json
Normal file
3834
joule-code/joule-code-rs/crates/joule-moq-room/openapi.json
Normal file
File diff suppressed because it is too large
Load diff
434
joule-code/joule-code-rs/crates/joule-moq-room/src/dev.rs
Normal file
434
joule-code/joule-code-rs/crates/joule-moq-room/src/dev.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
//! End to end, as a coding agent works: an MCP client drives `wai mcp`, which runs this crate's
|
||||
//! development room (`joule-moq-room dev`) as a child, and through its tools
|
||||
//!
|
||||
//! - starts a relay and a room service on loopback, creating a room;
|
||||
//! - 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", "--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);
|
||||
|
||||
let room = a.ok("dev_room_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("dev_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("dev_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("dev_join", json!({"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("dev_grant", json!({"principal": "alice", "role": "viewer"}));
|
||||
assert_eq!(again["key"].as_str(), Some(keys[0].as_str()));
|
||||
|
||||
// The session, verified.
|
||||
let s = a.ok("dev_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("verify_room_session", args.clone());
|
||||
assert_eq!(v["outcome"], "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("verify_room_session", 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("dev_leave", json!({"principal": "alice"}));
|
||||
assert!(l["events"].as_array().unwrap().iter().any(|e| e == "leave"), "{l}");
|
||||
let s = a.ok("dev_room_session", json!({}));
|
||||
let v = a.ok("verify_room_session", json!({"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_room_stop", json!({}))["stopped"], true);
|
||||
assert!(a.call("dev_room_session", json!({})).is_err(), "no room once stopped");
|
||||
let _ = std::fs::remove_dir_all(&project);
|
||||
}
|
||||
|
|
@ -0,0 +1,621 @@
|
|||
//! The service's OpenAPI description (`openapi.json`) held to the code, in both directions:
|
||||
//!
|
||||
//! - **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);
|
||||
|
||||
/// Every operation of `openapi.json` and the statuses it lists.
|
||||
fn documented() -> BTreeMap<Op, BTreeSet<u16>> {
|
||||
let text = std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("openapi.json")).expect("openapi.json");
|
||||
let doc: Value = serde_json::from_str(&text).expect("openapi.json is JSON");
|
||||
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
|
||||
}
|
||||
|
||||
/// The functions that answer each operation: the router that matches it, and every function
|
||||
/// on its way that answers.
|
||||
type Handlers = &'static [(&'static str, &'static str)];
|
||||
|
||||
const HANDLERS: &[(&str, &str, Handlers)] = &[
|
||||
("GET", "/", &[("http.rs", "handle")]),
|
||||
("GET", "/health", &[("http.rs", "handle")]),
|
||||
("GET", "/room", &[("http.rs", "handle")]),
|
||||
("POST", "/grant", &[("http.rs", "handle"), ("http.rs", "grant"), ("remote.rs", "grant"), ("remote.rs", "bad"), ("call.rs", "handle")]),
|
||||
("GET", "/age/receipt", &[("http.rs", "age_receipt")]),
|
||||
("GET", "/mls", &[("http.rs", "mls_log")]),
|
||||
("POST", "/mls/key-package", &[("http.rs", "handle"), ("http.rs", "mls_body"), ("http.rs", "mls_key_package")]),
|
||||
("POST", "/mls/post", &[("http.rs", "handle"), ("http.rs", "mls_body"), ("http.rs", "mls_post")]),
|
||||
("GET", "/co/objects", &[("http.rs", "co_objects")]),
|
||||
("GET", "/co/state", &[("http.rs", "co_state")]),
|
||||
("POST", "/co/schedule", &[("http.rs", "co_request"), ("http.rs", "unauthorised"), ("http.rs", "signed_reply")]),
|
||||
("POST", "/co/handover", &[("http.rs", "co_request"), ("http.rs", "unauthorised"), ("http.rs", "signed_reply")]),
|
||||
("POST", "/co/window", &[("http.rs", "co_request"), ("http.rs", "unauthorised"), ("http.rs", "signed_reply")]),
|
||||
("POST", "/co/fidelity", &[("http.rs", "co_request"), ("http.rs", "unauthorised"), ("http.rs", "signed_reply")]),
|
||||
("GET", "/watch/time", &[("http.rs", "handle")]),
|
||||
("GET", "/watch/source", &[("http.rs", "watch_source")]),
|
||||
("GET", "/watch/challenge", &[("http.rs", "handle")]),
|
||||
("GET", "/watch", &[("http.rs", "watch_log")]),
|
||||
("GET", "/watch/room", &[("http.rs", "handle")]),
|
||||
("POST", "/watch/join", &[("http.rs", "handle"), ("http.rs", "watch_join")]),
|
||||
("POST", "/watch/post", &[("http.rs", "handle"), ("http.rs", "watch_post")]),
|
||||
("POST", "/remote/control", &[("remote.rs", "control"), ("http.rs", "unauthorised")]),
|
||||
("GET", "/remote/room", &[("remote.rs", "session"), ("remote.rs", "admit_reader"), ("http.rs", "unauthorised")]),
|
||||
("GET", "/remote/state", &[("remote.rs", "state"), ("remote.rs", "admit_reader"), ("http.rs", "unauthorised")]),
|
||||
("GET", "/msg", &[("messaging.rs", "handle")]),
|
||||
("GET", "/msg/challenge", &[("messaging.rs", "handle")]),
|
||||
("POST", "/msg/join", &[("messaging.rs", "handle"), ("messaging.rs", "join"), ("messaging.rs", "unauthorised")]),
|
||||
("GET", "/msg/log", &[("messaging.rs", "handle"), ("messaging.rs", "log"), ("messaging.rs", "unauthorised")]),
|
||||
("POST", "/msg/key-package", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]),
|
||||
("POST", "/msg/mls", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]),
|
||||
("POST", "/msg/post", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]),
|
||||
("POST", "/msg/ack", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]),
|
||||
("POST", "/msg/leave", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]),
|
||||
("POST", "/msg/remove", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]),
|
||||
("GET", "/call/challenge", &[("http.rs", "handle"), ("call.rs", "handle")]),
|
||||
("POST", "/call/place", &[("http.rs", "handle"), ("call.rs", "handle"), ("call.rs", "unauthorised"), ("call.rs", "body"), ("call.rs", "place")]),
|
||||
("POST", "/call/ring", &[("http.rs", "handle"), ("call.rs", "handle"), ("http.rs", "unauthorised"), ("call.rs", "body"), ("call.rs", "ring")]),
|
||||
("GET", "/inbox", &[("http.rs", "handle"), ("call.rs", "handle"), ("call.rs", "unauthorised"), ("call.rs", "inbox")]),
|
||||
("POST", "/call/answer", &[("http.rs", "handle"), ("call.rs", "handle"), ("call.rs", "unauthorised"), ("call.rs", "body"), ("call.rs", "answer_of")]),
|
||||
("GET", "/call", &[("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_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());
|
||||
}
|
||||
|
|
@ -6,6 +6,32 @@ 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`** (the feature `mcp`): WAI's abilities as tools for a coding assistant, a Model
|
||||
Context Protocol server (revision 2025-06-18) on standard input and output. Tools inspect an
|
||||
envelope or a package; list and describe registered capabilities; verify a delivery receipt, a
|
||||
claim (with the context the tool takes: the previous claim, the object, an OER receipt, a
|
||||
gap's object), a live-room session and a stream audit bundle, each answering with the
|
||||
verifier's verdict and reason code; package a media file under `wai wrap`'s rules, inside the
|
||||
project directory only; run a conformance corpus's independent verifier; and search the
|
||||
specification. Development room tools, marked development only, start a relay and a room
|
||||
service on loopback, mint grants for test principals (refusing any service whose address is
|
||||
not a loopback address, before connecting), join and publish as them, and read the session.
|
||||
Each tool carries its safety hints; `--read-only` offers only those that read or run a
|
||||
verifier. The tools' verdicts agree vector by vector with jwp-conformance, claim-conformance,
|
||||
live-conformance/rooms and live-conformance/audit (`tests/cli_mcp.rs`).
|
||||
- **`wai setup-agent`**: writes `AGENTS.md` and `CLAUDE.md` (which imports it) in a project and
|
||||
registers `wai mcp` 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, writes nothing
|
||||
outside the project directory, and answers in JSON off a terminal, exit 4 when existing files
|
||||
wait for confirmation (`tests/cli_setup_agent.rs`).
|
||||
- **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, held to the code by a contract
|
||||
test that matches 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.
|
||||
|
||||
### `wai.live.message` and `wai.live.call`: conversations and calls on live rooms
|
||||
|
||||
- **The extension** ([live-messaging](extensions/live-messaging.md)): a conversation is an
|
||||
|
|
|
|||
|
|
@ -68,6 +68,37 @@ 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, and `CLAUDE.md`, which imports it; and it
|
||||
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). 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 diff and the command that
|
||||
confirms. It writes nothing outside the project directory. Off a terminal its output is JSON.
|
||||
|
||||
`wai mcp`, in a build with the feature `mcp`, is a Model Context Protocol server on standard
|
||||
input and output. Its tools inspect an envelope or a package; list and describe the registered
|
||||
capabilities; verify delivery receipts, claims, live-room sessions and stream audit bundles,
|
||||
each answer the verifier's verdict and reason code; package a media file; run a conformance
|
||||
corpus's independent verifier; search the specification's text; and, marked development only,
|
||||
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
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
## SDK consumption
|
||||
|
||||
`wai-rs` exposes a stable C ABI in [`src/ffi.rs`](wai-rs/src/ffi.rs) —
|
||||
|
|
|
|||
|
|
@ -693,6 +693,12 @@ 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 abilities as tools for a coding agent, a Model Context Protocol server on
|
||||
# standard input and output (src/bin/wai_cli/mcp/). 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.
|
||||
|
|
@ -1468,6 +1474,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"
|
||||
|
|
|
|||
|
|
@ -58,6 +58,51 @@ mod http_vectors;
|
|||
#[path = "wai_cli/http_playlists.rs"]
|
||||
mod http_playlists;
|
||||
|
||||
// The commands for coding agents, each in a module of its own and reached through `COMMANDS`
|
||||
// below: `wai mcp` (a build with `mcp`) and `wai setup-agent`.
|
||||
#[cfg(feature = "mcp")]
|
||||
#[path = "wai_cli/mcp/mod.rs"]
|
||||
mod mcp;
|
||||
#[path = "wai_cli/setup_agent.rs"]
|
||||
mod setup_agent;
|
||||
|
||||
/// The name `wai mcp` is called by; `setup-agent` registers it under this name.
|
||||
const MCP_COMMAND: &str = "mcp";
|
||||
|
||||
/// A verb kept in a module of its own: the name it is called by, its arguments for the usage
|
||||
/// text, what it does, and the function that runs it.
|
||||
struct Verb {
|
||||
name: &'static str,
|
||||
args: &'static str,
|
||||
what: &'static str,
|
||||
run: fn(&[String]) -> ExitCode,
|
||||
}
|
||||
|
||||
/// The commands kept in modules of their own. A command's name is one line here.
|
||||
const COMMANDS: &[Verb] = &[
|
||||
Verb { name: MCP_COMMAND, args: "[--spec-root <wai-dir>] [--room-bin <path>] [--project <dir>] [--read-only]",
|
||||
what: "WAI's tools for a coding agent: a Model Context Protocol server on standard input and output (a build with `mcp`)", run: run_mcp },
|
||||
Verb { name: "setup-agent", args: "[--dir <project>] [--dry-run] [--confirm] [--json] [--all] [--only <file>,...] [--command <program>]",
|
||||
what: "write AGENTS.md and CLAUDE.md and register the MCP server in the project's agent configuration", run: setup_agent::main },
|
||||
];
|
||||
|
||||
fn run_mcp(args: &[String]) -> ExitCode {
|
||||
#[cfg(feature = "mcp")]
|
||||
{
|
||||
match mcp::main(args) {
|
||||
Ok(true) => ExitCode::SUCCESS,
|
||||
Ok(false) => ExitCode::from(1),
|
||||
Err(e) => die(e),
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
{
|
||||
let _ = args;
|
||||
die(format!("built without mcp: `wai {MCP_COMMAND}` needs a build with the feature `mcp` \
|
||||
(cargo install --path wai/wai-rs --no-default-features --features mcp)"))
|
||||
}
|
||||
}
|
||||
|
||||
fn die(msg: impl std::fmt::Display) -> ExitCode {
|
||||
eprintln!("error: {msg}");
|
||||
ExitCode::from(2)
|
||||
|
|
@ -90,6 +135,10 @@ fn usage() {
|
|||
platform_encode_cli::usage();
|
||||
#[cfg(not(feature = "platform_encode"))]
|
||||
eprintln!("wai encode avc|hevc …, wai encoders, wai encode-record verify … a build with `platform_encode`");
|
||||
for c in COMMANDS {
|
||||
eprintln!("wai {} {}", c.name, c.args);
|
||||
eprintln!(" {}", c.what);
|
||||
}
|
||||
eprintln!();
|
||||
eprintln!("encode and decode need a build with the `codecs` feature.");
|
||||
eprintln!("codecs: png jpeg avif jxl jxl-lossless opus flac av1 av1-lossless zstd xz");
|
||||
|
|
@ -590,7 +639,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`)"));
|
||||
}
|
||||
|
|
@ -650,10 +699,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
|
||||
|
|
@ -714,7 +778,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());
|
||||
|
|
@ -746,23 +810,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,
|
||||
|
|
@ -930,6 +1012,9 @@ fn main() -> ExitCode {
|
|||
usage();
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
if let Some(c) = COMMANDS.iter().find(|c| c.name == args[0]) {
|
||||
return (c.run)(&args[1..]);
|
||||
}
|
||||
let r = match args[0].as_str() {
|
||||
#[cfg(feature = "platform_encode")]
|
||||
"encode" if args.len() >= 3 && matches!(args[1].as_str(), "avc" | "hevc") => platform_encode_cli::encode(&args[1..]),
|
||||
|
|
@ -957,7 +1042,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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
323
wai/wai-rs/src/bin/wai_cli/mcp/dev_room.rs
Normal file
323
wai/wai-rs/src/bin/wai_cli/mcp/dev_room.rs
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
//! The development room tools: 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),
|
||||
//! grants minted for test principals, and those principals joining, publishing and leaving.
|
||||
//!
|
||||
//! **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::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`
|
||||
/// on `PATH`.
|
||||
fn room_bin(server: &Server, args: &Value) -> Result<PathBuf, String> {
|
||||
if let Some(p) = opt_str(args, "room_bin").map(PathBuf::from).or_else(|| server.room_bin.clone()) {
|
||||
return Ok(p);
|
||||
}
|
||||
let path = std::env::var_os("PATH").unwrap_or_default();
|
||||
std::env::split_paths(&path).map(|d| d.join("joule-moq-room")).find(|p| p.is_file()).ok_or_else(|| {
|
||||
"the development room runs the room service program joule-moq-room, which is not on PATH: build it from \
|
||||
joule-code/joule-code-rs/crates/joule-moq-room (cargo install --path .), then give room_bin, --room-bin or WAI_ROOM_BIN"
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
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> {
|
||||
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,
|
||||
"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> {
|
||||
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> {
|
||||
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, _: &Value) -> Result<Value, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
207
wai/wai-rs/src/bin/wai_cli/mcp/mod.rs
Normal file
207
wai/wai-rs/src/bin/wai_cli/mcp/mod.rs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
//! `wai mcp`: WAI's abilities 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.
|
||||
//!
|
||||
//! The tools (`tools.rs`, one table: a tool's name is one line there) inspect envelopes and
|
||||
//! packages, read the capability registry, verify receipts, claims, live-room sessions and
|
||||
//! audit bundles, package a media file, run a conformance corpus's independent verifier, search
|
||||
//! the specification's text, and drive a development room on loopback (`dev_room.rs`). Each
|
||||
//! answers with a structured result. Nothing leaves the machine unless the caller names a remote
|
||||
//! service, and the only tool that takes a service's address refuses every one that is not on
|
||||
//! this machine.
|
||||
//!
|
||||
//! ```text
|
||||
//! wai mcp [--spec-root <wai-dir>] [--room-bin <path>] [--project <dir>] [--read-only]
|
||||
//! ```
|
||||
//!
|
||||
//! `--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`, from `joule-code/joule-code-rs/crates/joule-moq-room`); by
|
||||
//! default the one 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. It names no tool: the tools' table is
|
||||
/// the one place their names are written.
|
||||
const INSTRUCTIONS: &str = "WAI's tools on this machine. Inspect a .wai envelope or a package directory; \
|
||||
list and describe 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; run a \
|
||||
conformance corpus's independent verifier; and search the specification's text, which is the authority \
|
||||
on every rule. The development room 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 refuse any service that is not on this machine. No tool sends \
|
||||
anything off the machine.";
|
||||
|
||||
/// 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) 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(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 { 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,
|
||||
}))
|
||||
}
|
||||
"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 {
|
||||
"usage: wai mcp [--spec-root <wai-dir>] [--room-bin <path>] [--project <dir>] [--read-only]".into()
|
||||
}
|
||||
|
||||
/// `wai mcp`: serve until standard input closes.
|
||||
pub fn main(args: &[String]) -> Result<bool, String> {
|
||||
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;
|
||||
}
|
||||
("--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;
|
||||
}
|
||||
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(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)
|
||||
}
|
||||
856
wai/wai-rs/src/bin/wai_cli/mcp/tools.rs
Normal file
856
wai/wai-rs/src/bin/wai_cli/mcp/tools.rs
Normal file
|
|
@ -0,0 +1,856 @@
|
|||
//! The tools `wai mcp` serves, in one table ([`TOOLS`]): a tool's name, title, one-line
|
||||
//! description, input schema, its safety hints and the function that runs it. Renaming a tool
|
||||
//! is one line of the table; nothing else names it.
|
||||
|
||||
use super::dev_room;
|
||||
use super::Server;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// 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 {
|
||||
pub name: &'static str,
|
||||
pub title: &'static str,
|
||||
pub description: &'static str,
|
||||
pub schema: fn() -> Value,
|
||||
pub effect: Effect,
|
||||
pub run: fn(&mut Server, &Value) -> Result<Value, String>,
|
||||
}
|
||||
|
||||
/// Every tool, in the order `tools/list` gives them.
|
||||
pub(crate) const TOOLS: &[Tool] = &[
|
||||
Tool { name: "inspect", title: "Inspect a WAI envelope or package", effect: Effect::Reads, schema: s_path, run: 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 { name: "list_capabilities", title: "List registered capabilities", effect: Effect::Reads, schema: s_list_caps, run: list_capabilities,
|
||||
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 { name: "describe_capability", title: "Describe one capability", effect: Effect::Reads, schema: s_cap, run: describe_capability,
|
||||
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 { name: "verify_receipt", title: "Verify a delivery receipt", effect: Effect::Reads, schema: s_receipt, run: verify_receipt,
|
||||
description: "Verify a JWP delivery receipt (jwp-receipts §2.2 to §3): a group receipt's signature and payload and its parent link (a group that names a parent needs the previous group given), or an object receipt against its group: the group's signature, the object's inclusion and, with its bytes, its content hash. The verdict, the step and the reason code." },
|
||||
Tool { name: "verify_claim", title: "Verify a claim receipt", effect: Effect::Reads, schema: s_claim, run: verify_claim,
|
||||
description: "Verify a typed claim receipt (jwp-receipts §2.4, §3.1) from its wire text, with the context given (the previous claim, the object, an OER receipt, a gap's object): the verdict, its class and link hash, or the step and reason code it is refused at." },
|
||||
Tool { name: "verify_room_session", title: "Verify a live-room session", effect: Effect::Reads, schema: s_room, run: verify_room_session,
|
||||
description: "Verify a live room as live-rooms §9 does, from its signed descriptor, its grants, the key-log head and every principal's event chain: accept, gap or refuse with the reason and where, the room id, who is present and the merged order's length." },
|
||||
Tool { name: "verify_audit_bundle", title: "Verify a stream audit bundle", effect: Effect::Reads, schema: s_bundle, run: verify_audit_bundle,
|
||||
description: "Verify a player's stream audit bundle (stream-audit §9, §10) for an owner and its channels: the session record, its acknowledgements and presentation claims; the outcome with its reason code." },
|
||||
Tool { name: "package_media", title: "Package a media file", effect: Effect::Writes, schema: s_package, run: package_media,
|
||||
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 { name: "list_conformance", title: "List conformance corpora", effect: Effect::Reads, schema: s_none, run: list_conformance,
|
||||
description: "The conformance corpora in the specification tree and the independent verifier that checks each, as CI runs them." },
|
||||
Tool { name: "run_conformance", title: "Run a conformance corpus", effect: Effect::Runs, schema: s_corpus, run: run_conformance,
|
||||
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 { name: "search_spec", title: "Search the specification", effect: Effect::Reads, schema: s_search, run: search_spec,
|
||||
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 { name: "dev_room_start", 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, creating a room; returns its service and relay addresses and the room id. Never for deployment." },
|
||||
Tool { name: "dev_grant", title: "Mint a development grant", effect: Effect::DevRoom, schema: s_dev_grant, run: dev_room::grant,
|
||||
description: "DEVELOPMENT ONLY. 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 { name: "dev_join", title: "Join and publish as a test principal", effect: Effect::DevRoom, schema: s_dev_join, run: dev_room::join,
|
||||
description: "DEVELOPMENT ONLY. 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 { name: "dev_leave", title: "Leave as a test principal", effect: Effect::DevRoom, schema: s_dev_principal, run: dev_room::leave,
|
||||
description: "DEVELOPMENT ONLY. Close a test principal's session in the development room and wait for the clock's leave." },
|
||||
Tool { name: "dev_room_session", title: "Read the development room's session", effect: Effect::DevRoom, schema: s_none, run: dev_room::session,
|
||||
description: "DEVELOPMENT ONLY. The development room as a verifier is given it: descriptor, grants, key-log head and the clock's event chain, ready for verify_room_session." },
|
||||
Tool { name: "dev_room_stop", 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." },
|
||||
];
|
||||
|
||||
/// The tools a server offers: all of them, or with `--read-only` those 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| !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,
|
||||
},
|
||||
})
|
||||
})
|
||||
.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>) -> &'static str {
|
||||
TOOLS.iter().find(|t| std::ptr::fn_addr_eq(t.run, run)).map_or("?", |t| t.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"])
|
||||
}
|
||||
fn s_receipt() -> Value {
|
||||
obj(
|
||||
json!({
|
||||
"receipt": {"description": "The receipt's JSON (object or text): a group receipt (kind group) or an object receipt (kind object)."},
|
||||
"group": {"description": "For an object receipt: its group receipt's JSON."},
|
||||
"previous": {"description": "For a group receipt: the previous group receipt's JSON, to check the parent link."},
|
||||
"envelope_hex": {"type": "string", "description": "For an object receipt: the object's bytes in hexadecimal, to check its content hash."},
|
||||
"envelope_path": {"type": "string", "description": "For an object receipt: a file holding the object's bytes."}
|
||||
}),
|
||||
&["receipt"],
|
||||
)
|
||||
}
|
||||
fn s_claim() -> Value {
|
||||
obj(
|
||||
json!({
|
||||
"claim": {"description": "The claim receipt's wire text (a string), or its JSON object."},
|
||||
"previous": {"type": "string", "description": "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."},
|
||||
"object": {"type": "object", "description": "For an energy claim: the delivery receipt's object, {content_hash, joules_micro} and its leaf's class where labelled."},
|
||||
"oer": {"type": "object", "description": "For an energy claim naming an OER receipt: {receipt, keys}."},
|
||||
"gap_object": {"type": "object", "description": "For a delivery-gap claim: the object receipt at its coordinates, {track_alias, group_id, object_id, content_hash}."}
|
||||
}),
|
||||
&["claim"],
|
||||
)
|
||||
}
|
||||
fn s_room() -> Value {
|
||||
obj(
|
||||
json!({
|
||||
"descriptor": {"type": "string", "description": "The signed WRD1 descriptor, hexadecimal."},
|
||||
"grants": {"type": "array", "items": {"type": "string"}, "description": "The WRG1 grants, hexadecimal, in order."},
|
||||
"key_log_head": {"type": ["string", "null"], "description": "The authority's key-log head (its JSON text), or null."},
|
||||
"chains": {"type": "array", "items": {"type": "array", "items": {"type": "string"}}, "description": "Each principal's WEV1 records in stream order, hexadecimal."}
|
||||
}),
|
||||
&["descriptor", "grants", "chains"],
|
||||
)
|
||||
}
|
||||
fn s_bundle() -> Value {
|
||||
obj(
|
||||
json!({
|
||||
"bundle": {"description": "The bundle's JSON (object or text)."},
|
||||
"owner": {"type": "string", "description": format!("The owner's key, {HEX64}.")},
|
||||
"channels": {"type": "array", "items": {"type": "string"}, "description": "The channels the owner serves."}
|
||||
}),
|
||||
&["bundle", "owner", "channels"],
|
||||
)
|
||||
}
|
||||
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 list_conformance 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"],
|
||||
)
|
||||
}
|
||||
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 or PATH."},
|
||||
"publishers": {"type": "integer", "minimum": 1, "maximum": 64, "description": "Publishers at once (default 4)."}
|
||||
}),
|
||||
&[],
|
||||
)
|
||||
}
|
||||
fn s_dev_grant() -> Value {
|
||||
obj(
|
||||
json!({
|
||||
"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_dev_join() -> Value {
|
||||
obj(
|
||||
json!({
|
||||
"principal": {"type": "string", "description": "A test principal granted the publisher role by the grant tool."},
|
||||
"objects": {"type": "integer", "minimum": 1, "maximum": 64, "description": "Objects on each track (default 1)."}
|
||||
}),
|
||||
&["principal"],
|
||||
)
|
||||
}
|
||||
fn s_dev_principal() -> Value {
|
||||
obj(json!({"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 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: Vec<&str> = 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}")) },
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn native_capabilities() -> Vec<&'static str> {
|
||||
#[cfg(feature = "codecs")]
|
||||
{
|
||||
wai::sink_capabilities().to_vec()
|
||||
}
|
||||
#[cfg(not(feature = "codecs"))]
|
||||
{
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 list_capabilities(_: &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 describe_capability(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(list_capabilities)));
|
||||
};
|
||||
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
|
||||
|
||||
fn verify_receipt(_: &mut Server, args: &Value) -> Result<Value, String> {
|
||||
use wai::jwp_delivery::{GroupReceipt, ObjectReceipt};
|
||||
let r = json_arg(args, "receipt")?;
|
||||
let refused = |kind: &str, e: wai::jwp_delivery::DeliveryError| json!({"kind": kind, "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!({"kind": "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!({"kind": "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(_: &mut Server, 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(_: &mut Server, 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(_: &mut Server, 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_media(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 list_conformance(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 run_conformance(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(list_conformance))),
|
||||
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 search_spec(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}))
|
||||
}
|
||||
|
||||
468
wai/wai-rs/src/bin/wai_cli/setup_agent.rs
Normal file
468
wai/wai-rs/src/bin/wai_cli/setup_agent.rs
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
//! `wai setup-agent`: make a project ready for coding agents to use WAI. In the project
|
||||
//! directory it writes the instructions agents read (`AGENTS.md`, and `CLAUDE.md` beside it,
|
||||
//! which imports it) 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>...]]
|
||||
//! [--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.
|
||||
//! - Output is text on a terminal, JSON otherwise or with `--json`.
|
||||
//!
|
||||
//! Exit status: 0 done (or a dry run); 1 a file could not be read or written; 2 a usage error;
|
||||
//! 4 changes to existing files wait for confirmation.
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::io::{BufRead, IsTerminal, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
/// The exit status when existing files would change and nothing confirmed it.
|
||||
const CONFIRMATION_REQUIRED: u8 = 4;
|
||||
|
||||
/// 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>>,
|
||||
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>...]] [--command <program>]"
|
||||
}
|
||||
|
||||
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, 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;
|
||||
}
|
||||
("--command", Some(v)) => {
|
||||
o.command = v.clone();
|
||||
i += 1;
|
||||
}
|
||||
(a, _) => return Err(format!("{a}: not an option here. {}", usage())),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// AGENTS.md's block: what WAI is and how an agent uses it here.
|
||||
fn agents_block(command: &str) -> String {
|
||||
let mcp = crate::MCP_COMMAND;
|
||||
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 (`{command} {mcp}`, registered in this project's MCP configuration) gives you WAI's abilities as \
|
||||
tools: inspect envelopes and packages, read the capability registry, verify receipts, claims, live-room sessions and \
|
||||
audit bundles, package media, run conformance corpora, search the specification, and run 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 room 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\
|
||||
- Without the MCP server the same abilities are on the command line: `{command} inspect`, `{command} capabilities`, \
|
||||
`{command} wrap`, `{command} registry export --check`.\n\
|
||||
{END}\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// 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) -> Value {
|
||||
let mcp = crate::MCP_COMMAND;
|
||||
match form {
|
||||
Form::McpServers | Form::McpServersAndContext => json!({"command": command, "args": [mcp]}),
|
||||
Form::Servers => json!({"type": "stdio", "command": command, "args": [mcp]}),
|
||||
Form::McpLocal => json!({"type": "local", "command": [command, mcp], "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) -> 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)}})))?,
|
||||
};
|
||||
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));
|
||||
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)
|
||||
}
|
||||
|
||||
fn read(p: &Path) -> Result<Option<String>, String> {
|
||||
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(format!("{}: {e}", p.display())),
|
||||
}
|
||||
}
|
||||
|
||||
fn plans(o: &Opts, project: &Path) -> Result<Vec<Plan>, String> {
|
||||
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<(), String> {
|
||||
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(())
|
||||
};
|
||||
add("AGENTS.md", "the instructions coding agents read", &|old| Ok(with_block(old, &agents_block(&o.command))))?;
|
||||
add("CLAUDE.md", "the instructions file some agents read first; 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) = (t.form, o.command.clone());
|
||||
add(t.path, t.what, &move |old| with_server(old, form, &command))?;
|
||||
}
|
||||
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(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<(), String> {
|
||||
let p = checked(project, path)?;
|
||||
if let Some(dir) = p.parent()
|
||||
&& !dir.exists()
|
||||
{
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
// Created inside the project: check again that it resolves there.
|
||||
checked(project, path)?;
|
||||
}
|
||||
std::fs::write(&p, text).map_err(|e| format!("{}: {e}", p.display()))
|
||||
}
|
||||
|
||||
/// `wai setup-agent`.
|
||||
pub fn main(args: &[String]) -> ExitCode {
|
||||
let json_out = args.iter().any(|a| a == "--json") || !std::io::stdout().is_terminal();
|
||||
let fail = |code: u8, message: String, hint: &str| {
|
||||
if json_out {
|
||||
println!("{}", json!({"ok": false, "error": {"message": message, "hint": hint}}));
|
||||
} else {
|
||||
eprintln!("error: {message}\nhint: {hint}");
|
||||
}
|
||||
ExitCode::from(code)
|
||||
};
|
||||
let o = match parse(args) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return fail(2, e, "wai setup-agent --dry-run shows what it would write"),
|
||||
};
|
||||
let project = match o.dir.canonicalize() {
|
||||
Ok(p) if p.is_dir() => p,
|
||||
Ok(p) => return fail(2, format!("{}: not a directory", p.display()), "give the project's directory with --dir"),
|
||||
Err(e) => return fail(2, format!("{}: {e}", o.dir.display()), "give the project's directory with --dir"),
|
||||
};
|
||||
let plans = match plans(&o, &project) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return fail(1, e, "check the files' permissions, or pass --only to leave a file out"),
|
||||
};
|
||||
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 waiting = false;
|
||||
let mut failed = false;
|
||||
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) => {
|
||||
failed = true;
|
||||
f.insert("reason".into(), json!(e));
|
||||
"failed"
|
||||
}
|
||||
}
|
||||
} else if interactive {
|
||||
"declined"
|
||||
} else {
|
||||
waiting = true;
|
||||
"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() + if o.all { " --all" } else { "" });
|
||||
let code = if failed { 1 } else if waiting { CONFIRMATION_REQUIRED } else { 0 };
|
||||
if json_out {
|
||||
let mut v = json!({"ok": code == 0, "project": project.display().to_string(), "dry_run": o.dry_run, "files": files, "exit": code});
|
||||
if waiting {
|
||||
v["confirm_command"] = json!(confirm);
|
||||
v["hint"] = json!("existing files would change: read each diff, then run confirm_command to apply them");
|
||||
}
|
||||
println!("{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}");
|
||||
}
|
||||
}
|
||||
ExitCode::from(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 v: Value = serde_json::from_str(&with_server(Some(t), Form::McpServers, "wai").unwrap()).unwrap();
|
||||
assert_eq!(v["mcpServers"]["other"]["command"], "x");
|
||||
assert_eq!(v["mcpServers"]["wai"]["args"][0], crate::MCP_COMMAND);
|
||||
assert_eq!(v["z"], 1);
|
||||
assert!(with_server(Some("{ // a comment\n}"), Form::Servers, "wai").is_err());
|
||||
let g: Value = serde_json::from_str(&with_server(Some(r#"{"context": {"fileName": "GEMINI.md"}}"#), Form::McpServersAndContext, "wai").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}");
|
||||
}
|
||||
}
|
||||
382
wai/wai-rs/tests/cli_mcp.rs
Normal file
382
wai/wai-rs/tests/cli_mcp.rs
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
//! `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`.
|
||||
//!
|
||||
//! - The verifiers agree with the committed corpora vector by vector: every group and object
|
||||
//! vector of jwp-conformance (`verify_receipt`), every claim vector whose context is one the
|
||||
//! tool takes (none, the previous claim, the object, an OER receipt, a gap's object;
|
||||
//! `verify_claim`), every room of live-conformance/rooms
|
||||
//! (`verify_room_session`) and every session of live-conformance/audit
|
||||
//! (`verify_audit_bundle`); a receipt changed by one character is refused.
|
||||
//! - `package_media` writes an envelope inside the project directory only, never over a file
|
||||
//! without `overwrite`; `inspect` reads it back, and the directory as a package.
|
||||
//! - `run_conformance` runs a corpus's independent verifier and reports it passed.
|
||||
//! - The development room tools refuse a service that is not on a loopback address, before any
|
||||
//! connection; 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()
|
||||
}
|
||||
|
||||
const TOOLS: &[&str] = &[
|
||||
"inspect", "list_capabilities", "describe_capability", "verify_receipt", "verify_claim", "verify_room_session", "verify_audit_bundle",
|
||||
"package_media", "list_conformance", "run_conformance", "search_spec",
|
||||
"dev_room_start", "dev_grant", "dev_join", "dev_leave", "dev_room_session", "dev_room_stop",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn the_handshake_and_the_tool_list() {
|
||||
let d = scratch("handshake");
|
||||
let mut c = Client::start(&d, &[]);
|
||||
let list = c.request("tools/list", json!({}));
|
||||
let tools = list["result"]["tools"].as_array().unwrap();
|
||||
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, TOOLS);
|
||||
for t in tools {
|
||||
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}");
|
||||
if t["name"].as_str().unwrap().starts_with("dev_") {
|
||||
assert!(d.starts_with("DEVELOPMENT ONLY."), "{t}: marked development-only");
|
||||
}
|
||||
}
|
||||
// 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, &["--read-only"]);
|
||||
let list = c.request("tools/list", json!({}));
|
||||
let names: Vec<String> = list["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect();
|
||||
for gone in ["package_media", "dev_room_start", "dev_grant", "dev_join", "dev_leave", "dev_room_session", "dev_room_stop"] {
|
||||
assert!(!names.iter().any(|n| n == gone), "{gone} is offered read-only");
|
||||
}
|
||||
assert!(names.iter().any(|n| n == "verify_receipt"));
|
||||
let r = c.request("tools/call", json!({"name": "package_media", "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("list_capabilities", json!({}));
|
||||
let n = all["count"].as_u64().unwrap();
|
||||
assert!(n > 50 && all["capabilities"].as_array().unwrap().len() as u64 == n);
|
||||
let rf = c.ok("list_capabilities", 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("describe_capability", 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}");
|
||||
assert!(c.call("describe_capability", json!({"capability": "wai.image.nothing"})).unwrap_err().contains("not in the registry"));
|
||||
let s = c.ok("search_spec", 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("search_spec", 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_media", 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_media", json!({"input": input, "capability": "wai.text.zstd", "intent": "improve"})).unwrap_err().contains("exists"));
|
||||
c.ok("package_media", 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_media", 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_media", json!({"input": input, "capability": "wai.text.zstd", "output": "../escape.wai"})).is_err());
|
||||
assert!(c.call("package_media", 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_media", 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("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("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("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("verify_receipt", args);
|
||||
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("verify_receipt", json!({"receipt": bad}));
|
||||
assert_eq!((r["verdict"].as_str(), r["reason"].as_str()), (Some("refuse"), Some("signature_verify_failed")), "{r}");
|
||||
let r = c.ok("verify_receipt", json!({"receipt": good["input"]["group"].to_string()}));
|
||||
assert_eq!(r["verdict"], "accept", "a receipt given as its text: {r}");
|
||||
assert!(c.call("verify_receipt", json!({"receipt": {"kind": "elsewhere"}})).is_err());
|
||||
}
|
||||
|
||||
#[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("verify_claim", args);
|
||||
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("verify_room_session", json!({"descriptor": v["descriptor"], "grants": v["grants"], "key_log_head": v["key_log_head"], "chains": v["chains"]}));
|
||||
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("verify_audit_bundle", json!({"bundle": s["bundle"], "owner": doc["owner"], "channels": doc["channels"]}));
|
||||
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("list_conformance", 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("run_conformance", json!({"corpus": "live-conformance/rooms", "timeout_s": 600}));
|
||||
assert_eq!(r["passed"], true, "{r}");
|
||||
assert_eq!(r["exit_code"], 0);
|
||||
assert!(c.call("run_conformance", json!({"corpus": "no-such-conformance"})).unwrap_err().contains("no verifier"));
|
||||
let e = c.call("run_conformance", 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, &[]);
|
||||
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("dev_grant", json!({"principal": "alice", "role": "publisher", "service": remote})).unwrap_err();
|
||||
assert!(e.starts_with("DEVELOPMENT ONLY"), "{remote}: {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("dev_grant", json!({"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 ["dev_join", "dev_leave"] {
|
||||
assert!(c.call(t, json!({"principal": "alice"})).is_err());
|
||||
}
|
||||
assert!(c.call("dev_room_session", json!({})).unwrap_err().contains("dev_room_start"));
|
||||
assert!(c.call("dev_room_stop", json!({})).is_err());
|
||||
assert!(c.call("dev_grant", json!({"principal": "alice", "role": "publisher"})).unwrap_err().contains("dev_room_start"));
|
||||
let e = c.call("dev_room_start", json!({"room_bin": d.join("no-such-program")})).unwrap_err();
|
||||
assert!(e.contains("no-such-program"), "{e}");
|
||||
}
|
||||
155
wai/wai-rs/tests/cli_setup_agent.rs
Normal file
155
wai/wai-rs/tests/cli_setup_agent.rs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
//! `wai setup-agent` in a scratch project, run as an agent runs it (no terminal, so its output
|
||||
//! is JSON and it asks nothing):
|
||||
//!
|
||||
//! - a fresh project gets AGENTS.md, CLAUDE.md (which imports it) and `.mcp.json`, and the
|
||||
//! configuration files of the agents whose directories it holds; a second run changes nothing;
|
||||
//! - a file that exists is changed only with `--confirm`: without it nothing that exists is
|
||||
//! written, the command exits 4 and lists each diff and the command that confirms; with it the
|
||||
//! block is added and everything else in the file is kept; `--dry-run` writes nothing;
|
||||
//! - 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.
|
||||
|
||||
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 action(v: &Value, path: &str) -> String {
|
||||
v["files"].as_array().unwrap().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}");
|
||||
for p in ["AGENTS.md", "CLAUDE.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");
|
||||
let agents = read(d.join("AGENTS.md"));
|
||||
assert!(agents.contains("## WAI") && agents.contains("wai mcp"), "{agents}");
|
||||
assert!(read(d.join("CLAUDE.md")).lines().any(|l| l == "@AGENTS.md"), "CLAUDE.md imports AGENTS.md");
|
||||
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!(v["files"].as_array().unwrap().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";
|
||||
std::fs::write(d.join("AGENTS.md"), mine).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_create".into()));
|
||||
assert!(!d.join("CLAUDE.md").exists());
|
||||
assert_eq!(read(d.join("AGENTS.md")), mine);
|
||||
// Without --confirm: what is new is created, what exists waits, and the command says how to confirm.
|
||||
let (code, v) = run(&d, &[]);
|
||||
assert_eq!(code, 4, "confirmation required: {v}");
|
||||
assert_eq!(action(&v, "AGENTS.md"), "needs_confirmation");
|
||||
assert_eq!(action(&v, ".mcp.json"), "needs_confirmation");
|
||||
assert_eq!(action(&v, "CLAUDE.md"), "created");
|
||||
assert_eq!(read(d.join("AGENTS.md")), mine, "nothing that exists was changed");
|
||||
let diff = v["files"].as_array().unwrap().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_command"].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 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 = v["files"].as_array().unwrap().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 a_usage_error_is_structured() {
|
||||
let d = scratch("usage");
|
||||
let (code, v) = run(&d, &["--frobnicate"]);
|
||||
assert_eq!(code, 2);
|
||||
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, 1, "{v}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
Loading…
Reference in a new issue