WAI for coding agents on the toolchain's names: wai mcp (toolsets core and dev, tools named by their operations), the room service's API with catalogue operationIds, wai setup-agent #505
24 changed files with 8126 additions and 39 deletions
11
.github/workflows/joule-moq-room.yml
vendored
11
.github/workflows/joule-moq-room.yml
vendored
|
|
@ -26,7 +26,16 @@ name: joule-moq-room room service
|
|||
# members keying through real MLS groups, messaging across a restart of the service from its
|
||||
# history store, a thread, reactions, an edit and a retraction, a removed member served nothing
|
||||
# past its removal, every record verified offline; and a call (tests/call_room.rs) rung, accepted
|
||||
# and carrying audio and video through the relay, and another missed.
|
||||
# and carrying audio and video through the relay, and another missed. tests/openapi_contract.rs
|
||||
# holds openapi.json to the service: every operationId is the operation's name in the toolchain's
|
||||
# catalogue (noun.verb, by wai::ops's rule), the routes the routers match are exactly its
|
||||
# operations, each status it lists is one its handlers return and each status they return is
|
||||
# listed, and the service, run in each of its modes, answers every operation with a status it
|
||||
# lists. tests/mcp_dev_room.rs builds `wai` with the feature `mcp` (a target directory of its own)
|
||||
# and drives `wai mcp --toolset core,dev` with an MCP client: the tool room_grant and POST /grant
|
||||
# named alike (room.grant), a development room (`joule-moq-room dev`) started on loopback, grants
|
||||
# for two test principals, each joining and publishing, the session verified as live-rooms §9
|
||||
# verifies a room, a changed record refused, and a grant from a service off the machine refused.
|
||||
#
|
||||
# It builds on wai-rs's room, event, composition, co-watch and remote desktop records, on the
|
||||
# relay, on joule-moq-mls and on the remote desktop host agent, so a change to any of them runs
|
||||
|
|
|
|||
25
.github/workflows/wai-conformance.yml
vendored
25
.github/workflows/wai-conformance.yml
vendored
|
|
@ -612,6 +612,31 @@ jobs:
|
|||
run: |
|
||||
cargo test --no-default-features --features http,video_manifest,key_discovery,cli_tls --bin wai --test cli_http --test dash_read_bounds
|
||||
cargo test --no-default-features --features http,video_manifest,key_discovery --test cli_http -- receipts_never_anchors_over_plaintext
|
||||
# The wai commands for coding agents. tests/cli_mcp.rs starts `wai mcp` (the feature `mcp`)
|
||||
# and drives it with an MCP client over its standard input and output: the handshake, the
|
||||
# tool list (every tool an operation of the catalogue named by it, `core` by default and
|
||||
# `dev` when asked, an unknown toolset refused) with each tool's schema and safety hints,
|
||||
# and every tool. receipt_verify agrees vector by vector with jwp-conformance (group and
|
||||
# object receipts), claim-conformance (each claim with the context the tool takes),
|
||||
# live-conformance/rooms and live-conformance/audit, and a receipt changed by one character
|
||||
# is refused; package_build writes only inside the project directory and never through a
|
||||
# link; conformance_run runs live_rooms_verify.py; --read-only offers no tool that writes,
|
||||
# serves or asks for a grant; the dev tools refuse any service off this machine before
|
||||
# connecting, and any target but local (the room itself, joined, published to and verified
|
||||
# through the tools, is joule-moq-room's tests/mcp_dev_room.rs). `--bin wai` runs the
|
||||
# binary's unit tests: every tool's operation named by the rule, the loopback guard and
|
||||
# setup-agent's merges and diff; `--lib ops::` the rule itself. tests/cli_setup_agent.rs
|
||||
# runs `wai setup-agent` in scratch projects as an agent does (no terminal, the JSON
|
||||
# envelope out): a fresh project, a second run that changes nothing, a CLAUDE.md made only
|
||||
# where one is, a file that exists changed only with --confirm (exit 4 and the proposed
|
||||
# changes otherwise), a dry run, --toolset registered, a JSON file with comments left
|
||||
# alone, nothing written through a link out of the project, and the table's exit statuses.
|
||||
# Cargo.toml declares cli_mcp with required-features = ["mcp"].
|
||||
- name: The wai commands for coding agents (mcp, setup-agent)
|
||||
working-directory: wai/wai-rs
|
||||
run: |
|
||||
cargo test --no-default-features --features mcp --bin wai --test cli_mcp --test cli_setup_agent
|
||||
cargo test --no-default-features --lib ops::
|
||||
# tests/staged_moq.rs runs the MoQ trace of staged-conformance/moq/ (moq-streaming-format
|
||||
# §4.1) with signed receipts: a chain for each track, a receipt held for its parent and
|
||||
# admitted when it arrives, the relay's per-hop delivery timeout, dropped objects checked
|
||||
|
|
|
|||
|
|
@ -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,37 @@ prefix = ["*", "*", "video"]
|
|||
trust_field = 1
|
||||
```
|
||||
|
||||
### A development room
|
||||
|
||||
`joule-moq-room dev` (`src/dev.rs`) runs a relay and the service together in one process, for
|
||||
a developer's machine and for tests, and never for anyone else. It binds 127.0.0.1 alone, on
|
||||
ports the system picks; its keys are made at start and kept only in the process; its relay
|
||||
holds media tracks to no receipts, so a test principal publishes plain objects; and its rate
|
||||
limits are open. It says so on standard error when it starts. Its first line on standard output
|
||||
is `{"ready": true, "development_only": true, "service", "relay", "room", "authority", "clock"}`,
|
||||
and it answers JSON control lines on standard input, each with an `op` and an optional `id`:
|
||||
|
||||
| `op` | Fields | Answer |
|
||||
|---|---|---|
|
||||
| `join` | `seed` (a test principal's Ed25519 seed, 64 hex), `grant` (a publisher's grant for that key, hex), `objects` (per track, 1 to 64) | the principal enters with its grant and a proof, publishes `audio` and `video` with that many objects on each, and the answer comes once the clock has signed its `join` and both `publish`es: `{"principal", "room", "tracks", "objects_published", "objects_served", "events"}` |
|
||||
| `leave` | `principal` (64 hex) | its session closes; the answer comes once the clock has signed its `leave` |
|
||||
| `session` | | the room as live-rooms §9 gives it to a verifier: `{"room", "t0_us", "descriptor", "grants", "key_log_head": null, "chains"}` |
|
||||
| `stop` | | the room stops and the process exits; so it does when standard input closes |
|
||||
|
||||
Grants come from the service itself (`POST /grant`). `wai mcp --toolset dev` (wai-rs, the
|
||||
feature `mcp`) runs this mode for its development tools.
|
||||
|
||||
## HTTP
|
||||
|
||||
[`openapi.json`](openapi.json) describes this API (OpenAPI 3.1): every path, its requests,
|
||||
answers and statuses, in every mode the service keeps. Each operation's `operationId` is its
|
||||
name in the WAI toolchain's catalogue, `noun.verb` (`room.show` is `GET /room`, `room.grant` is
|
||||
`POST /grant`), the name the other doors derive theirs from (`wai mcp`'s tool `room_grant`).
|
||||
`tests/openapi_contract.rs` holds it to the code: every operation is named as the catalogue
|
||||
names it, the routes the routers match are exactly its operations, every status it lists for an
|
||||
operation is one that operation's handlers return and every status they return is listed, and
|
||||
the service, run in each mode, answers every operation with a status it lists.
|
||||
|
||||
| Request | Answer |
|
||||
|---|---|
|
||||
| `GET /`, `GET /health` | `{"ok", "relay": the clock's session is open, "room"}` |
|
||||
|
|
@ -265,6 +296,13 @@ descriptor, its digest and the caller's grant, `409` `busy` while a call has its
|
|||
|
||||
## Tests
|
||||
|
||||
`tests/openapi_contract.rs` holds `openapi.json` to the service (above). `tests/mcp_dev_room.rs`
|
||||
builds `wai` with the feature `mcp`, in a target directory of its own, and drives
|
||||
`wai mcp --toolset core,dev` with an MCP client: the tool `room_grant` and the API's `POST /grant`
|
||||
named alike (`room.grant`), a development room started on loopback, grants for two test
|
||||
principals, each joining and publishing, the session verified as live-rooms §9 verifies a room,
|
||||
a changed record refused, a leave verified, and a grant from a service off the machine refused.
|
||||
|
||||
`cargo test` (this crate is a workspace of its own, with its own lock file, since it builds
|
||||
on `wai-rs`): the keys file, the grants checked against the descriptor and read by the
|
||||
relay's reader, the places, the limiter, and `tests/room_service.rs`, the service against a
|
||||
|
|
|
|||
3838
joule-code/joule-code-rs/crates/joule-moq-room/openapi.json
Normal file
3838
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,180 @@
|
|||
//! End to end, as a coding agent works: an MCP client drives `wai mcp --toolset core,dev`, which
|
||||
//! runs this crate's development room (`joule-moq-room dev`) as a child, and through its tools
|
||||
//!
|
||||
//! - finds the one operation the two doors share named alike: the tool `room_grant` is the
|
||||
//! operation `room.grant`, which `openapi.json` describes as the service's `POST /grant`;
|
||||
//! - starts a relay and a room service on loopback, with its room (`dev_start`);
|
||||
//! - mints publishers' grants for two test principals, and has each join the room with its grant
|
||||
//! and a proof of its key and publish audio and video;
|
||||
//! - reads the room's session (its descriptor, grants and the clock's chain) and verifies it as
|
||||
//! live-rooms §9 verifies a room: accepted, both principals present; a record changed by one
|
||||
//! byte is refused;
|
||||
//! - has one leave, and verifies the session again: accepted, one present;
|
||||
//! - and is refused a grant from a service that is not on this machine.
|
||||
//!
|
||||
//! The `wai` binary is built here, from `wai/wai-rs` with the feature `mcp`, into a target
|
||||
//! directory of its own: the two workspaces build `wai` with different features, and one target
|
||||
//! directory for both mixes their artifacts.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||||
|
||||
fn wai_bin() -> PathBuf {
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../../wai/wai-rs/Cargo.toml");
|
||||
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("wai-mcp");
|
||||
let cargo = std::env::var("CARGO").ok().or(option_env!("CARGO").map(String::from)).unwrap_or_else(|| "cargo".into());
|
||||
let status = Command::new(cargo)
|
||||
.args(["build", "--locked", "--no-default-features", "--features", "mcp", "--bin", "wai", "--manifest-path"])
|
||||
.arg(&manifest)
|
||||
.env("CARGO_TARGET_DIR", &target)
|
||||
.status()
|
||||
.expect("cargo runs");
|
||||
assert!(status.success(), "the wai binary builds with the feature mcp");
|
||||
target.join("debug").join(if cfg!(windows) { "wai.exe" } else { "wai" })
|
||||
}
|
||||
|
||||
struct Agent {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
out: BufReader<ChildStdout>,
|
||||
next: i64,
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
fn start(wai: &Path, project: &Path) -> Agent {
|
||||
let mut child = Command::new(wai)
|
||||
.args(["mcp", "--toolset", "core,dev", "--project"])
|
||||
.arg(project)
|
||||
.arg("--room-bin")
|
||||
.arg(env!("CARGO_BIN_EXE_joule-moq-room"))
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.expect("wai mcp starts");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let out = BufReader::new(child.stdout.take().unwrap());
|
||||
let mut a = Agent { child, stdin, out, next: 1 };
|
||||
let init = a.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "mcp_dev_room test", "version": "1"}}));
|
||||
assert_eq!(init["result"]["serverInfo"]["name"], "wai", "{init}");
|
||||
writeln!(a.stdin, "{}", json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).unwrap();
|
||||
a
|
||||
}
|
||||
|
||||
fn request(&mut self, method: &str, params: Value) -> Value {
|
||||
let id = self.next;
|
||||
self.next += 1;
|
||||
writeln!(self.stdin, "{}", json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
let mut l = String::new();
|
||||
assert!(self.out.read_line(&mut l).unwrap() > 0, "wai mcp closed its output");
|
||||
let v: Value = serde_json::from_str(&l).expect("a JSON-RPC line");
|
||||
assert_eq!(v["id"], json!(id));
|
||||
v
|
||||
}
|
||||
|
||||
fn call(&mut self, name: &str, args: Value) -> Result<Value, String> {
|
||||
let v = self.request("tools/call", json!({"name": name, "arguments": args}));
|
||||
let r = &v["result"];
|
||||
if r["isError"] == json!(true) {
|
||||
return Err(r["content"][0]["text"].as_str().unwrap_or("").to_string());
|
||||
}
|
||||
Ok(r["structuredContent"].clone())
|
||||
}
|
||||
|
||||
fn ok(&mut self, name: &str, args: Value) -> Value {
|
||||
self.call(name, args.clone()).unwrap_or_else(|e| panic!("{name} {args}: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Agent {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_starts_a_room_joins_publishes_and_verifies_the_session() {
|
||||
let wai = wai_bin();
|
||||
let project = std::env::temp_dir().join(format!("joule-moq-room-mcp-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&project).unwrap();
|
||||
let mut a = Agent::start(&wai, &project);
|
||||
|
||||
// One name for one operation in both doors: the tool and the API's operationId.
|
||||
let tools = a.request("tools/list", json!({}));
|
||||
let ops: Vec<String> = tools["result"]["tools"].as_array().unwrap().iter().map(|t| t["_meta"]["wai/operation"].as_str().unwrap().to_string()).collect();
|
||||
let api: Value = serde_json::from_str(&std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("openapi.json")).unwrap()).unwrap();
|
||||
assert!(ops.iter().any(|o| o == "room.grant"), "{ops:?}");
|
||||
assert_eq!(api["paths"]["/grant"]["post"]["operationId"], "room.grant", "the route room_grant asks for is the operation room.grant");
|
||||
let api_ops: Vec<String> = api["paths"].as_object().unwrap().values().flat_map(|i| i.as_object().unwrap().values().filter_map(|o| o["operationId"].as_str().map(String::from))).collect();
|
||||
for op in &ops {
|
||||
if api_ops.contains(op) {
|
||||
assert_eq!(op, "room.grant", "{op}: a tool that is an API operation too is the grant alone");
|
||||
}
|
||||
}
|
||||
|
||||
let room = a.ok("dev_start", json!({"publishers": 4}));
|
||||
assert_eq!(room["development_only"], true, "{room}");
|
||||
let service = room["service"].as_str().unwrap().to_string();
|
||||
assert!(service.starts_with("http://127.0.0.1:"), "{service}");
|
||||
assert!(room["relay"].as_str().unwrap().starts_with("ws://127.0.0.1:"), "{room}");
|
||||
let id = room["room"].as_str().unwrap().to_string();
|
||||
|
||||
// A grant from a service off this machine is refused before anything is sent.
|
||||
let e = a.call("room_grant", json!({"principal": "alice", "role": "publisher", "service": "http://192.0.2.10:9682"})).unwrap_err();
|
||||
assert!(e.starts_with("DEVELOPMENT ONLY"), "{e}");
|
||||
|
||||
// Two test principals are granted, join and publish.
|
||||
let mut keys = Vec::new();
|
||||
for name in ["alice", "bob"] {
|
||||
let g = a.ok("room_grant", json!({"principal": name, "role": "publisher"}));
|
||||
assert_eq!((g["role"].as_str(), g["room"].as_str()), (Some("publisher"), Some(id.as_str())), "{g}");
|
||||
keys.push(g["key"].as_str().unwrap().to_string());
|
||||
let j = a.ok("room_join", json!({"target": "local", "principal": name, "objects": 2}));
|
||||
assert_eq!(j["events"], json!(["join", "publish", "publish"]), "{name}: the clock signed its join and publishes: {j}");
|
||||
assert_eq!(j["objects_published"], 4);
|
||||
assert!(j["objects_served"].as_u64().unwrap() >= 2, "the relay serves what it published: {j}");
|
||||
}
|
||||
// The same name is the same key: a second grant for alice names her key again.
|
||||
let again = a.ok("room_grant", json!({"principal": "alice", "role": "viewer"}));
|
||||
assert_eq!(again["key"].as_str(), Some(keys[0].as_str()));
|
||||
|
||||
// The session, verified.
|
||||
let s = a.ok("room_session", json!({}));
|
||||
assert_eq!(s["room"].as_str(), Some(id.as_str()));
|
||||
let args = json!({"descriptor": s["descriptor"], "grants": s["grants"], "key_log_head": s["key_log_head"], "chains": s["chains"]});
|
||||
let v = a.ok("receipt_verify", args.clone());
|
||||
assert_eq!((v["kind"].as_str(), v["outcome"].as_str()), (Some("room_session"), Some("accept")), "{v}");
|
||||
assert_eq!(v["room"].as_str(), Some(id.as_str()));
|
||||
let mut present: Vec<String> = v["present"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect();
|
||||
present.sort();
|
||||
let mut want = keys.clone();
|
||||
want.sort();
|
||||
assert_eq!(present, want, "both are present");
|
||||
assert_eq!(v["records_in_order"], 6, "a join and two publishes each");
|
||||
|
||||
// A record changed by one byte: refused, at that record.
|
||||
let mut bad = args.clone();
|
||||
let rec = bad["chains"][0][1].as_str().unwrap().to_string();
|
||||
let flipped = format!("{}{}", &rec[..rec.len() - 2], if rec.ends_with("00") { "01" } else { "00" });
|
||||
bad["chains"][0][1] = json!(flipped);
|
||||
let r = a.ok("receipt_verify", bad);
|
||||
assert_eq!(r["outcome"], "refuse", "{r}");
|
||||
assert_eq!(r["at"], json!(["record", 0, 1]), "{r}");
|
||||
|
||||
// Alice leaves; the session verifies with Bob alone present.
|
||||
let l = a.ok("room_leave", json!({"principal": "alice"}));
|
||||
assert!(l["events"].as_array().unwrap().iter().any(|e| e == "leave"), "{l}");
|
||||
let s = a.ok("room_session", json!({}));
|
||||
let v = a.ok("receipt_verify", json!({"kind": "room_session", "descriptor": s["descriptor"], "grants": s["grants"], "key_log_head": s["key_log_head"], "chains": s["chains"]}));
|
||||
assert_eq!(v["outcome"], "accept", "{v}");
|
||||
assert_eq!(v["present"], json!([keys[1]]), "{v}");
|
||||
assert_eq!(v["records_in_order"], 7);
|
||||
|
||||
assert_eq!(a.ok("dev_stop", json!({}))["stopped"], true);
|
||||
assert!(a.call("room_session", json!({})).is_err(), "no room once stopped");
|
||||
let _ = std::fs::remove_dir_all(&project);
|
||||
}
|
||||
|
|
@ -0,0 +1,644 @@
|
|||
//! The service's OpenAPI description (`openapi.json`) held to the code, in both directions:
|
||||
//!
|
||||
//! - **Names.** Every operation's `operationId` is its name in the toolchain's catalogue, the
|
||||
//! one `HANDLERS` gives it: `noun.verb` by the rule every door keeps (`wai::ops::check_id`),
|
||||
//! each name once.
|
||||
//! - **Routes.** The (method, path) pairs the routers match (`src/http.rs`,
|
||||
//! `src/messaging.rs`, `src/call.rs`) are exactly the description's operations.
|
||||
//! - **Statuses, from the source.** Each operation names the functions that answer it below
|
||||
//! (`HANDLERS`). Every status the description lists for an operation is one those functions
|
||||
//! return, and every status a function returns is listed for an operation it answers (405,
|
||||
//! the answer to a method a path does not take, is stated once in the description).
|
||||
//! - **Statuses, from the running service.** The service runs on loopback beside a relay, in
|
||||
//! each of its modes (a plain room, end-to-end encryption, admission by age, co-performance,
|
||||
//! a watch room, a remote desktop room, a conversation, calls, and without its relay), and
|
||||
//! every documented operation is asked at least once in the mode that serves it: every
|
||||
//! answer's status is one the description lists for that operation, each probe's the one it
|
||||
//! was written to reach, and a method a documented path does not take is answered 405 (404
|
||||
//! under `/msg` and the call paths) and never 200.
|
||||
|
||||
#[path = "common/rfc_test_keys.rs"]
|
||||
mod rfc_test_keys;
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use joule_moq::grant_proof::{self as gp, GrantProof};
|
||||
use joule_moq_room::dev::DevRoom;
|
||||
use joule_moq_room::keys::Keys;
|
||||
use joule_moq_room::{http, Config, Service};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::time::timeout;
|
||||
|
||||
const STEP: Duration = Duration::from_secs(20);
|
||||
const HOST: &str = "room.test";
|
||||
|
||||
// ------------------------------------------------------------------------- the description
|
||||
|
||||
type Op = (String, String);
|
||||
|
||||
fn description() -> Value {
|
||||
let text = std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("openapi.json")).expect("openapi.json");
|
||||
serde_json::from_str(&text).expect("openapi.json is JSON")
|
||||
}
|
||||
|
||||
/// Every operation of `openapi.json` and the statuses it lists.
|
||||
fn documented() -> BTreeMap<Op, BTreeSet<u16>> {
|
||||
let doc = description();
|
||||
assert_eq!(doc["openapi"].as_str().map(|v| v.starts_with("3.1")), Some(true), "an OpenAPI 3.1 document");
|
||||
let mut ops = BTreeMap::new();
|
||||
for (path, item) in doc["paths"].as_object().expect("paths") {
|
||||
for (method, o) in item.as_object().expect("a path item") {
|
||||
let statuses: BTreeSet<u16> = o["responses"].as_object().expect("responses").keys().map(|s| s.parse().expect("a numeric status")).collect();
|
||||
assert!(!statuses.is_empty(), "{method} {path} lists no status");
|
||||
ops.insert((method.to_ascii_uppercase(), path.clone()), statuses);
|
||||
}
|
||||
}
|
||||
ops
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------- the source
|
||||
|
||||
fn source(file: &str) -> String {
|
||||
std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src").join(file)).expect("a source file")
|
||||
}
|
||||
|
||||
/// The (method, path) pairs a router's arms match: every `(Method::X, "/path")` in its file.
|
||||
fn routes_in(file: &str) -> BTreeSet<Op> {
|
||||
let s = source(file);
|
||||
let mut out = BTreeSet::new();
|
||||
let mut rest = s.as_str();
|
||||
while let Some(i) = rest.find("(Method::") {
|
||||
rest = &rest[i + "(Method::".len()..];
|
||||
let Some(comma) = rest.find(", \"") else { break };
|
||||
let method = &rest[..comma];
|
||||
if !method.chars().all(|c| c.is_ascii_uppercase()) {
|
||||
continue;
|
||||
}
|
||||
let after = &rest[comma + 3..];
|
||||
let Some(end) = after.find('"') else { break };
|
||||
out.insert((method.to_string(), after[..end].to_string()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
const STATUS: &[(&str, u16)] = &[
|
||||
("OK", 200), ("BAD_REQUEST", 400), ("UNAUTHORIZED", 401), ("FORBIDDEN", 403), ("NOT_FOUND", 404), ("METHOD_NOT_ALLOWED", 405),
|
||||
("CONFLICT", 409), ("GONE", 410), ("PAYLOAD_TOO_LARGE", 413), ("TOO_MANY_REQUESTS", 429), ("INTERNAL_SERVER_ERROR", 500), ("SERVICE_UNAVAILABLE", 503),
|
||||
];
|
||||
|
||||
/// The statuses a top-level function of `file` names (`StatusCode::X` in its body: from its
|
||||
/// `fn` line to the next line that is `}` alone).
|
||||
fn fn_statuses(file: &str, name: &str) -> BTreeSet<u16> {
|
||||
let s = source(file);
|
||||
let lines: Vec<&str> = s.lines().collect();
|
||||
let starts: Vec<usize> = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| {
|
||||
let t = l.trim_start_matches("pub(crate) ").trim_start_matches("pub ").trim_start_matches("async ");
|
||||
!l.starts_with(' ') && t.starts_with(&format!("fn {name}("))
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
assert_eq!(starts.len(), 1, "{file} has one top-level fn {name}");
|
||||
let body: Vec<&str> = lines[starts[0]..].iter().take_while(|l| **l != "}").copied().collect();
|
||||
let body = body.join("\n");
|
||||
let mut out = BTreeSet::new();
|
||||
let mut rest = body.as_str();
|
||||
while let Some(i) = rest.find("StatusCode::") {
|
||||
rest = &rest[i + "StatusCode::".len()..];
|
||||
let name: String = rest.chars().take_while(|c| c.is_ascii_uppercase() || *c == '_').collect();
|
||||
let code = STATUS.iter().find(|(n, _)| *n == name).unwrap_or_else(|| panic!("{file}: {name}: a status this test does not know")).1;
|
||||
out.insert(code);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Each operation: its method and path, its name in the toolchain's catalogue, and the functions
|
||||
/// that answer it (the router that matches it, and every function on its way that answers).
|
||||
type Handlers = &'static [(&'static str, &'static str)];
|
||||
|
||||
const HANDLERS: &[(&str, &str, &str, Handlers)] = &[
|
||||
("GET", "/", "service.show", &[("http.rs", "handle")]),
|
||||
("GET", "/health", "service.check", &[("http.rs", "handle")]),
|
||||
("GET", "/room", "room.show", &[("http.rs", "handle")]),
|
||||
("POST", "/grant", "room.grant", &[("http.rs", "handle"), ("http.rs", "grant"), ("remote.rs", "grant"), ("remote.rs", "bad"), ("call.rs", "handle")]),
|
||||
("GET", "/age/receipt", "admission.show", &[("http.rs", "age_receipt")]),
|
||||
("GET", "/mls", "mls.tail", &[("http.rs", "mls_log")]),
|
||||
("POST", "/mls/key-package", "mls.import", &[("http.rs", "handle"), ("http.rs", "mls_body"), ("http.rs", "mls_key_package")]),
|
||||
("POST", "/mls/post", "mls.update", &[("http.rs", "handle"), ("http.rs", "mls_body"), ("http.rs", "mls_post")]),
|
||||
("GET", "/co/objects", "performance.list", &[("http.rs", "co_objects")]),
|
||||
("GET", "/co/state", "performance.show", &[("http.rs", "co_state")]),
|
||||
("POST", "/co/schedule", "schedule.create", &[("http.rs", "co_request"), ("http.rs", "unauthorised"), ("http.rs", "signed_reply")]),
|
||||
("POST", "/co/handover", "handover.create", &[("http.rs", "co_request"), ("http.rs", "unauthorised"), ("http.rs", "signed_reply")]),
|
||||
("POST", "/co/window", "window.create", &[("http.rs", "co_request"), ("http.rs", "unauthorised"), ("http.rs", "signed_reply")]),
|
||||
("POST", "/co/fidelity", "fidelity.create", &[("http.rs", "co_request"), ("http.rs", "unauthorised"), ("http.rs", "signed_reply")]),
|
||||
("GET", "/watch/time", "time.show", &[("http.rs", "handle")]),
|
||||
("GET", "/watch/source", "source.fetch", &[("http.rs", "watch_source")]),
|
||||
("GET", "/watch/challenge", "watchchallenge.create", &[("http.rs", "handle")]),
|
||||
("GET", "/watch", "watch.tail", &[("http.rs", "watch_log")]),
|
||||
("GET", "/watch/room", "watch.show", &[("http.rs", "handle")]),
|
||||
("POST", "/watch/join", "watch.join", &[("http.rs", "handle"), ("http.rs", "watch_join")]),
|
||||
("POST", "/watch/post", "watch.update", &[("http.rs", "handle"), ("http.rs", "watch_post")]),
|
||||
("POST", "/remote/control", "control.grant", &[("remote.rs", "control"), ("http.rs", "unauthorised")]),
|
||||
("GET", "/remote/room", "remote.show", &[("remote.rs", "session"), ("remote.rs", "admit_reader"), ("http.rs", "unauthorised")]),
|
||||
("GET", "/remote/state", "remote.verify", &[("remote.rs", "state"), ("remote.rs", "admit_reader"), ("http.rs", "unauthorised")]),
|
||||
("GET", "/msg", "conversation.show", &[("messaging.rs", "handle")]),
|
||||
("GET", "/msg/challenge", "conversationchallenge.create", &[("messaging.rs", "handle")]),
|
||||
("POST", "/msg/join", "conversation.join", &[("messaging.rs", "handle"), ("messaging.rs", "join"), ("messaging.rs", "unauthorised")]),
|
||||
("GET", "/msg/log", "conversation.tail", &[("messaging.rs", "handle"), ("messaging.rs", "log"), ("messaging.rs", "unauthorised")]),
|
||||
("POST", "/msg/key-package", "conversation.import", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]),
|
||||
("POST", "/msg/mls", "conversation.update", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]),
|
||||
("POST", "/msg/post", "message.create", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]),
|
||||
("POST", "/msg/ack", "acknowledgement.create", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]),
|
||||
("POST", "/msg/leave", "conversation.leave", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]),
|
||||
("POST", "/msg/remove", "member.delete", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]),
|
||||
("GET", "/call/challenge", "callchallenge.create", &[("http.rs", "handle"), ("call.rs", "handle")]),
|
||||
("POST", "/call/place", "call.create", &[("http.rs", "handle"), ("call.rs", "handle"), ("call.rs", "unauthorised"), ("call.rs", "body"), ("call.rs", "place")]),
|
||||
("POST", "/call/ring", "call.invite", &[("http.rs", "handle"), ("call.rs", "handle"), ("http.rs", "unauthorised"), ("call.rs", "body"), ("call.rs", "ring")]),
|
||||
("GET", "/inbox", "inbox.show", &[("http.rs", "handle"), ("call.rs", "handle"), ("call.rs", "unauthorised"), ("call.rs", "inbox")]),
|
||||
("POST", "/call/answer", "answer.create", &[("http.rs", "handle"), ("call.rs", "handle"), ("call.rs", "unauthorised"), ("call.rs", "body"), ("call.rs", "answer_of")]),
|
||||
("GET", "/call", "call.show", &[("http.rs", "handle"), ("call.rs", "handle"), ("call.rs", "unauthorised"), ("call.rs", "current")]),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn every_route_the_routers_match_is_documented_and_no_other() {
|
||||
let doc: BTreeSet<Op> = documented().into_keys().collect();
|
||||
let code: BTreeSet<Op> = ["http.rs", "messaging.rs", "call.rs"].iter().flat_map(|f| routes_in(f)).collect();
|
||||
let undocumented: Vec<&Op> = code.difference(&doc).collect();
|
||||
let unserved: Vec<&Op> = doc.difference(&code).collect();
|
||||
assert!(undocumented.is_empty(), "routes the code serves that openapi.json does not describe: {undocumented:?}");
|
||||
assert!(unserved.is_empty(), "routes openapi.json describes that no router matches: {unserved:?}");
|
||||
let mapped: BTreeSet<Op> = HANDLERS.iter().map(|(m, p, _, _)| (m.to_string(), p.to_string())).collect();
|
||||
assert_eq!(mapped, doc, "HANDLERS names the functions of every documented operation, and of no other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_operation_is_named_as_the_catalogue_names_it() {
|
||||
let doc = description();
|
||||
let mut seen = BTreeSet::new();
|
||||
for (m, p, op, _) in HANDLERS {
|
||||
assert_eq!(wai::ops::check_id(op), Ok(()), "{m} {p}: {op}");
|
||||
assert!(seen.insert(*op), "{op} names two operations");
|
||||
let id = doc["paths"][*p][m.to_ascii_lowercase()]["operationId"].as_str();
|
||||
assert_eq!(id, Some(*op), "{m} {p}: its operationId is its name in the catalogue");
|
||||
}
|
||||
// The operations the catalogue names with their routes.
|
||||
for (m, p, op) in [("GET", "/room", "room.show"), ("POST", "/grant", "room.grant")] {
|
||||
assert!(HANDLERS.iter().any(|h| (h.0, h.1, h.2) == (m, p, op)), "{op} is {m} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_documented_status_is_one_its_handlers_return_and_every_returned_status_is_documented() {
|
||||
let doc = documented();
|
||||
let mut by_fn: BTreeMap<(&str, &str), BTreeSet<u16>> = BTreeMap::new();
|
||||
for (m, p, _, fns) in HANDLERS {
|
||||
let listed = &doc[&(m.to_string(), p.to_string())];
|
||||
let mut can: BTreeSet<u16> = BTreeSet::new();
|
||||
for (f, n) in *fns {
|
||||
can.extend(fn_statuses(f, n));
|
||||
by_fn.entry((f, n)).or_default().extend(listed.iter().copied());
|
||||
}
|
||||
let never: Vec<&u16> = listed.difference(&can).collect();
|
||||
assert!(never.is_empty(), "{m} {p}: openapi.json lists {never:?}, which none of {fns:?} returns");
|
||||
}
|
||||
for ((f, n), listed) in &by_fn {
|
||||
let returned: BTreeSet<u16> = fn_statuses(f, n).into_iter().filter(|s| *s != 405).collect();
|
||||
let missing: Vec<&u16> = returned.difference(listed).collect();
|
||||
assert!(missing.is_empty(), "{f}:{n} returns {missing:?}, which openapi.json lists for none of the operations it answers");
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------- requests
|
||||
|
||||
struct Answer {
|
||||
status: u16,
|
||||
headers: BTreeMap<String, String>,
|
||||
json: Value,
|
||||
}
|
||||
|
||||
async fn send(addr: SocketAddr, method: &str, path: &str, headers: &[(&str, String)], body: Option<Vec<u8>>) -> Answer {
|
||||
let mut s = tokio::net::TcpStream::connect(addr).await.expect("the service answers");
|
||||
let b = body.unwrap_or_default();
|
||||
let extra: String = headers.iter().map(|(k, v)| format!("{k}: {v}\r\n")).collect();
|
||||
let head = format!("{method} {path} HTTP/1.1\r\nHost: {HOST}\r\nX-Forwarded-For: 10.20.0.1\r\n{extra}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", b.len());
|
||||
s.write_all(head.as_bytes()).await.unwrap();
|
||||
// A body past what the request takes may be refused before it is read: a reset then is fine.
|
||||
let _ = s.write_all(&b).await;
|
||||
let mut out = Vec::new();
|
||||
let _ = timeout(STEP, s.read_to_end(&mut out)).await.expect("an answer in time");
|
||||
let text = String::from_utf8_lossy(&out).to_string();
|
||||
let (head, body) = text.split_once("\r\n\r\n").unwrap_or_else(|| panic!("{method} {path}: no response: {text:?}"));
|
||||
let status: u16 = head.split(' ').nth(1).unwrap().parse().unwrap();
|
||||
let headers = head.lines().skip(1).filter_map(|l| l.split_once(':')).map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_string())).collect();
|
||||
Answer { status, headers, json: serde_json::from_str(body).unwrap_or(Value::Null) }
|
||||
}
|
||||
|
||||
/// What the running service answered, held to the description as it is noted.
|
||||
struct Probes {
|
||||
doc: BTreeMap<Op, BTreeSet<u16>>,
|
||||
seen: BTreeMap<Op, BTreeSet<u16>>,
|
||||
mode: &'static str,
|
||||
addr: SocketAddr,
|
||||
}
|
||||
|
||||
impl Probes {
|
||||
/// Ask, require a status the description lists for the operation and, when given, the one
|
||||
/// the probe was written to reach.
|
||||
async fn ask(&mut self, method: &str, path: &str, headers: &[(&str, String)], body: Option<Vec<u8>>, want: u16) -> Answer {
|
||||
let a = send(self.addr, method, path, headers, body).await;
|
||||
let op = (method.to_string(), path.split('?').next().unwrap().to_string());
|
||||
let listed = self.doc.get(&op).unwrap_or_else(|| panic!("[{}] {method} {path}: not an operation of openapi.json", self.mode));
|
||||
assert!(listed.contains(&a.status), "[{}] {method} {path} answered {} {}, which openapi.json does not list ({listed:?})", self.mode, a.status, a.json);
|
||||
assert_eq!(a.status, want, "[{}] {method} {path}: {}", self.mode, a.json);
|
||||
self.seen.entry(op).or_default().insert(a.status);
|
||||
a
|
||||
}
|
||||
|
||||
async fn json(&mut self, method: &str, path: &str, body: Value, want: u16) -> Answer {
|
||||
self.ask(method, path, &[], Some(body.to_string().into_bytes()), want).await
|
||||
}
|
||||
|
||||
async fn get(&mut self, path: &str, want: u16) -> Answer {
|
||||
self.ask("GET", path, &[], None, want).await
|
||||
}
|
||||
}
|
||||
|
||||
fn key(i: u8) -> SigningKey {
|
||||
SigningKey::from_bytes(&[i; 32])
|
||||
}
|
||||
|
||||
fn pubhex(k: &SigningKey) -> String {
|
||||
hex::encode(k.verifying_key().to_bytes())
|
||||
}
|
||||
|
||||
fn challenge_from(a: &Answer) -> [u8; 32] {
|
||||
maybe_challenge(a).unwrap_or_else(|| panic!("no challenge in an answer {} {}", a.status, a.json))
|
||||
}
|
||||
|
||||
fn maybe_challenge(a: &Answer) -> Option<[u8; 32]> {
|
||||
a.headers.get(&gp::HEADER_CHALLENGE.to_ascii_lowercase()).and_then(|c| gp::from_base64url(c)).and_then(|c| c.try_into().ok())
|
||||
}
|
||||
|
||||
async fn call_challenge(addr: SocketAddr) -> [u8; 32] {
|
||||
hex_challenge(&send(addr, "GET", "/call/challenge", &[], None).await)
|
||||
}
|
||||
|
||||
fn hex_challenge(a: &Answer) -> [u8; 32] {
|
||||
hex::decode(a.json["challenge"].as_str().expect("a challenge")).unwrap().try_into().unwrap()
|
||||
}
|
||||
|
||||
/// A grant's `Authorization` and the proof of its key over `challenge` for the request.
|
||||
fn authorised(grant: Option<&[u8]>, k: &SigningKey, challenge: [u8; 32], method: &str, path: &str) -> Vec<(&'static str, String)> {
|
||||
let digest = grant.map_or([0; 32], |g| gp::grant_digest_and_principal(g).unwrap().0);
|
||||
let proof = GrantProof::sign(digest, k, challenge, &gp::context(method, HOST, path)).unwrap();
|
||||
let mut hs = vec![(gp::HEADER_PROOF, gp::base64url(&proof.to_bytes()))];
|
||||
if let Some(g) = grant {
|
||||
hs.push(("Authorization", format!("Bearer {}", gp::base64url(g))));
|
||||
}
|
||||
hs
|
||||
}
|
||||
|
||||
fn big(n: usize) -> Vec<u8> {
|
||||
let mut v = br#"{"pad":""#.to_vec();
|
||||
v.extend(std::iter::repeat_n(b'a', n));
|
||||
v.extend(br#""}"#);
|
||||
v
|
||||
}
|
||||
|
||||
const OTHER_ROOM: &str = "abababababababababababababababababababababababababababababababab";
|
||||
|
||||
// ------------------------------------------------------------------------------------- modes
|
||||
|
||||
async fn plain(p: &mut Probes) {
|
||||
let room = p.get("/room", 200).await.json["room"].as_str().unwrap().to_string();
|
||||
p.get("/", 200).await;
|
||||
p.get("/health", 200).await;
|
||||
let a = p.json("POST", "/grant", json!({"principal": pubhex(&key(1)), "role": "publisher"}), 200).await;
|
||||
assert!(a.json["grant"].as_str().is_some() && a.json["room"]["room"].as_str() == Some(room.as_str()));
|
||||
p.json("POST", "/grant", json!({"principal": pubhex(&key(2)), "role": "publisher"}), 200).await;
|
||||
p.json("POST", "/grant", json!({"principal": pubhex(&key(3)), "role": "publisher"}), 409).await;
|
||||
p.json("POST", "/grant", json!({"principal": pubhex(&key(4)), "role": "viewer"}), 200).await;
|
||||
p.json("POST", "/grant", json!({"principal": "00", "role": "viewer"}), 400).await;
|
||||
p.json("POST", "/grant", json!({"principal": pubhex(&key(4)), "role": "owner"}), 400).await;
|
||||
p.ask("POST", "/grant", &[], Some(b"not json".to_vec()), 400).await;
|
||||
p.ask("POST", "/grant", &[], Some(big(2048)), 413).await;
|
||||
p.get("/age/receipt", 404).await;
|
||||
// The modes this room does not keep.
|
||||
p.get(&format!("/mls?room={room}"), 404).await;
|
||||
p.get("/mls", 400).await;
|
||||
p.get(&format!("/mls?room={OTHER_ROOM}"), 410).await;
|
||||
for path in ["/mls/key-package", "/mls/post"] {
|
||||
p.json("POST", path, json!({"room": room, "key_package": "00", "frames": ["00"]}), 404).await;
|
||||
p.json("POST", path, json!({"room": OTHER_ROOM}), 410).await;
|
||||
p.json("POST", path, json!({}), 400).await;
|
||||
p.ask("POST", path, &[], Some(big(140 * 1024)), 413).await;
|
||||
}
|
||||
p.get("/co/objects", 404).await;
|
||||
p.get("/co/state", 404).await;
|
||||
for path in ["/co/schedule", "/co/handover", "/co/window", "/co/fidelity"] {
|
||||
p.json("POST", path, json!({}), 404).await;
|
||||
}
|
||||
p.get("/watch/time", 200).await;
|
||||
p.get("/watch/source", 404).await;
|
||||
p.get("/watch/challenge", 404).await;
|
||||
p.get(&format!("/watch?room={room}&wait_ms=0"), 404).await;
|
||||
p.get("/watch", 400).await;
|
||||
p.get(&format!("/watch?room={OTHER_ROOM}"), 410).await;
|
||||
p.get("/watch/room", 404).await;
|
||||
p.json("POST", "/watch/join", json!({"room": room, "grant": "00", "proof": "00"}), 404).await;
|
||||
p.json("POST", "/watch/join", json!({"room": OTHER_ROOM, "grant": "00", "proof": "00"}), 410).await;
|
||||
p.json("POST", "/watch/join", json!({"room": room}), 400).await;
|
||||
p.ask("POST", "/watch/join", &[], Some(big(5000)), 413).await;
|
||||
p.json("POST", "/watch/post", json!({"room": room, "events": ["00"]}), 404).await;
|
||||
p.json("POST", "/watch/post", json!({"room": OTHER_ROOM, "events": ["00"]}), 410).await;
|
||||
p.json("POST", "/watch/post", json!({"room": room, "events": []}), 400).await;
|
||||
p.ask("POST", "/watch/post", &[], Some(big(17 * 1024)), 413).await;
|
||||
p.json("POST", "/remote/control", json!({}), 404).await;
|
||||
p.get("/remote/room", 404).await;
|
||||
p.get("/remote/state", 404).await;
|
||||
for path in ["/msg", "/msg/challenge", "/msg/log"] {
|
||||
p.get(path, 404).await;
|
||||
}
|
||||
for path in ["/msg/join", "/msg/key-package", "/msg/mls", "/msg/post", "/msg/ack", "/msg/leave", "/msg/remove"] {
|
||||
p.json("POST", path, json!({}), 404).await;
|
||||
}
|
||||
p.get("/call/challenge", 404).await;
|
||||
p.get("/inbox", 404).await;
|
||||
p.get("/call", 404).await;
|
||||
for path in ["/call/place", "/call/ring", "/call/answer"] {
|
||||
p.json("POST", path, json!({}), 404).await;
|
||||
}
|
||||
// A method a documented path does not take.
|
||||
for (m, path) in [("GET", "/grant"), ("POST", "/room"), ("POST", "/health"), ("GET", "/mls/post"), ("POST", "/co/state"), ("GET", "/watch/join"), ("POST", "/remote/room"), ("PUT", "/grant"), ("DELETE", "/room")] {
|
||||
let a = send(p.addr, m, path, &[], None).await;
|
||||
assert_eq!(a.status, 405, "{m} {path}");
|
||||
}
|
||||
let a = send(p.addr, "GET", "/no/such/path", &[], None).await;
|
||||
assert_eq!(a.status, 404);
|
||||
}
|
||||
|
||||
async fn without_relay(p: &mut Probes) {
|
||||
p.json("POST", "/grant", json!({"principal": pubhex(&key(5)), "role": "viewer"}), 503).await;
|
||||
let h = p.get("/health", 200).await;
|
||||
assert_eq!(h.json["relay"], json!(false));
|
||||
}
|
||||
|
||||
async fn rate(p: &mut Probes) {
|
||||
p.json("POST", "/grant", json!({"principal": pubhex(&key(6)), "role": "viewer"}), 200).await;
|
||||
p.json("POST", "/grant", json!({"principal": pubhex(&key(7)), "role": "viewer"}), 429).await;
|
||||
p.get("/age/receipt", 429).await;
|
||||
}
|
||||
|
||||
async fn e2ee(p: &mut Probes) {
|
||||
let room = p.get("/room", 200).await.json["room"].as_str().unwrap().to_string();
|
||||
let a = p.get(&format!("/mls?room={room}&wait_ms=0"), 200).await;
|
||||
assert_eq!(a.json["room"].as_str(), Some(room.as_str()));
|
||||
p.json("POST", "/mls/key-package", json!({"room": room, "key_package": "zz"}), 400).await;
|
||||
p.json("POST", "/mls/key-package", json!({"room": room, "key_package": "00"}), 400).await;
|
||||
p.json("POST", "/mls/post", json!({"room": room, "frames": ["00"]}), 400).await;
|
||||
}
|
||||
|
||||
async fn age(p: &mut Probes) {
|
||||
let a = p.json("POST", "/grant", json!({"principal": pubhex(&key(8)), "role": "viewer"}), 401).await;
|
||||
assert_eq!(a.json["error"], json!("token_required"));
|
||||
assert!(a.headers.contains_key("www-authenticate"), "a challenge for a token");
|
||||
let a = p.ask("POST", "/grant", &[("Authorization", "PrivateToken token=\"!!\"".into())], Some(json!({"principal": pubhex(&key(8)), "role": "viewer"}).to_string().into_bytes()), 403).await;
|
||||
assert_eq!(a.json["error"], json!("token_malformed"));
|
||||
let r = p.get("/age/receipt", 200).await;
|
||||
assert!(r.json["receipt"].is_object() || r.json["receipt"].is_string(), "{}", r.json);
|
||||
}
|
||||
|
||||
async fn co(p: &mut Probes) {
|
||||
let objects = p.get("/co/objects?from=0", 200).await;
|
||||
assert!(objects.json["objects"].is_array());
|
||||
p.get("/co/state", 200).await;
|
||||
let lead = key(0x21);
|
||||
let g = p.json("POST", "/grant", json!({"principal": pubhex(&lead), "role": "publisher"}), 200).await;
|
||||
let grant = hex::decode(g.json["grant"].as_str().unwrap()).unwrap();
|
||||
let viewer = key(0x22);
|
||||
let v = p.json("POST", "/grant", json!({"principal": pubhex(&viewer), "role": "viewer"}), 200).await;
|
||||
let vgrant = hex::decode(v.json["grant"].as_str().unwrap()).unwrap();
|
||||
// No proof: 401 with a challenge; a proof then passes.
|
||||
let a = p.json("POST", "/co/fidelity", json!({}), 401).await;
|
||||
let mut c = challenge_from(&a);
|
||||
let call = async |p: &mut Probes, who: &SigningKey, g: &[u8], path: &str, body: Vec<u8>, want: u16, c: &mut [u8; 32]| {
|
||||
let hs = authorised(Some(g), who, *c, "POST", path);
|
||||
let a = p.ask("POST", path, &hs, Some(body), want).await;
|
||||
if let Some(n) = maybe_challenge(&a) {
|
||||
*c = n;
|
||||
} else {
|
||||
*c = challenge_from(&p.json("POST", "/co/fidelity", json!({}), 401).await);
|
||||
}
|
||||
a
|
||||
};
|
||||
let a = call(p, &lead, &grant, "/co/fidelity", b"{}".to_vec(), 200, &mut c).await;
|
||||
assert!(a.json["ok"].is_boolean(), "{}", a.json);
|
||||
call(p, &viewer, &vgrant, "/co/schedule", json!({"mode": "mark", "segments": []}).to_string().into_bytes(), 403, &mut c).await;
|
||||
call(p, &viewer, &vgrant, "/co/window", json!({"rule": "plurality", "options": 3, "seconds": 5}).to_string().into_bytes(), 403, &mut c).await;
|
||||
call(p, &viewer, &vgrant, "/co/fidelity", b"{}".to_vec(), 403, &mut c).await;
|
||||
call(p, &lead, &grant, "/co/schedule", json!({"mode": "neither", "segments": []}).to_string().into_bytes(), 400, &mut c).await;
|
||||
call(p, &lead, &grant, "/co/handover", json!({"to": "00"}).to_string().into_bytes(), 400, &mut c).await;
|
||||
call(p, &lead, &grant, "/co/window", json!({"rule": "sometimes"}).to_string().into_bytes(), 400, &mut c).await;
|
||||
call(p, &lead, &grant, "/co/fidelity", b"not json".to_vec(), 400, &mut c).await;
|
||||
call(p, &lead, &grant, "/co/window", big(5000), 413, &mut c).await;
|
||||
let w = call(p, &lead, &grant, "/co/window", json!({"rule": "plurality", "options": 3, "seconds": 30, "question": "which"}).to_string().into_bytes(), 200, &mut c).await;
|
||||
assert_eq!(w.json["ok"], json!(true), "{}", w.json);
|
||||
let s = call(p, &lead, &grant, "/co/schedule", json!({"mode": "mark", "beat_us": 500000, "beats_per_bar": 4, "segments": [{"length": 8, "turns": 2, "holders": [pubhex(&lead)]}]}).to_string().into_bytes(), 200, &mut c).await;
|
||||
assert_eq!(s.json["ok"], json!(true), "{}", s.json);
|
||||
call(p, &lead, &grant, "/co/handover", json!({"to": pubhex(&viewer)}).to_string().into_bytes(), 400, &mut c).await;
|
||||
let h = call(p, &lead, &grant, "/co/handover", json!({"to": pubhex(&lead)}).to_string().into_bytes(), 200, &mut c).await;
|
||||
assert_eq!(h.json["ok"], json!(true), "{}", h.json);
|
||||
}
|
||||
|
||||
async fn watch(p: &mut Probes) {
|
||||
let r = p.get("/room", 200).await;
|
||||
let room = r.json["room"].as_str().unwrap().to_string();
|
||||
let room_id: [u8; 32] = hex::decode(&room).unwrap().try_into().unwrap();
|
||||
let t0 = r.json["t0_us"].as_u64().unwrap();
|
||||
let src = p.get("/watch/source", 200).await;
|
||||
assert_eq!(src.json, Value::Null, "the source's bytes, not JSON");
|
||||
p.get("/watch/room", 200).await;
|
||||
p.get(&format!("/watch?room={room}&wait_ms=0"), 200).await;
|
||||
let viewer = key(0x31);
|
||||
let g = p.json("POST", "/grant", json!({"principal": pubhex(&viewer), "role": "viewer"}), 200).await;
|
||||
let grant = hex::decode(g.json["grant"].as_str().unwrap()).unwrap();
|
||||
let digest = gp::grant_digest_and_principal(&grant).unwrap().0;
|
||||
let join = |c: [u8; 32], target: &str| json!({"room": room, "grant": hex::encode(&grant), "proof": hex::encode(wai::live_room::Proof::sign(digest, &viewer, c, &format!("POST {target}")).to_bytes())});
|
||||
let c = hex_challenge(&p.get("/watch/challenge", 200).await);
|
||||
p.json("POST", "/watch/join", join(c, "//elsewhere.test/watch/join"), 401).await;
|
||||
let c = hex_challenge(&p.get("/watch/challenge", 200).await);
|
||||
let j = p.json("POST", "/watch/join", join(c, &format!("//{HOST}/watch/join")), 200).await;
|
||||
let joined = j.json["t"].as_u64().unwrap();
|
||||
// A control in the past is refused; one ahead is carried.
|
||||
use wai::co_watch::{action, Control};
|
||||
let now_t = (std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_micros() as u64).saturating_sub(t0).max(joined);
|
||||
let ev = |seq: u64, prev: [u8; 32], payload: Vec<u8>| wai::live_events::Event::sign(&viewer, room_id, seq, prev, now_t, None, wai::live_events::kind::META, payload);
|
||||
let past = ev(0, [0; 32], Control { action: action::PLAY, at: now_t.saturating_sub(1), arg: 0 }.encode());
|
||||
p.json("POST", "/watch/post", json!({"room": room, "events": [hex::encode(past.to_bytes())]}), 409).await;
|
||||
let ahead = ev(0, [0; 32], Control { action: action::PLAY, at: now_t + 700_000, arg: 0 }.encode());
|
||||
let a = p.json("POST", "/watch/post", json!({"room": room, "events": [hex::encode(ahead.to_bytes())]}), 200).await;
|
||||
assert_eq!(a.json["carried"], json!(1));
|
||||
}
|
||||
|
||||
async fn remote(p: &mut Probes) {
|
||||
p.json("POST", "/grant", json!({"principal": pubhex(&key(0x41)), "role": "host"}), 403).await;
|
||||
p.json("POST", "/grant", json!({"principal": pubhex(&key(0x41)), "role": "publisher"}), 400).await;
|
||||
let ctl = key(0x42);
|
||||
let g = p.json("POST", "/grant", json!({"principal": pubhex(&ctl), "role": "controller"}), 200).await;
|
||||
let grant = hex::decode(g.json["grant"].as_str().unwrap()).unwrap();
|
||||
let c = challenge_from(&p.get("/remote/room", 401).await);
|
||||
let room = p.ask("GET", "/remote/room", &authorised(Some(&grant), &ctl, c, "GET", "/remote/room"), None, 200).await;
|
||||
assert!(room.json["descriptor"].is_string(), "{}", room.json);
|
||||
let c = challenge_from(&p.get("/remote/state", 401).await);
|
||||
let state = p.ask("GET", "/remote/state", &authorised(Some(&grant), &ctl, c, "GET", "/remote/state"), None, 200).await;
|
||||
assert!(state.json["verified"].is_object(), "{}", state.json);
|
||||
let a = p.json("POST", "/remote/control", json!({}), 401).await;
|
||||
let mut c = challenge_from(&a);
|
||||
let body = json!({"to": pubhex(&ctl), "control": 3}).to_string().into_bytes();
|
||||
let a = p.ask("POST", "/remote/control", &authorised(Some(&grant), &ctl, c, "POST", "/remote/control"), Some(body), 403).await;
|
||||
c = challenge_from(&a);
|
||||
p.ask("POST", "/remote/control", &authorised(Some(&grant), &ctl, c, "POST", "/remote/control"), Some(b"not json".to_vec()), 400).await;
|
||||
}
|
||||
|
||||
async fn messaging(p: &mut Probes) {
|
||||
p.get("/msg", 200).await;
|
||||
// Join on a proof of the key; the first member owns the conversation.
|
||||
let owner = key(0x51);
|
||||
let c = hex_challenge(&p.get("/msg/challenge", 200).await);
|
||||
p.ask("POST", "/msg/join", &[], None, 401).await;
|
||||
let j = p.ask("POST", "/msg/join", &authorised(None, &owner, c, "POST", "/msg/join"), None, 200).await;
|
||||
assert_eq!(j.json["role"], json!("owner"));
|
||||
let grant = hex::decode(j.json["grant"].as_str().unwrap()).unwrap();
|
||||
let mut c = challenge_from(&j);
|
||||
let member = async |p: &mut Probes, method: &str, path: &str, body: Option<Vec<u8>>, want: u16, c: &mut [u8; 32]| {
|
||||
let a = p.ask(method, path, &authorised(Some(&grant), &owner, *c, method, path.split('?').next().unwrap()), body, want).await;
|
||||
*c = match maybe_challenge(&a) {
|
||||
Some(n) => n,
|
||||
None => hex_challenge(&send(p.addr, "GET", "/msg/challenge", &[], None).await),
|
||||
};
|
||||
a
|
||||
};
|
||||
let log = member(p, "GET", "/msg/log?from=0&wait_ms=0", None, 200, &mut c).await;
|
||||
assert!(log.json["entries"].is_array());
|
||||
member(p, "POST", "/msg/key-package", Some(b"{}".to_vec()), 400, &mut c).await;
|
||||
member(p, "POST", "/msg/mls", Some(json!({"frames": []}).to_string().into_bytes()), 400, &mut c).await;
|
||||
member(p, "POST", "/msg/post", Some(b"{}".to_vec()), 400, &mut c).await;
|
||||
member(p, "POST", "/msg/ack", Some(json!({"ack": "00"}).to_string().into_bytes()), 409, &mut c).await;
|
||||
member(p, "POST", "/msg/remove", Some(json!({"principal": pubhex(&key(0x52))}).to_string().into_bytes()), 409, &mut c).await;
|
||||
member(p, "POST", "/msg/remove", Some(json!({"principal": "00"}).to_string().into_bytes()), 400, &mut c).await;
|
||||
member(p, "POST", "/msg/leave", Some(b"not json".to_vec()), 400, &mut c).await;
|
||||
member(p, "POST", "/msg/leave", Some(b"{}".to_vec()), 200, &mut c).await;
|
||||
// Once it has left, it is no member.
|
||||
member(p, "POST", "/msg/key-package", Some(json!({"key_package": "00"}).to_string().into_bytes()), 403, &mut c).await;
|
||||
for path in ["/msg/key-package", "/msg/mls", "/msg/post", "/msg/ack", "/msg/leave", "/msg/remove"] {
|
||||
p.json("POST", path, json!({}), 401).await;
|
||||
}
|
||||
p.get("/msg/log", 401).await;
|
||||
let a = send(p.addr, "GET", "/msg/nothing", &[], None).await;
|
||||
assert_eq!(a.status, 404);
|
||||
let a = send(p.addr, "GET", "/msg/join", &[], None).await;
|
||||
assert_eq!(a.status, 404, "a method a conversation's path does not take");
|
||||
}
|
||||
|
||||
async fn calls(p: &mut Probes) {
|
||||
let caller = key(0x61);
|
||||
let callee = key(0x62);
|
||||
p.json("POST", "/grant", json!({"principal": pubhex(&caller), "role": "publisher"}), 403).await;
|
||||
let c = hex_challenge(&p.get("/call/challenge", 200).await);
|
||||
p.json("POST", "/call/place", json!({"invitees": [pubhex(&callee)]}), 401).await;
|
||||
p.ask("POST", "/call/place", &authorised(None, &caller, c, "POST", "/call/place"), Some(json!({"invitees": []}).to_string().into_bytes()), 400).await;
|
||||
let placed = p.ask("POST", "/call/place", &authorised(None, &caller, call_challenge(p.addr).await, "POST", "/call/place"), Some(json!({"invitees": [pubhex(&callee)]}).to_string().into_bytes()), 200).await;
|
||||
let grant = hex::decode(placed.json["grant"].as_str().unwrap()).unwrap();
|
||||
let a = p.ask("GET", "/call", &authorised(None, &caller, call_challenge(p.addr).await, "GET", "/call"), None, 200).await;
|
||||
assert!(a.json.is_object());
|
||||
p.ask("GET", "/call", &authorised(None, &key(0x63), call_challenge(p.addr).await, "GET", "/call"), None, 403).await;
|
||||
let a = p.ask("GET", "/inbox", &authorised(None, &callee, call_challenge(p.addr).await, "GET", "/inbox"), None, 200).await;
|
||||
assert!(a.json["invitations"].is_array());
|
||||
p.get("/inbox", 401).await;
|
||||
p.get("/call", 401).await;
|
||||
p.ask("POST", "/call/ring", &authorised(Some(&grant), &caller, call_challenge(p.addr).await, "POST", "/call/ring"), Some(json!({"invitations": []}).to_string().into_bytes()), 400).await;
|
||||
p.ask("POST", "/call/answer", &authorised(None, &callee, call_challenge(p.addr).await, "POST", "/call/answer"), Some(json!({"answer": "00"}).to_string().into_bytes()), 400).await;
|
||||
p.json("POST", "/call/ring", json!({}), 401).await;
|
||||
p.json("POST", "/call/answer", json!({}), 401).await;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------- running
|
||||
|
||||
/// A service with no relay to reach: its clock never opens a session.
|
||||
async fn start_without_relay(extra: &str) -> SocketAddr {
|
||||
let cfg = Config::from_toml(&format!("listen = \"127.0.0.1:0\"\nkeys = \"/nonexistent\"\nrelay_url = \"ws://127.0.0.1:9/moq\"\n{extra}")).unwrap();
|
||||
let svc = Service::new(Keys::generate(), &cfg);
|
||||
let listener = tokio::net::TcpListener::bind(cfg.listen).await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
svc.start();
|
||||
tokio::spawn(http::serve(svc, listener));
|
||||
addr
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn the_running_service_answers_every_documented_route_with_a_documented_status() {
|
||||
timeout(Duration::from_secs(300), run()).await.expect("the probes finish in time");
|
||||
}
|
||||
|
||||
async fn run() {
|
||||
let doc = documented();
|
||||
let dir = std::env::temp_dir().join(format!("joule-moq-room-openapi-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let source = dir.join("source.wav");
|
||||
std::fs::write(&source, (0..2048u32).map(|i| (i % 251) as u8).collect::<Vec<_>>()).unwrap();
|
||||
let k16 = rfc_test_keys::k16();
|
||||
let modes: Vec<(&'static str, String)> = vec![
|
||||
("plain", String::new()),
|
||||
("e2ee", "[room]\nmax_publishers = 3\njoin_wait_s = 30\ne2ee = true\n".into()),
|
||||
("age", format!("[age]\ntoken_window_s = 600\n[[age.issuers]]\nname = \"issuer.example\"\nkeys = [{{ threshold = 16, spki = \"{}\" }}]\n[[age.ratings]]\nscheme = \"https://ratings.example/scheme\"\nvalue = \"16\"\nmin_age = 16\n", hex::encode(&k16.spki))),
|
||||
("co", "[room]\nmax_publishers = 3\njoin_wait_s = 30\n[co]\naudience_input = 2\nlatency_budget_ms = 1500\nlead = \"first\"\nai = true\nfidelity_every_s = 0\ncommit_after_ms = 800\nlead_ms = 200\n".into()),
|
||||
("watch", format!("[room]\nwatch_source = \"{}\"\nwatch_duration_ms = 60000\nwatch_window_ms = 2000\n", source.display())),
|
||||
("remote", "[room]\nmax_present = 8\njoin_wait_s = 30\n[remote]\nwidth = 640\nheight = 360\nfps = 20\nallow = \"all\"\ncontrol = 3\nmax_age_ms = 3000\n".into()),
|
||||
("messaging", format!("[messaging]\nstore = \"{}\"\nmax_members = 8\n", dir.join("conversation").display())),
|
||||
("calls", "[call]\nring_ms = 30000\n".into()),
|
||||
("rate", "[rate]\nclient_burst = 1\nclient_every_s = 600\n".into()),
|
||||
];
|
||||
let mut seen: BTreeMap<Op, BTreeSet<u16>> = BTreeMap::new();
|
||||
for (mode, extra) in modes {
|
||||
let room = DevRoom::start(2, &extra).await.unwrap_or_else(|e| panic!("[{mode}] the room starts: {e}"));
|
||||
let mut p = Probes { doc: doc.clone(), seen: BTreeMap::new(), mode, addr: room.http };
|
||||
match mode {
|
||||
"plain" => plain(&mut p).await,
|
||||
"e2ee" => e2ee(&mut p).await,
|
||||
"age" => age(&mut p).await,
|
||||
"co" => co(&mut p).await,
|
||||
"watch" => watch(&mut p).await,
|
||||
"remote" => remote(&mut p).await,
|
||||
"messaging" => messaging(&mut p).await,
|
||||
"calls" => calls(&mut p).await,
|
||||
"rate" => rate(&mut p).await,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
for (op, s) in p.seen {
|
||||
seen.entry(op).or_default().extend(s);
|
||||
}
|
||||
room.stop().await;
|
||||
}
|
||||
let addr = start_without_relay("").await;
|
||||
let mut p = Probes { doc: doc.clone(), seen: BTreeMap::new(), mode: "without its relay", addr };
|
||||
without_relay(&mut p).await;
|
||||
for (op, s) in p.seen {
|
||||
seen.entry(op).or_default().extend(s);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
// Every documented operation was asked, and answered with a status it lists.
|
||||
let unasked: Vec<&Op> = doc.keys().filter(|op| !seen.contains_key(*op)).collect();
|
||||
assert!(unasked.is_empty(), "documented operations no probe asked: {unasked:?}");
|
||||
let pairs: usize = doc.values().map(BTreeSet::len).sum();
|
||||
let reached: usize = seen.values().map(BTreeSet::len).sum();
|
||||
let ok_reached = seen.values().filter(|s| s.contains(&200)).count();
|
||||
eprintln!("openapi contract: {} operations asked; {reached} of {pairs} documented (operation, status) pairs reached by a running service, {ok_reached} operations answered 200; every other listed status is one its handler returns (the source check)", doc.len());
|
||||
}
|
||||
|
|
@ -6,6 +6,42 @@ wire format may change and such changes are recorded here.
|
|||
|
||||
## v0.4.0 — current
|
||||
|
||||
### Coding agents: `wai mcp`, `wai setup-agent`, and the room service's API described
|
||||
|
||||
- **`wai mcp`** (`agent.mcp`; the feature `mcp`, which the quickstart's `wai` holds): WAI's
|
||||
operations as tools for a coding assistant, a Model Context Protocol server (revision
|
||||
2025-06-18) on standard input and output. Each tool is an operation of the toolchain's
|
||||
catalogue, named by it with `_` for the dot, in toolsets. `core`, the default:
|
||||
`envelope_inspect` (an envelope or a package), `registry_list` and `registry_show` (the
|
||||
registered capabilities), `receipt_verify` (a delivery receipt, a claim with the context the
|
||||
tool takes, a live-room session or a stream audit bundle, by `kind` or read from the
|
||||
arguments; each answer the verifier's verdict and reason code), `package_build` (a media file
|
||||
under `wai wrap`'s rules, inside the project directory only), `conformance_list` and
|
||||
`conformance_run` (a corpus's independent verifier), and `docs_search` (the specification's
|
||||
text). `dev`, marked development only, on the target `local` alone: `dev_start` and `dev_stop`
|
||||
(a relay and a room service on loopback), `room_grant` (grants for test principals, refusing
|
||||
any service whose address is not a loopback address, before connecting), `room_join`,
|
||||
`room_leave` and `room_session`. `--toolset` chooses; `--read-only` offers only the tools that
|
||||
read or run a verifier. The verdicts agree vector by vector with jwp-conformance,
|
||||
claim-conformance, live-conformance/rooms and live-conformance/audit (`tests/cli_mcp.rs`).
|
||||
- **`wai setup-agent`** (`agent.setup`): writes `AGENTS.md` in a project, adds an import of it to
|
||||
a `CLAUDE.md` the project already has, and registers `wai mcp` (with `--toolset`, those
|
||||
toolsets) in its MCP configuration files, by their documented formats; it changes a file that
|
||||
exists only after its diff is shown and confirmed, has a dry run, and writes nothing outside
|
||||
the project directory. Off a terminal it answers in the toolchain's JSON envelope
|
||||
(`envelope/1`), with the table's exit statuses: 2 usage, 3 permission, 4 confirmation
|
||||
required (each proposed change and the confirming command given), 5 not found
|
||||
(`tests/cli_setup_agent.rs`).
|
||||
- **`wai mcp` and `wai setup-agent` are verbs of the one table** (`dx::VERBS`) that holds `hello`,
|
||||
`create`, `dev` and `verify`. `wai::ops` holds the rule operation names keep: `noun.verb`,
|
||||
lowercase, the verb from a closed list.
|
||||
- **The room service's API**: `joule-moq-room/openapi.json` (OpenAPI 3.1) describes every route,
|
||||
request and status of the room service in each of its modes, each operation's `operationId`
|
||||
its name in the catalogue (`room.show`, `room.grant`, `watch.join`, `message.create`, ...),
|
||||
held to the code by a contract test that matches the names, the routers' routes, each
|
||||
operation's statuses against its handlers', and the running service's answers in every mode.
|
||||
`joule-moq-room dev` runs a development room on loopback for `wai mcp` and for tests.
|
||||
|
||||
### The developer's path: `wai hello`, `create`, `dev` and `verify`; the floor with no system library; the examples
|
||||
|
||||
- **`floor`** (wai-rs feature): the mandatory floor of SPEC §5 (PNG, FLAC, zstd) decoded with no
|
||||
|
|
|
|||
|
|
@ -91,6 +91,45 @@ and, on macOS without pkg-config, in the Homebrew "flac" formula prefix
|
|||
(`/opt/homebrew/opt/flac/lib` or `/usr/local/opt/flac/lib`). Where neither finds
|
||||
it, set `RUSTFLAGS="-L <the directory holding libFLAC>"`.
|
||||
|
||||
## For coding agents
|
||||
|
||||
`wai setup-agent` makes a project ready for a coding agent to use WAI. In the project directory
|
||||
it writes `AGENTS.md`, the instructions agents read, adds an import of it to a `CLAUDE.md` the
|
||||
project already has, and registers `wai mcp` in the project's MCP configuration: `.mcp.json`,
|
||||
and the project-level MCP file of each agent whose directory the project already holds (`--all`
|
||||
writes every one it knows, `--dry-run` lists them, `--toolset core,dev` registers the server
|
||||
with those toolsets). A file that does not exist is created; one that exists is changed only
|
||||
after its diff is shown and the change confirmed (asked on a terminal, or `--confirm`); without
|
||||
either, nothing that exists changes and the command exits 4 with each proposed change and the
|
||||
command that confirms. It writes nothing outside the project directory. Off a terminal its
|
||||
output is the toolchain's JSON envelope.
|
||||
|
||||
`wai mcp` is a Model Context Protocol server on standard input and output. Each tool is an
|
||||
operation of the toolchain, named by it with `_` for the dot, and the tools come in toolsets:
|
||||
|
||||
| Toolset | Tools |
|
||||
|---|---|
|
||||
| `core` (the default) | `envelope_inspect`, `registry_list`, `registry_show`, `receipt_verify`, `package_build`, `conformance_list`, `conformance_run`, `docs_search` |
|
||||
| `dev` | `dev_start`, `dev_stop`, `room_grant`, `room_join`, `room_leave`, `room_session` |
|
||||
|
||||
`receipt_verify` verifies a delivery receipt, a claim, a live-room session or a stream audit
|
||||
bundle, and answers with the verifier's verdict and reason code. The `dev` tools, marked
|
||||
development only, act on the target `local` alone: they start a relay and a room service on
|
||||
loopback (`joule-moq-room dev`), mint grants for test principals, join and publish as them, and
|
||||
read the session for verification; the grant tool refuses any service that is not on a loopback
|
||||
address. `--read-only` offers only the tools that read or run a verifier; the packaging tool
|
||||
writes only inside the project directory. No tool sends anything off the machine.
|
||||
|
||||
```bash
|
||||
cd wai-rs
|
||||
cargo install --path . --no-default-features --features mcp # `wai` with `wai mcp`, no codec libraries
|
||||
cd /path/to/your/project && wai setup-agent --toolset core,dev
|
||||
```
|
||||
|
||||
The quickstart's `wai` holds `wai mcp` too. The room service's HTTP API is described, OpenAPI
|
||||
3.1, in [`joule-moq-room/openapi.json`](../joule-code/joule-code-rs/crates/joule-moq-room/openapi.json),
|
||||
each operation's `operationId` its name in the toolchain's catalogue.
|
||||
|
||||
## SDK consumption
|
||||
|
||||
`wai-rs` exposes a stable C ABI in [`src/ffi.rs`](wai-rs/src/ffi.rs) —
|
||||
|
|
|
|||
|
|
@ -151,10 +151,11 @@ ffi = []
|
|||
# `wai encode` writes PNG and zstd (FLAC encoding goes through libFLAC: a build with `codecs`).
|
||||
# The quickstart's `wai` is built with it alone (`--no-default-features --features floor`).
|
||||
floor = ["dep:image", "dep:claxon", "dep:zstd"]
|
||||
# What the quickstart's `wai` holds: the floor, and `wai hello` and `wai verify` (a receipt
|
||||
# sealed and checked as http-delivery §8 lays receipts out). Pure Rust and the floor's zstd:
|
||||
# no system library. `wai create` and `wai dev` need no feature.
|
||||
quickstart = ["floor", "http", "receipt_claims", "dep:getrandom"]
|
||||
# What the quickstart's `wai` holds: the floor, `wai hello` and `wai verify` (a receipt
|
||||
# sealed and checked as http-delivery §8 lays receipts out), and `wai mcp`, the server
|
||||
# `wai setup-agent` registers. Pure Rust and the floor's zstd: no system library. `wai create`,
|
||||
# `wai dev` and `wai setup-agent` need no feature.
|
||||
quickstart = ["floor", "http", "receipt_claims", "mcp", "dep:getrandom"]
|
||||
# Pulls in the ONNX runtime + zstd unpack so the native sink can
|
||||
# decode `wai.neural.<...>` envelopes. Off by default — the bare
|
||||
# container + zeroth codecs build with no ML dep chain.
|
||||
|
|
@ -706,6 +707,13 @@ pq_signatures = ["key_discovery", "dep:ml-dsa"]
|
|||
# http-delivery §8.4): rustls with the operating system's trust store. Built into the
|
||||
# binary only; without it the CLI refuses to anchor rather than fetch over plaintext.
|
||||
cli_tls = ["http", "key_discovery", "dep:rustls", "dep:rustls-platform-verifier"]
|
||||
# `wai mcp`: WAI's operations as tools for a coding agent, a Model Context Protocol server on
|
||||
# standard input and output (src/bin/wai_cli/mcp/), each tool named by its operation, in toolsets
|
||||
# (`core`, `dev`). Built into the binary only: the verifiers it
|
||||
# serves (delivery receipts and claims, live rooms, stream audit bundles) and Ed25519 for the
|
||||
# development room's test principals. It opens no connection but to a room service on a loopback
|
||||
# address; no new dependency.
|
||||
mcp = ["receipt_claims", "live_room", "stream_audit", "ed25519"]
|
||||
# Emit a REAL C2PA manifest (not just the assertion shapes). Off by default:
|
||||
# the SDK is a large dependency chain and most WAI consumers only need the
|
||||
# binding types, which `provenance` already gives them.
|
||||
|
|
@ -1491,6 +1499,12 @@ name = "cli_http"
|
|||
path = "tests/cli_http.rs"
|
||||
required-features = ["http"]
|
||||
|
||||
# `wai mcp` driven by an MCP client over its standard input and output: every tool.
|
||||
[[test]]
|
||||
name = "cli_mcp"
|
||||
path = "tests/cli_mcp.rs"
|
||||
required-features = ["mcp"]
|
||||
|
||||
[[test]]
|
||||
name = "int_kernels_vectors"
|
||||
path = "tests/int_kernels_vectors.rs"
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@ mod score;
|
|||
#[path = "wai_cli/encode.rs"]
|
||||
mod platform_encode_cli;
|
||||
|
||||
// The developer's path: `hello`, `create`, `dev`, `verify`, one module a verb, named in one
|
||||
// table (dx::VERBS).
|
||||
// Every verb kept in a module of its own, named in one table (dx::VERBS): the developer's
|
||||
// path (`hello`, `create`, `dev`, `verify`) and the agent door (`mcp`, `setup-agent`).
|
||||
#[path = "wai_cli/dx/mod.rs"]
|
||||
mod dx;
|
||||
|
||||
|
|
@ -634,7 +634,7 @@ fn license_str(cap: &str) -> &'static str {
|
|||
/// than `create`: `replicate` is refused over it (§7), and so is any other, so its
|
||||
/// envelope is written with `--intent create`.
|
||||
fn wrap(cap: &str, input: &str, out: &str, fallback: Option<&str>,
|
||||
intent: Option<&str>, companions: &[(String, String)]) -> Result<(), String> {
|
||||
intent: Option<&str>, companions: &[(String, String)]) -> Result<Wrapped, String> {
|
||||
if !wai::codecs::is_known_capability(cap) {
|
||||
return Err(format!("'{cap}' is not a registered capability (see `wai capabilities`)"));
|
||||
}
|
||||
|
|
@ -694,10 +694,25 @@ fn wrap(cap: &str, input: &str, out: &str, fallback: Option<&str>,
|
|||
})?;
|
||||
let n = envelope.write(out).map_err(|e| e.to_string())?;
|
||||
let tier = wai::codecs::capability_tier(cap).map(|t| format!("{t:?}")).unwrap_or_else(|| "?".into());
|
||||
println!("wrapped → {out} ({n} B envelope, {n_in} B payload)");
|
||||
println!(" capability {cap} · media {} · {tier} · {} · intent {intent}",
|
||||
media_for(cap), license_str(cap));
|
||||
Ok(())
|
||||
Ok(Wrapped {
|
||||
lines: vec![
|
||||
format!("wrapped → {out} ({n} B envelope, {n_in} B payload)"),
|
||||
format!(" capability {cap} · media {} · {tier} · {} · intent {intent}", media_for(cap), license_str(cap)),
|
||||
],
|
||||
facts: serde_json::json!({
|
||||
"output": out, "format": "WAI1", "envelope_bytes": n, "intent": intent, "media": media_for(cap),
|
||||
"components": [{"index": 0, "capability": cap, "role": "rendition", "bytes": n_in, "licence": license_str(cap), "tier": tier}],
|
||||
"fallback": fallback,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// What `wrap` wrote: the lines the command prints, and the same facts as JSON for a caller
|
||||
/// that reads them (`wai mcp`'s packaging tool).
|
||||
struct Wrapped {
|
||||
lines: Vec<String>,
|
||||
#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
|
||||
facts: serde_json::Value,
|
||||
}
|
||||
|
||||
/// SPEC §5, *Payload of `wai.generate.spectral`*: the payload of that string is an
|
||||
|
|
@ -758,7 +773,7 @@ fn generate_lines(found: &[String]) -> Vec<String> {
|
|||
/// The `WAI2` half of [`wrap`]: the base payload as the one selectable
|
||||
/// rendition, each companion bound to it.
|
||||
fn wrap_with_companions(cap: &str, payload: Vec<u8>, out: &str, fallback: Option<&str>,
|
||||
intent: &str, companions: &[(String, String)]) -> Result<(), String> {
|
||||
intent: &str, companions: &[(String, String)]) -> Result<Wrapped, String> {
|
||||
if fallback.is_some() {
|
||||
return Err("--fallback names a WAI1 alternative; a WAI2 envelope carries alternatives \
|
||||
as renditions (SPEC \u{a7}7.1)".into());
|
||||
|
|
@ -790,23 +805,41 @@ fn wrap_with_companions(cap: &str, payload: Vec<u8>, out: &str, fallback: Option
|
|||
envelope.check_component_roles()
|
||||
.map_err(|e| format!("{e} (SPEC \u{a7}5, \u{a7}7.1)"))?;
|
||||
let n = envelope.write(out).map_err(|e| e.to_string())?;
|
||||
println!("wrapped → {out} ({n} B WAI2 envelope, {} components, intent {intent})",
|
||||
envelope.renditions.len());
|
||||
print_components(&envelope);
|
||||
Ok(())
|
||||
let mut lines = vec![format!("wrapped → {out} ({n} B WAI2 envelope, {} components, intent {intent})",
|
||||
envelope.renditions.len())];
|
||||
lines.extend(component_lines(&envelope));
|
||||
let components: Vec<serde_json::Value> = envelope.manifest.renditions.iter().zip(envelope.renditions.iter()).enumerate()
|
||||
.map(|(i, (meta, p))| serde_json::json!({
|
||||
"index": i, "capability": meta.capability, "bytes": p.len(), "licence": license_str(&meta.capability),
|
||||
"role": if meta.is_companion() { format!("companion of {}", meta.companion_of.as_deref().unwrap_or("?")) } else { "rendition".into() },
|
||||
}))
|
||||
.collect();
|
||||
Ok(Wrapped {
|
||||
lines,
|
||||
facts: serde_json::json!({"output": out, "format": "WAI2", "envelope_bytes": n, "intent": intent, "media": media_for(cap), "components": components}),
|
||||
})
|
||||
}
|
||||
|
||||
/// One line per `WAI2` component: index, capability, role, size, licence.
|
||||
fn print_components(m: &wai::container::WaiMulti) {
|
||||
for l in component_lines(m) {
|
||||
println!("{l}");
|
||||
}
|
||||
}
|
||||
|
||||
/// [`print_components`]'s lines.
|
||||
fn component_lines(m: &wai::container::WaiMulti) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
for (i, (meta, p)) in m.manifest.renditions.iter().zip(m.renditions.iter()).enumerate() {
|
||||
let role = match (meta.is_companion(), meta.companion_of.as_deref()) {
|
||||
(true, Some(of)) => format!("companion of {of}"),
|
||||
(true, None) => "companion (unbound)".into(),
|
||||
(false, _) => "rendition".into(),
|
||||
};
|
||||
println!(" [{i}] {:<26} {:<30} {:>10} B {}", meta.capability, role, p.len(),
|
||||
license_str(&meta.capability));
|
||||
lines.push(format!(" [{i}] {:<26} {:<30} {:>10} B {}", meta.capability, role, p.len(),
|
||||
license_str(&meta.capability)));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// `wai unwrap <in.wai> [-o payload] [--component <i>]` — parse the envelope,
|
||||
|
|
@ -976,8 +1009,7 @@ fn main() -> ExitCode {
|
|||
}
|
||||
if let Some(r) = dx::dispatch(&args) {
|
||||
return match r {
|
||||
Ok(true) => ExitCode::SUCCESS,
|
||||
Ok(false) => ExitCode::from(1),
|
||||
Ok(code) => ExitCode::from(code),
|
||||
Err(e) => die(e),
|
||||
};
|
||||
}
|
||||
|
|
@ -1008,7 +1040,8 @@ fn main() -> ExitCode {
|
|||
let out = opt(&args[3..], "-o").map(String::from).unwrap_or("out.wai".into());
|
||||
match companion_args(&args[3..]) {
|
||||
Ok(comps) => wrap(&args[1], &args[2], &out, opt(&args[3..], "--fallback"),
|
||||
opt(&args[3..], "--intent"), &comps),
|
||||
opt(&args[3..], "--intent"), &comps)
|
||||
.map(|w| w.lines.iter().for_each(|l| println!("{l}"))),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,8 +213,9 @@ fn find_web(flag_value: Option<&str>, exe_dir: Option<&Path>) -> Result<PathBuf,
|
|||
))
|
||||
}
|
||||
|
||||
/// A binary: the flag, the environment variable, beside this binary, then `$PATH`.
|
||||
fn find_bin(name: &str, flag_value: Option<&str>, env: &str, exe_dir: Option<&Path>) -> Result<PathBuf, String> {
|
||||
/// A binary: the flag, the environment variable, beside this binary, then `$PATH`. `wai mcp`'s
|
||||
/// development room finds the room service the same way.
|
||||
pub(super) fn find_bin(name: &str, flag_value: Option<&str>, env: &str, exe_dir: Option<&Path>) -> Result<PathBuf, String> {
|
||||
if let Some(p) = flag_value.map(PathBuf::from).or_else(|| std::env::var_os(env).map(PathBuf::from)) {
|
||||
return if p.is_file() { Ok(p) } else { Err(format!("{name}: {} is not a file", p.display())) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
//! The developer's path through WAI, one verb a module, every verb in [`VERBS`]:
|
||||
//! Every verb kept in a module of its own, one verb a module, every verb in [`VERBS`]:
|
||||
//!
|
||||
//! - `hello`: the four steps of WAI (name, dispatch, rebuild, receipt) on a bundled sample;
|
||||
//! - `create`: a new project from one of the examples (`wai/examples/`);
|
||||
//! - `dev`: a relay, a room service and the room page on one origin, on this machine;
|
||||
//! - `verify`: the receipts under a directory, checked offline.
|
||||
//! - `verify`: the receipts under a directory, checked offline;
|
||||
//! - `mcp` (`agent.mcp`): WAI's operations as tools for a coding agent, a Model Context
|
||||
//! Protocol server on standard input and output, in toolsets (`../mcp/`);
|
||||
//! - `setup-agent` (`agent.setup`): a project made ready for coding agents (`../setup_agent.rs`).
|
||||
//!
|
||||
//! A verb is named in this table and nowhere else: renaming or regrouping one is a line
|
||||
//! here. The project file a template writes (`wai.json`) is read by [`project`] alone.
|
||||
|
|
@ -15,25 +18,62 @@ mod dev;
|
|||
mod hello;
|
||||
mod verify;
|
||||
|
||||
/// One verb: its name, its one-line usage, and what runs it. `run` returns `Ok(true)` when
|
||||
/// everything it checked held, `Ok(false)` when it ran and something did not (exit 1).
|
||||
#[cfg(feature = "mcp")]
|
||||
#[path = "../mcp/mod.rs"]
|
||||
mod mcp;
|
||||
#[path = "../setup_agent.rs"]
|
||||
mod setup_agent;
|
||||
|
||||
/// The verb `wai mcp` is called by; `setup-agent` registers the server under it.
|
||||
pub const MCP: &str = "mcp";
|
||||
|
||||
/// How a verb ends.
|
||||
pub enum Run {
|
||||
/// `Ok(true)` when everything it checked held (exit 0), `Ok(false)` when it ran and
|
||||
/// something did not (exit 1); an error is a usage error (exit 2).
|
||||
Checks(fn(&[String]) -> Result<bool, String>),
|
||||
/// An exit status of the toolchain's table (0 done, 1 a check failed, 2 usage, 3
|
||||
/// permission, 4 confirmation required, 5 not found, 6 unavailable, 7 internal), the
|
||||
/// verb's output already written.
|
||||
Status(fn(&[String]) -> u8),
|
||||
}
|
||||
|
||||
/// One verb: its name, its usage, and what runs it.
|
||||
pub struct Verb {
|
||||
pub name: &'static str,
|
||||
pub usage: &'static str,
|
||||
pub run: fn(&[String]) -> Result<bool, String>,
|
||||
pub run: Run,
|
||||
}
|
||||
|
||||
pub const VERBS: &[Verb] = &[
|
||||
Verb { name: "hello", usage: hello::USAGE, run: hello::main },
|
||||
Verb { name: "create", usage: create::USAGE, run: create::main },
|
||||
Verb { name: "dev", usage: dev::USAGE, run: dev::main },
|
||||
Verb { name: "verify", usage: verify::USAGE, run: verify::main },
|
||||
Verb { name: "hello", usage: hello::USAGE, run: Run::Checks(hello::main) },
|
||||
Verb { name: "create", usage: create::USAGE, run: Run::Checks(create::main) },
|
||||
Verb { name: "dev", usage: dev::USAGE, run: Run::Checks(dev::main) },
|
||||
Verb { name: "verify", usage: verify::USAGE, run: Run::Checks(verify::main) },
|
||||
Verb { name: MCP, usage: MCP_USAGE, run: Run::Checks(mcp_main) },
|
||||
Verb { name: "setup-agent", usage: setup_agent::USAGE, run: Run::Status(setup_agent::main) },
|
||||
];
|
||||
|
||||
/// The verb `args[0]` names, run on the rest; `None` when it names none of these.
|
||||
pub fn dispatch(args: &[String]) -> Option<Result<bool, String>> {
|
||||
const MCP_USAGE: &str = "wai mcp [--toolset core,dev] [--read-only] [--project DIR] [--spec-root DIR] [--room-bin BIN]\n WAI's operations as tools for a coding agent: a Model Context Protocol server on standard\n input and output (a build with `mcp`)";
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
fn mcp_main(args: &[String]) -> Result<bool, String> {
|
||||
mcp::main(args)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
fn mcp_main(_: &[String]) -> Result<bool, String> {
|
||||
Err(format!("`wai {MCP}` needs a build with the feature `mcp` (cargo install --path wai/wai-rs --no-default-features --features mcp)"))
|
||||
}
|
||||
|
||||
/// The verb `args[0]` names, run on the rest, and its exit status; `None` when it names none
|
||||
/// of these.
|
||||
pub fn dispatch(args: &[String]) -> Option<Result<u8, String>> {
|
||||
let v = VERBS.iter().find(|v| Some(v.name) == args.first().map(String::as_str))?;
|
||||
Some((v.run)(&args[1..]))
|
||||
Some(match v.run {
|
||||
Run::Checks(run) => run(&args[1..]).map(|held| if held { 0 } else { 1 }),
|
||||
Run::Status(run) => Ok(run(&args[1..])),
|
||||
})
|
||||
}
|
||||
|
||||
/// Each verb's usage line, for `wai`'s own usage.
|
||||
|
|
|
|||
335
wai/wai-rs/src/bin/wai_cli/mcp/dev_room.rs
Normal file
335
wai/wai-rs/src/bin/wai_cli/mcp/dev_room.rs
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
//! The `dev` toolset: a relay and a room service on loopback, run as the room service's `dev`
|
||||
//! mode (`joule-moq-room dev`, a child process driven by JSON control lines) by `dev_start` and
|
||||
//! `dev_stop`; and on that room, the target `local`, grants minted for test principals
|
||||
//! (`room_grant`), those principals joining, publishing and leaving (`room_join`, `room_leave`),
|
||||
//! and the room's session as a verifier is given it (`room_session`).
|
||||
//!
|
||||
//! **Development only.** The room binds 127.0.0.1 alone and its keys live only in the child
|
||||
//! process. A grant is asked for over HTTP from a service whose address is a loopback address,
|
||||
//! and from no other: the guard is [`loopback_service`], applied before any connection is made,
|
||||
//! with no name resolved. A test principal's key is SHA-256 of its name under a fixed label, so
|
||||
//! the same name is the same key in every run, and is never a key anyone should hold.
|
||||
|
||||
use super::tools::{name_of, opt_str, str_arg, unhex};
|
||||
use super::Server;
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::mpsc::{channel, Receiver};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long the room has to start, and each control line to be answered (a join waits for the
|
||||
/// clock's records, up to 20 s, inside the child).
|
||||
const START: Duration = Duration::from_secs(60);
|
||||
const ANSWER: Duration = Duration::from_secs(45);
|
||||
|
||||
/// A test principal: its name's key, and the grant last minted for it.
|
||||
pub(crate) struct Principal {
|
||||
seed: [u8; 32],
|
||||
public: [u8; 32],
|
||||
grant: Option<(String, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl Principal {
|
||||
fn named(name: &str) -> Principal {
|
||||
let mut h = Sha256::new();
|
||||
h.update(b"wai mcp development principal\x00");
|
||||
h.update(name.as_bytes());
|
||||
let seed: [u8; 32] = h.finalize().into();
|
||||
let public = ed25519_dalek::SigningKey::from_bytes(&seed).verifying_key().to_bytes();
|
||||
Principal { seed, public, grant: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// The running development room: the child, its control channel and its `ready` line.
|
||||
pub(crate) struct Dev {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
lines: Receiver<String>,
|
||||
ready: Value,
|
||||
next: u64,
|
||||
}
|
||||
|
||||
impl Dev {
|
||||
fn spawn(bin: &PathBuf, publishers: u64) -> Result<Dev, String> {
|
||||
let mut child = Command::new(bin)
|
||||
.arg("dev")
|
||||
.arg("--publishers")
|
||||
.arg(publishers.to_string())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.map_err(|e| format!("{}: {e}", bin.display()))?;
|
||||
let stdin = child.stdin.take().expect("piped");
|
||||
let stdout = child.stdout.take().expect("piped");
|
||||
let (tx, lines) = channel();
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(stdout).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut d = Dev { child, stdin, lines, ready: Value::Null, next: 1 };
|
||||
let first = d.lines.recv_timeout(START).map_err(|_| {
|
||||
let _ = d.child.kill();
|
||||
format!("{} dev did not start within {} s", bin.display(), START.as_secs())
|
||||
})?;
|
||||
let ready: Value = serde_json::from_str(&first).map_err(|e| format!("the room's first line is not JSON: {e}"))?;
|
||||
if ready["ready"] != json!(true) {
|
||||
let _ = d.child.kill();
|
||||
return Err(format!("the room did not start: {}", ready["error"].as_str().unwrap_or(&first)));
|
||||
}
|
||||
d.ready = ready;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// One control line and its answer.
|
||||
fn ask(&mut self, mut line: Value) -> Result<Value, String> {
|
||||
let id = self.next;
|
||||
self.next += 1;
|
||||
line["id"] = json!(id);
|
||||
writeln!(self.stdin, "{line}").and_then(|_| self.stdin.flush()).map_err(|e| format!("the development room has stopped: {e}"))?;
|
||||
loop {
|
||||
let got = self.lines.recv_timeout(ANSWER).map_err(|_| "the development room did not answer in time".to_string())?;
|
||||
let v: Value = serde_json::from_str(&got).map_err(|e| format!("the room answered with a line that is not JSON: {e}"))?;
|
||||
if v["id"] == json!(id) {
|
||||
return if v["ok"] == json!(true) {
|
||||
let mut v = v;
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.remove("id");
|
||||
o.remove("ok");
|
||||
}
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(v["error"].as_str().unwrap_or("the room refused it").to_string())
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the room and wait for the child.
|
||||
pub(crate) fn stop(mut self) {
|
||||
let _ = writeln!(self.stdin, "{}", json!({"op": "stop"}));
|
||||
let _ = self.stdin.flush();
|
||||
for _ in 0..100 {
|
||||
if let Ok(Some(_)) = self.child.try_wait() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn dev_mut(server: &mut Server) -> Result<&mut Dev, String> {
|
||||
server.dev.as_mut().ok_or_else(|| format!("no development room is running: {} starts one", name_of(start)))
|
||||
}
|
||||
|
||||
/// The room service program: the argument, `--room-bin` or `WAI_ROOM_BIN`, or `joule-moq-room`
|
||||
/// beside this binary, then on `PATH`, as `wai dev` finds it.
|
||||
fn room_bin(server: &Server, args: &Value) -> Result<PathBuf, String> {
|
||||
let given = opt_str(args, "room_bin").map(String::from).or_else(|| server.room_bin.as_ref().map(|p| p.display().to_string()));
|
||||
let exe_dir = std::env::current_exe().ok().and_then(|e| std::fs::canonicalize(e).ok()).and_then(|e| e.parent().map(Path::to_path_buf));
|
||||
super::super::dev::find_bin("joule-moq-room", given.as_deref(), "WAI_ROOM_BIN", exe_dir.as_deref()).map_err(|e| {
|
||||
format!("{e}. The development room runs the room service joule-moq-room (built from joule-code/joule-code-rs/crates/joule-moq-room): give room_bin, --room-bin or WAI_ROOM_BIN")
|
||||
})
|
||||
}
|
||||
|
||||
/// The tools of this set act on the target `local` alone: a target named, if any, must be it.
|
||||
fn local_target(args: &Value) -> Result<(), String> {
|
||||
match args.get("target") {
|
||||
None => Ok(()),
|
||||
Some(Value::String(t)) if t == "local" => Ok(()),
|
||||
Some(t) => Err(format!("target {t}: the development tools act on the target local alone (this machine's development room)")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start(server: &mut Server, args: &Value) -> Result<Value, String> {
|
||||
if let Some(d) = &server.dev {
|
||||
let mut v = d.ready.clone();
|
||||
v["already_running"] = json!(true);
|
||||
return Ok(v);
|
||||
}
|
||||
let bin = room_bin(server, args)?;
|
||||
let publishers = args.get("publishers").and_then(Value::as_u64).unwrap_or(4).clamp(1, 64);
|
||||
let d = Dev::spawn(&bin, publishers)?;
|
||||
let mut v = d.ready.clone();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.remove("ready");
|
||||
}
|
||||
v["development_only"] = json!(true);
|
||||
v["warning"] = json!("a development room on loopback: keys in memory, media held to no receipts, rate limits open; never serve it");
|
||||
server.dev = Some(d);
|
||||
server.principals.clear();
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
pub(crate) fn stop(server: &mut Server, _: &Value) -> Result<Value, String> {
|
||||
let d = server.dev.take().ok_or("no development room is running")?;
|
||||
d.stop();
|
||||
server.principals.clear();
|
||||
Ok(json!({"stopped": true}))
|
||||
}
|
||||
|
||||
/// The address and path prefix of a room service URL whose host is a loopback address, or
|
||||
/// the refusal. `localhost` is taken as 127.0.0.1 and no name is resolved: a service on any
|
||||
/// other host, by any name, is refused before a connection is made.
|
||||
pub(crate) fn loopback_service(url: &str) -> Result<(SocketAddr, String), String> {
|
||||
let refuse = |why: &str| {
|
||||
Err(format!(
|
||||
"DEVELOPMENT ONLY: development grants are minted on a room service on this machine alone, and {url} {why}. \
|
||||
A deployed service grants by its own rules; ask its operator."
|
||||
))
|
||||
};
|
||||
let Some(rest) = url.strip_prefix("http://") else { return refuse("is not an http:// address on loopback") };
|
||||
let (authority, prefix) = match rest.find('/') {
|
||||
Some(i) => (&rest[..i], rest[i..].trim_end_matches('/').to_string()),
|
||||
None => (rest, String::new()),
|
||||
};
|
||||
if authority.contains('@') {
|
||||
return refuse("carries credentials");
|
||||
}
|
||||
let (host, port) = match authority.rsplit_once(':') {
|
||||
Some((h, p)) if !h.is_empty() && !p.contains(']') => (h, p.parse::<u16>().map_err(|_| format!("{url}: a port"))?),
|
||||
_ => (authority, 80),
|
||||
};
|
||||
let ip: IpAddr = match host {
|
||||
"localhost" => IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
h if h.starts_with('[') && h.ends_with(']') => match h[1..h.len() - 1].parse::<Ipv6Addr>() {
|
||||
Ok(a) => IpAddr::V6(a),
|
||||
Err(_) => return refuse("does not name a loopback address"),
|
||||
},
|
||||
h => match h.parse::<Ipv4Addr>() {
|
||||
Ok(a) => IpAddr::V4(a),
|
||||
Err(_) => return refuse("does not name a loopback address"),
|
||||
},
|
||||
};
|
||||
if !ip.is_loopback() {
|
||||
return refuse("is not on a loopback address");
|
||||
}
|
||||
Ok((SocketAddr::new(ip, port), prefix))
|
||||
}
|
||||
|
||||
/// One HTTP/1.1 POST of JSON to a loopback service: its status and its JSON.
|
||||
fn post(addr: SocketAddr, path: &str, body: &Value) -> Result<(u16, Value), String> {
|
||||
let mut s = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| format!("{addr}: {e}"))?;
|
||||
s.set_read_timeout(Some(Duration::from_secs(20))).map_err(|e| e.to_string())?;
|
||||
let b = body.to_string();
|
||||
write!(s, "POST {path} HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{b}", b.len()).map_err(|e| e.to_string())?;
|
||||
let mut out = Vec::new();
|
||||
s.read_to_end(&mut out).map_err(|e| format!("{addr}: {e}"))?;
|
||||
let text = String::from_utf8_lossy(&out);
|
||||
let (head, body) = text.split_once("\r\n\r\n").ok_or("the service's answer is not HTTP")?;
|
||||
let status = head.split(' ').nth(1).and_then(|s| s.parse().ok()).ok_or("the service's answer has no status")?;
|
||||
Ok((status, serde_json::from_str(body).unwrap_or(Value::Null)))
|
||||
}
|
||||
|
||||
pub(crate) fn grant(server: &mut Server, args: &Value) -> Result<Value, String> {
|
||||
local_target(args)?;
|
||||
let name = str_arg(args, "principal")?.to_string();
|
||||
let role = str_arg(args, "role")?;
|
||||
if !matches!(role, "publisher" | "viewer") {
|
||||
return Err("role: publisher or viewer".into());
|
||||
}
|
||||
let service = match opt_str(args, "service") {
|
||||
Some(s) => s.to_string(),
|
||||
None => dev_mut(server)?.ready["service"].as_str().unwrap_or_default().to_string(),
|
||||
};
|
||||
// The guard: nothing is sent unless the service is on this machine.
|
||||
let (addr, prefix) = loopback_service(&service)?;
|
||||
let p = server.principals.entry(name.clone()).or_insert_with(|| Principal::named(&name));
|
||||
let (status, v) = post(addr, &format!("{prefix}/grant"), &json!({"principal": super::tools::hex(&p.public), "role": role}))?;
|
||||
if status != 200 {
|
||||
return Err(format!("the service refused the grant: {status} {v}"));
|
||||
}
|
||||
let g = v["grant"].as_str().and_then(unhex).ok_or("the service's answer holds no grant")?;
|
||||
p.grant = Some((role.to_string(), g));
|
||||
Ok(json!({
|
||||
"development_only": true,
|
||||
"target": "local",
|
||||
"principal": name,
|
||||
"key": super::tools::hex(&p.public),
|
||||
"role": role,
|
||||
"grant": v["grant"],
|
||||
"digest": v["digest"],
|
||||
"exp": v["exp"],
|
||||
"room": v["room"]["room"],
|
||||
"service": service,
|
||||
"note": "a test principal's key is derived from its name: it is never a key to hold",
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn join(server: &mut Server, args: &Value) -> Result<Value, String> {
|
||||
local_target(args)?;
|
||||
let name = str_arg(args, "principal")?.to_string();
|
||||
let objects = args.get("objects").and_then(Value::as_u64).unwrap_or(1).clamp(1, 64);
|
||||
let (seed, grant) = {
|
||||
let p = server.principals.get(&name).ok_or_else(|| format!("{name} holds no grant: {} mints one", name_of(grant)))?;
|
||||
match &p.grant {
|
||||
Some((role, g)) if role == "publisher" => (p.seed, g.clone()),
|
||||
_ => return Err(format!("{name} holds no publisher's grant: {} mints one with role publisher", name_of(grant))),
|
||||
}
|
||||
};
|
||||
let mut v = dev_mut(server)?.ask(json!({"op": "join", "seed": super::tools::hex(&seed), "grant": super::tools::hex(&grant), "objects": objects}))?;
|
||||
v["development_only"] = json!(true);
|
||||
v["name"] = json!(name);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
pub(crate) fn leave(server: &mut Server, args: &Value) -> Result<Value, String> {
|
||||
local_target(args)?;
|
||||
let name = str_arg(args, "principal")?.to_string();
|
||||
let public = server.principals.get(&name).map(|p| p.public).ok_or_else(|| format!("no test principal {name}"))?;
|
||||
let mut v = dev_mut(server)?.ask(json!({"op": "leave", "principal": super::tools::hex(&public)}))?;
|
||||
v["development_only"] = json!(true);
|
||||
v["name"] = json!(name);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
pub(crate) fn session(server: &mut Server, args: &Value) -> Result<Value, String> {
|
||||
local_target(args)?;
|
||||
let mut v = dev_mut(server)?.ask(json!({"op": "session"}))?;
|
||||
v["development_only"] = json!(true);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_a_loopback_service_is_taken() {
|
||||
for ok in ["http://127.0.0.1:9682", "http://127.0.0.1:9682/room-api", "http://localhost:1/", "http://[::1]:80", "http://127.9.9.9"] {
|
||||
assert!(loopback_service(ok).is_ok(), "{ok}");
|
||||
}
|
||||
assert_eq!(loopback_service("http://127.0.0.1:9682/room-api/").unwrap().1, "/room-api");
|
||||
for no in [
|
||||
"https://127.0.0.1:9682",
|
||||
"http://10.0.0.5:9682",
|
||||
"http://192.168.4.37",
|
||||
"http://room.example",
|
||||
"http://localhost.example:80",
|
||||
"http://127.0.0.1.example",
|
||||
"http://[email protected]:80",
|
||||
"http://[2001:db8::1]:80",
|
||||
"ws://127.0.0.1:1",
|
||||
"127.0.0.1:9682",
|
||||
] {
|
||||
let e = loopback_service(no).expect_err(no);
|
||||
assert!(e.starts_with("DEVELOPMENT ONLY"), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_is_the_same_key_every_time() {
|
||||
assert_eq!(Principal::named("alice").public, Principal::named("alice").public);
|
||||
assert_ne!(Principal::named("alice").public, Principal::named("bob").public);
|
||||
}
|
||||
}
|
||||
240
wai/wai-rs/src/bin/wai_cli/mcp/mod.rs
Normal file
240
wai/wai-rs/src/bin/wai_cli/mcp/mod.rs
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
//! `wai mcp` (catalogue `agent.mcp`): WAI's operations as tools for a coding assistant, served
|
||||
//! over the Model Context Protocol (revision 2025-06-18) on standard input and output, one
|
||||
//! JSON-RPC message per line. Standard output carries the protocol alone; anything else goes to
|
||||
//! standard error.
|
||||
//!
|
||||
//! A tool is an operation of the toolchain's catalogue, named by it: the operation's name with
|
||||
//! `_` for the dot (`receipt.verify` is `receipt_verify`), never a name of its own. The tools
|
||||
//! (`tools.rs`, one table: an operation is one line there) come in toolsets, and a server offers
|
||||
//! the toolsets it is started with:
|
||||
//!
|
||||
//! - `core` (the default): inspect envelopes and packages, read the capability registry, verify
|
||||
//! receipts, claims, live-room sessions and audit bundles, package a media file, list and run
|
||||
//! conformance corpora, search the specification's text;
|
||||
//! - `dev`: a development room on loopback (`dev_room.rs`): start and stop it, mint grants for
|
||||
//! test principals, join and publish as them, and read the session, each on the target
|
||||
//! `local` alone.
|
||||
//!
|
||||
//! Each answers with a structured result. Nothing leaves the machine, and the only tool that
|
||||
//! takes a service's address refuses every one that is not on this machine.
|
||||
//!
|
||||
//! ```text
|
||||
//! wai mcp [--toolset <set>[,<set>...]] [--read-only] [--project <dir>] [--spec-root <wai-dir>] [--room-bin <path>]
|
||||
//! ```
|
||||
//!
|
||||
//! `--toolset` names the toolsets to offer (`core` when none is named; repeated or
|
||||
//! comma-separated). `--spec-root` (or `WAI_SPEC_ROOT`) is the `wai/` directory whose
|
||||
//! specification text and conformance corpora the tools read; by default the one this binary
|
||||
//! was built from, when it is still there. `--room-bin` (or `WAI_ROOM_BIN`) is the room service
|
||||
//! program the development room runs (`joule-moq-room`); by default the one beside this binary,
|
||||
//! then on `PATH`. `--project` is the directory the server may write in (the packaging tool's
|
||||
//! envelopes), by default the one it starts in; it writes nowhere else. `--read-only` offers only
|
||||
//! the tools that read or run a verifier: nothing that writes a file, starts a process that
|
||||
//! serves, or asks a service for a grant.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::io::{BufRead, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[path = "dev_room.rs"]
|
||||
mod dev_room;
|
||||
#[path = "tools.rs"]
|
||||
mod tools;
|
||||
|
||||
/// The protocol revisions this server speaks, newest first.
|
||||
const PROTOCOLS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
|
||||
|
||||
/// What the server tells an assistant when it connects, a sentence for each toolset it offers.
|
||||
/// It names no tool: the tools' table is the one place their names are written.
|
||||
fn instructions(toolsets: &[tools::Toolset]) -> String {
|
||||
let mut s = String::from("WAI's operations as tools, on this machine; each tool is an operation of the WAI toolchain, named by it.");
|
||||
if toolsets.contains(&tools::Toolset::Core) {
|
||||
s.push_str(" Inspect a .wai envelope or a package directory; list and show the registered capabilities and their licence \
|
||||
classes; verify a delivery receipt, a claim, a live-room session or an audit bundle (each answer is the verifier's verdict and its \
|
||||
reason code: quote it rather than judging the input yourself); package a media file into a WAI envelope; list and run conformance \
|
||||
corpora through their independent verifiers; and search the specification's text, which is the authority on every rule.");
|
||||
}
|
||||
if toolsets.contains(&tools::Toolset::Dev) {
|
||||
s.push_str(" The development tools, each marked DEVELOPMENT ONLY, start a relay and a room service on loopback with keys \
|
||||
held in memory, mint grants for test principals, join and publish as them, and read the room's session for verification; they act \
|
||||
on the target local alone and refuse any service that is not on this machine.");
|
||||
}
|
||||
s.push_str(" No tool sends anything off the machine.");
|
||||
s
|
||||
}
|
||||
|
||||
/// The server's state across calls: where it reads the specification, the room service it
|
||||
/// runs, the development room while one runs, and the name the client gave.
|
||||
pub(crate) struct Server {
|
||||
pub(crate) toolsets: Vec<tools::Toolset>,
|
||||
pub(crate) read_only: bool,
|
||||
pub(crate) project: PathBuf,
|
||||
pub(crate) spec_root: Option<PathBuf>,
|
||||
pub(crate) spec_root_from: &'static str,
|
||||
pub(crate) room_bin: Option<PathBuf>,
|
||||
pub(crate) dev: Option<dev_room::Dev>,
|
||||
pub(crate) principals: std::collections::BTreeMap<String, dev_room::Principal>,
|
||||
pub(crate) client: Option<String>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
fn new(toolsets: Vec<tools::Toolset>, spec_root: Option<PathBuf>, room_bin: Option<PathBuf>, project: PathBuf, read_only: bool) -> Server {
|
||||
let (spec_root, spec_root_from) = match spec_root {
|
||||
Some(p) => (Some(p), "--spec-root"),
|
||||
None => match std::env::var_os("WAI_SPEC_ROOT") {
|
||||
Some(p) => (Some(PathBuf::from(p)), "WAI_SPEC_ROOT"),
|
||||
None => {
|
||||
let built = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
if built.join("SPEC.md").is_file() { (Some(built), "the tree this binary was built from") } else { (None, "none") }
|
||||
}
|
||||
},
|
||||
};
|
||||
let room_bin = room_bin.or_else(|| std::env::var_os("WAI_ROOM_BIN").map(PathBuf::from));
|
||||
Server { toolsets, read_only, project, spec_root, spec_root_from, room_bin, dev: None, principals: Default::default(), client: None }
|
||||
}
|
||||
|
||||
/// `p` (relative to the project directory) as a path to write, when it is inside the
|
||||
/// project directory, its parent directory exists, and it is not a link.
|
||||
pub(crate) fn inside_project(&self, p: &std::path::Path) -> Result<PathBuf, String> {
|
||||
let abs = if p.is_absolute() { p.to_path_buf() } else { self.project.join(p) };
|
||||
let name = abs.file_name().ok_or_else(|| format!("{}: not a file name", p.display()))?;
|
||||
let parent = abs.parent().unwrap_or(&self.project).canonicalize().map_err(|e| format!("{}: {e}", p.display()))?;
|
||||
if !parent.starts_with(&self.project) {
|
||||
return Err(format!("{} is outside the project directory {}; this server writes only inside it", p.display(), self.project.display()));
|
||||
}
|
||||
let target = parent.join(name);
|
||||
if std::fs::symlink_metadata(&target).is_ok_and(|m| m.file_type().is_symlink()) {
|
||||
return Err(format!("{} is a link; this server writes only plain files inside the project directory", target.display()));
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
/// The specification's root, or why there is none.
|
||||
pub(crate) fn spec_root(&self) -> Result<PathBuf, String> {
|
||||
let root = self.spec_root.as_ref().ok_or(
|
||||
"no specification tree: start the server with --spec-root <the repository's wai/ directory>, or set WAI_SPEC_ROOT",
|
||||
)?;
|
||||
if !root.join("SPEC.md").is_file() {
|
||||
return Err(format!("{} (from {}) holds no SPEC.md: it is not a wai/ directory", root.display(), self.spec_root_from));
|
||||
}
|
||||
root.canonicalize().map_err(|e| format!("{}: {e}", root.display()))
|
||||
}
|
||||
|
||||
/// One JSON-RPC message's answer; `None` for a notification.
|
||||
pub(crate) fn handle(&mut self, msg: &Value) -> Option<Value> {
|
||||
if msg.is_array() {
|
||||
return Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "a batch is not taken (MCP 2025-06-18): send one message at a time"}}));
|
||||
}
|
||||
// A response or a notification from the client (no id): nothing to answer.
|
||||
let id = msg.get("id").cloned()?;
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
if method.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let ok = |result: Value| Some(json!({"jsonrpc": "2.0", "id": id, "result": result}));
|
||||
let err = |code: i64, message: String| Some(json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}));
|
||||
match method {
|
||||
"initialize" => {
|
||||
self.client = params.pointer("/clientInfo/name").and_then(Value::as_str).map(String::from);
|
||||
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOLS[0]);
|
||||
let version = PROTOCOLS.iter().find(|v| **v == asked).copied().unwrap_or(PROTOCOLS[0]);
|
||||
ok(json!({
|
||||
"protocolVersion": version,
|
||||
"capabilities": {"tools": {"listChanged": false}},
|
||||
"serverInfo": {"name": "wai", "title": "WAI", "version": env!("CARGO_PKG_VERSION")},
|
||||
"instructions": instructions(&self.toolsets),
|
||||
}))
|
||||
}
|
||||
"ping" => ok(json!({})),
|
||||
"tools/list" => ok(json!({"tools": tools::list(self)})),
|
||||
"tools/call" => {
|
||||
let name = params.get("name").and_then(Value::as_str).unwrap_or("");
|
||||
let args = params.get("arguments").cloned().unwrap_or(json!({}));
|
||||
if !args.is_object() {
|
||||
return err(-32602, "arguments: an object".into());
|
||||
}
|
||||
match tools::call(self, name, &args) {
|
||||
None => err(-32602, format!("no tool {name}; tools/list names them")),
|
||||
Some(Ok(v)) => {
|
||||
let text = serde_json::to_string_pretty(&v).unwrap_or_default();
|
||||
ok(json!({"content": [{"type": "text", "text": text}], "structuredContent": v, "isError": false}))
|
||||
}
|
||||
Some(Err(e)) => ok(json!({"content": [{"type": "text", "text": e}], "isError": true})),
|
||||
}
|
||||
}
|
||||
other => err(-32601, format!("no method {other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
if let Some(d) = self.dev.take() {
|
||||
d.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn usage() -> String {
|
||||
format!(
|
||||
"usage: wai mcp [--toolset <set>[,<set>...]] [--read-only] [--project <dir>] [--spec-root <wai-dir>] [--room-bin <path>]\n\
|
||||
toolsets: {} (core when none is named)",
|
||||
tools::Toolset::ALL.iter().map(|t| t.name()).collect::<Vec<_>>().join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
/// `wai mcp`: serve until standard input closes.
|
||||
pub fn main(args: &[String]) -> Result<bool, String> {
|
||||
let mut toolsets: Vec<tools::Toolset> = Vec::new();
|
||||
let mut spec_root = None;
|
||||
let mut room_bin = None;
|
||||
let mut project = None;
|
||||
let mut read_only = false;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match (args[i].as_str(), args.get(i + 1)) {
|
||||
("--read-only", _) => {
|
||||
read_only = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
("--toolset", Some(v)) => {
|
||||
for name in v.split(',').map(str::trim).filter(|n| !n.is_empty()) {
|
||||
let t = tools::Toolset::named(name).ok_or_else(|| format!("--toolset {name}: no such toolset\n{}", usage()))?;
|
||||
if !toolsets.contains(&t) {
|
||||
toolsets.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
("--spec-root", Some(v)) => spec_root = Some(PathBuf::from(v)),
|
||||
("--room-bin", Some(v)) => room_bin = Some(PathBuf::from(v)),
|
||||
("--project", Some(v)) => project = Some(PathBuf::from(v)),
|
||||
_ => return Err(usage()),
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
if toolsets.is_empty() {
|
||||
toolsets.push(tools::Toolset::Core);
|
||||
}
|
||||
let project = project.map_or_else(std::env::current_dir, Ok).and_then(|p| p.canonicalize()).map_err(|e| format!("the project directory: {e}"))?;
|
||||
let mut server = Server::new(toolsets, spec_root, room_bin, project, read_only);
|
||||
let stdin = std::io::stdin();
|
||||
let mut out = std::io::stdout().lock();
|
||||
for line in stdin.lock().lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => server.handle(&msg),
|
||||
Err(e) => Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": format!("not JSON: {e}")}})),
|
||||
};
|
||||
if let Some(r) = reply
|
||||
&& writeln!(out, "{r}").and_then(|_| out.flush()).is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
920
wai/wai-rs/src/bin/wai_cli/mcp/tools.rs
Normal file
920
wai/wai-rs/src/bin/wai_cli/mcp/tools.rs
Normal file
|
|
@ -0,0 +1,920 @@
|
|||
//! The tools `wai mcp` serves, in one table ([`TOOLS`]): each tool's operation (its name in the
|
||||
//! toolchain's catalogue), its toolset, title, one-line description, input schema, its safety
|
||||
//! hints and the function that runs it. A tool's name is its operation's, with `_` for the dot
|
||||
//! (`wai::ops::mcp_tool_name`); nothing else names it.
|
||||
|
||||
use super::dev_room;
|
||||
use super::Server;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The toolsets a server can offer. `quantum` is not among them: the quantum command line's
|
||||
/// own server (`wai-quantum mcp`) serves those tools.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Toolset {
|
||||
/// Inspect, the registry, verify, package, conformance, the specification's text.
|
||||
Core,
|
||||
/// The development room on loopback, on the target `local` alone.
|
||||
Dev,
|
||||
}
|
||||
|
||||
impl Toolset {
|
||||
pub(crate) const ALL: &'static [Toolset] = &[Toolset::Core, Toolset::Dev];
|
||||
|
||||
pub(crate) fn name(self) -> &'static str {
|
||||
match self {
|
||||
Toolset::Core => "core",
|
||||
Toolset::Dev => "dev",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn named(name: &str) -> Option<Toolset> {
|
||||
Toolset::ALL.iter().copied().find(|t| t.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a tool does to the machine, for the client's safety prompts and for `--read-only`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Effect {
|
||||
/// Reads only.
|
||||
Reads,
|
||||
/// Runs a local program that writes nothing (a conformance verifier).
|
||||
Runs,
|
||||
/// Writes a file inside the project directory.
|
||||
Writes,
|
||||
/// Starts or drives the development room on loopback.
|
||||
DevRoom,
|
||||
}
|
||||
|
||||
pub(crate) struct Tool {
|
||||
/// The operation, as the catalogue names it (`noun.verb`).
|
||||
pub op: &'static str,
|
||||
pub toolset: Toolset,
|
||||
pub title: &'static str,
|
||||
pub description: &'static str,
|
||||
pub schema: fn() -> Value,
|
||||
pub effect: Effect,
|
||||
pub run: fn(&mut Server, &Value) -> Result<Value, String>,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
/// The tool's name: its operation's, with `_` for the dot.
|
||||
pub(crate) fn name(&self) -> String {
|
||||
wai::ops::mcp_tool_name(self.op)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every tool, in the order `tools/list` gives them.
|
||||
pub(crate) const TOOLS: &[Tool] = &[
|
||||
Tool { op: "envelope.inspect", toolset: Toolset::Core, title: "Inspect a WAI envelope or package", effect: Effect::Reads, schema: s_path, run: envelope_inspect,
|
||||
description: "What a .wai envelope (WAI1 or WAI2) or a package directory holds: its media and intent, each component's capability with its licence class, fidelity tier and role, its pin, and the envelope's conformance checks." },
|
||||
Tool { op: "registry.list", toolset: Toolset::Core, title: "List registered capabilities", effect: Effect::Reads, schema: s_list_caps, run: registry_list,
|
||||
description: "The capability registry, filtered by media, licence class, kind, status or a substring: each entry's capability string, media, role, tier, licence and replicate class, and the SPEC section that defines it." },
|
||||
Tool { op: "registry.show", toolset: Toolset::Core, title: "Show one capability", effect: Effect::Reads, schema: s_cap, run: registry_show,
|
||||
description: "One capability's registry entry, whether this build decodes it natively, whether it is dispatched standalone, companion-only or a candidate, and the specification lines that name it." },
|
||||
Tool { op: "receipt.verify", toolset: Toolset::Core, title: "Verify a receipt, a claim, a room session or an audit bundle", effect: Effect::Reads, schema: s_verify, run: receipt_verify,
|
||||
description: "Verify, by kind (given, or read from the arguments): a JWP delivery receipt (jwp-receipts §2.2 to §3, a group receipt or an object receipt against its group), a claim receipt (§2.4, §3.1, with the context given), a live-room session (live-rooms §9) or a stream audit bundle (stream-audit §9, §10). The verifier's verdict, and the step and reason code it refuses at." },
|
||||
Tool { op: "package.build", toolset: Toolset::Core, title: "Package a media file", effect: Effect::Writes, schema: s_package, run: package_build,
|
||||
description: "Carry a media file's bytes in a WAI envelope under a registered capability, with an optional fallback or companions, as `wai wrap` does and under the same rules; writes the envelope inside the project directory and returns what it holds." },
|
||||
Tool { op: "conformance.list", toolset: Toolset::Core, title: "List conformance corpora", effect: Effect::Reads, schema: s_none, run: conformance_list,
|
||||
description: "The conformance corpora in the specification tree and the independent verifier that checks each, as CI runs them." },
|
||||
Tool { op: "conformance.run", toolset: Toolset::Core, title: "Run a conformance corpus", effect: Effect::Runs, schema: s_corpus, run: conformance_run,
|
||||
description: "Run a corpus's independent verifier (python3 -I <verifier> <corpus>) on this machine and return whether it passed, its exit code, its time and the end of its output." },
|
||||
Tool { op: "docs.search", toolset: Toolset::Core, title: "Search the specification", effect: Effect::Reads, schema: s_search, run: docs_search,
|
||||
description: "Search SPEC.md, REGISTRY.md and every extension for lines holding all the query's words: each hit's file, line, section heading and text, for quoting with its section." },
|
||||
Tool { op: "dev.start", toolset: Toolset::Dev, title: "Start a development room", effect: Effect::DevRoom, schema: s_dev_start, run: dev_room::start,
|
||||
description: "DEVELOPMENT ONLY. Start a relay and a room service on loopback (127.0.0.1) with keys held in memory, with its room; returns its service and relay addresses and the room id. Never for deployment." },
|
||||
Tool { op: "dev.stop", toolset: Toolset::Dev, title: "Stop the development room", effect: Effect::DevRoom, schema: s_none, run: dev_room::stop,
|
||||
description: "DEVELOPMENT ONLY. Stop the development relay and room service this server started." },
|
||||
Tool { op: "room.grant", toolset: Toolset::Dev, title: "Mint a development grant", effect: Effect::DevRoom, schema: s_room_grant, run: dev_room::grant,
|
||||
description: "DEVELOPMENT ONLY. On the target local: ask a room service on this machine for a grant for a named test principal, whose key is derived from its name; refuses any service that is not on a loopback address." },
|
||||
Tool { op: "room.join", toolset: Toolset::Dev, title: "Join and publish as a test principal", effect: Effect::DevRoom, schema: s_room_join, run: dev_room::join,
|
||||
description: "DEVELOPMENT ONLY. On the target local: enter the development room as a test principal with its grant and a proof of its key, publish its audio and video tracks with a few objects, and wait until the room's clock has signed its join and publishes." },
|
||||
Tool { op: "room.leave", toolset: Toolset::Dev, title: "Leave as a test principal", effect: Effect::DevRoom, schema: s_room_principal, run: dev_room::leave,
|
||||
description: "DEVELOPMENT ONLY. On the target local: close a test principal's session in the development room and wait for the clock's leave." },
|
||||
Tool { op: "room.session", toolset: Toolset::Dev, title: "Read the development room's session", effect: Effect::DevRoom, schema: s_target, run: dev_room::session,
|
||||
description: "DEVELOPMENT ONLY. On the target local: the development room as a verifier is given it (descriptor, grants, key-log head and the clock's event chain), ready for receipt_verify." },
|
||||
];
|
||||
|
||||
/// The tools a server offers: those of its toolsets, or with `--read-only` those of them that
|
||||
/// only read or run a verifier.
|
||||
pub(crate) fn offered(server: &Server) -> impl Iterator<Item = &'static Tool> + '_ {
|
||||
let read_only = server.read_only;
|
||||
TOOLS.iter().filter(move |t| server.toolsets.contains(&t.toolset) && (!read_only || matches!(t.effect, Effect::Reads | Effect::Runs)))
|
||||
}
|
||||
|
||||
/// `tools/list`'s entries.
|
||||
pub(crate) fn list(server: &Server) -> Vec<Value> {
|
||||
offered(server)
|
||||
.map(|t| {
|
||||
json!({
|
||||
"name": t.name(),
|
||||
"title": t.title,
|
||||
"description": t.description,
|
||||
"inputSchema": (t.schema)(),
|
||||
"annotations": {
|
||||
"title": t.title,
|
||||
"readOnlyHint": matches!(t.effect, Effect::Reads | Effect::Runs),
|
||||
"destructiveHint": false,
|
||||
"idempotentHint": matches!(t.effect, Effect::Reads | Effect::Runs),
|
||||
"openWorldHint": false,
|
||||
},
|
||||
"_meta": {"wai/operation": t.op, "wai/toolset": t.toolset.name()},
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Run the tool `name`; `None` when no tool offered has that name.
|
||||
pub(crate) fn call(server: &mut Server, name: &str, args: &Value) -> Option<Result<Value, String>> {
|
||||
let t = offered(server).find(|t| t.name() == name)?;
|
||||
Some((t.run)(server, args))
|
||||
}
|
||||
|
||||
/// The name a table entry runs under, for messages that point at another tool.
|
||||
pub(crate) fn name_of(run: fn(&mut Server, &Value) -> Result<Value, String>) -> String {
|
||||
TOOLS.iter().find(|t| std::ptr::fn_addr_eq(t.run, run)).map_or_else(|| "?".into(), Tool::name)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------- schemas
|
||||
|
||||
fn obj(props: Value, required: &[&str]) -> Value {
|
||||
json!({"type": "object", "properties": props, "required": required, "additionalProperties": false})
|
||||
}
|
||||
|
||||
const HEX64: &str = "64 lowercase hexadecimal digits";
|
||||
|
||||
fn s_none() -> Value {
|
||||
obj(json!({}), &[])
|
||||
}
|
||||
fn s_path() -> Value {
|
||||
obj(json!({"path": {"type": "string", "description": "A .wai file, or a directory whose .wai files make a package."}}), &["path"])
|
||||
}
|
||||
fn s_list_caps() -> Value {
|
||||
obj(
|
||||
json!({
|
||||
"media": {"type": "string", "description": "image, audio, video, text, 3d, ... (the registry's media class)."},
|
||||
"licence": {"type": "string", "description": "A licence class, e.g. royalty-free."},
|
||||
"kind": {"type": "string", "description": "payload, record, companion, ..."},
|
||||
"status": {"type": "string", "description": "registered or candidate."},
|
||||
"contains": {"type": "string", "description": "A substring of the capability string."}
|
||||
}),
|
||||
&[],
|
||||
)
|
||||
}
|
||||
fn s_cap() -> Value {
|
||||
obj(json!({"capability": {"type": "string", "description": "A capability string, e.g. wai.image.avif."}}), &["capability"])
|
||||
}
|
||||
/// The kinds `receipt_verify` takes, and the argument that names each when no kind is given.
|
||||
const VERIFY_KINDS: &[(&str, &str)] = &[("delivery", "receipt"), ("claim", "claim"), ("room_session", "descriptor"), ("audit_bundle", "bundle")];
|
||||
|
||||
fn s_verify() -> Value {
|
||||
obj(
|
||||
json!({
|
||||
"kind": {"type": "string", "enum": VERIFY_KINDS.iter().map(|(k, _)| *k).collect::<Vec<_>>(), "description": "What is verified: a JWP delivery receipt, a claim receipt, a live-room session or a stream audit bundle. When left out, it is read from the arguments given (receipt, claim, descriptor or bundle)."},
|
||||
"receipt": {"description": "delivery: the receipt's JSON (object or text), a group receipt (kind group) or an object receipt (kind object)."},
|
||||
"group": {"description": "delivery, for an object receipt: its group receipt's JSON."},
|
||||
"previous": {"description": "delivery, for a group receipt: the previous group receipt's JSON, to check the parent link. claim: the signer's previous claim's wire text, for the chain (§3.1 step 5); a claim that names a parent is refused without it."},
|
||||
"envelope_hex": {"type": "string", "description": "delivery, for an object receipt: the object's bytes in hexadecimal, to check its content hash."},
|
||||
"envelope_path": {"type": "string", "description": "delivery, for an object receipt: a file holding the object's bytes."},
|
||||
"claim": {"description": "claim: the claim receipt's wire text (a string), or its JSON object."},
|
||||
"object": {"type": "object", "description": "claim, for an energy claim: the delivery receipt's object, {content_hash, joules_micro} and its leaf's class where labelled."},
|
||||
"oer": {"type": "object", "description": "claim, for an energy claim naming an OER receipt: {receipt, keys}."},
|
||||
"gap_object": {"type": "object", "description": "claim, for a delivery-gap claim: the object receipt at its coordinates, {track_alias, group_id, object_id, content_hash}."},
|
||||
"descriptor": {"type": "string", "description": "room_session: the signed WRD1 descriptor, hexadecimal."},
|
||||
"grants": {"type": "array", "items": {"type": "string"}, "description": "room_session: the WRG1 grants, hexadecimal, in order."},
|
||||
"key_log_head": {"type": ["string", "null"], "description": "room_session: the authority's key-log head (its JSON text), or null."},
|
||||
"chains": {"type": "array", "items": {"type": "array", "items": {"type": "string"}}, "description": "room_session: each principal's WEV1 records in stream order, hexadecimal."},
|
||||
"bundle": {"description": "audit_bundle: the bundle's JSON (object or text)."},
|
||||
"owner": {"type": "string", "description": format!("audit_bundle: the owner's key, {HEX64}.")},
|
||||
"channels": {"type": "array", "items": {"type": "string"}, "description": "audit_bundle: the channels the owner serves."}
|
||||
}),
|
||||
&[],
|
||||
)
|
||||
}
|
||||
fn s_package() -> Value {
|
||||
obj(
|
||||
json!({
|
||||
"input": {"type": "string", "description": "The media file whose bytes the envelope carries."},
|
||||
"capability": {"type": "string", "description": "The registered capability the bytes are, e.g. wai.image.png."},
|
||||
"output": {"type": "string", "description": "Where to write the envelope, inside the project directory; default <input>.wai."},
|
||||
"intent": {"type": "string", "description": "replicate (the default where a sink accepts it unpinned), improve or create."},
|
||||
"fallback": {"type": "string", "description": "A capability a sink without the first decodes the same payload through."},
|
||||
"companions": {"type": "array", "items": {"type": "object", "properties": {"capability": {"type": "string"}, "input": {"type": "string"}}, "required": ["capability", "input"]}, "description": "Companion payloads bound to the base (a WAI2 envelope)."},
|
||||
"overwrite": {"type": "boolean", "description": "Replace an existing output file (default false)."}
|
||||
}),
|
||||
&["input", "capability"],
|
||||
)
|
||||
}
|
||||
fn s_corpus() -> Value {
|
||||
obj(
|
||||
json!({
|
||||
"corpus": {"type": "string", "description": "The corpus directory, relative to the wai/ directory, as conformance_list names it."},
|
||||
"verifier": {"type": "string", "description": "The verifier script, when the corpus has several or none listed."},
|
||||
"timeout_s": {"type": "integer", "minimum": 1, "maximum": 1800, "description": "Default 600."}
|
||||
}),
|
||||
&["corpus"],
|
||||
)
|
||||
}
|
||||
fn s_search() -> Value {
|
||||
obj(
|
||||
json!({
|
||||
"query": {"type": "string", "description": "Words that must all appear on a line (case-insensitive)."},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 200, "description": "Default 20."}
|
||||
}),
|
||||
&["query"],
|
||||
)
|
||||
}
|
||||
/// The target a development tool acts on: `local`, this machine's development room, the one
|
||||
/// target these tools take.
|
||||
fn target_prop() -> Value {
|
||||
json!({"type": "string", "enum": ["local"], "description": "Where the operation runs: local (the development room on this machine), the default and the only target these tools take."})
|
||||
}
|
||||
fn s_target() -> Value {
|
||||
obj(json!({"target": target_prop()}), &[])
|
||||
}
|
||||
fn s_dev_start() -> Value {
|
||||
obj(
|
||||
json!({
|
||||
"room_bin": {"type": "string", "description": "The room service program (joule-moq-room); default --room-bin, WAI_ROOM_BIN, beside wai, then PATH."},
|
||||
"publishers": {"type": "integer", "minimum": 1, "maximum": 64, "description": "Publishers at once (default 4)."}
|
||||
}),
|
||||
&[],
|
||||
)
|
||||
}
|
||||
fn s_room_grant() -> Value {
|
||||
obj(
|
||||
json!({
|
||||
"target": target_prop(),
|
||||
"principal": {"type": "string", "description": "A test principal's name; its Ed25519 key is derived from it."},
|
||||
"role": {"type": "string", "enum": ["publisher", "viewer"]},
|
||||
"service": {"type": "string", "description": "The room service's URL; default the development room this server started. Only a loopback address is accepted."}
|
||||
}),
|
||||
&["principal", "role"],
|
||||
)
|
||||
}
|
||||
fn s_room_join() -> Value {
|
||||
obj(
|
||||
json!({
|
||||
"target": target_prop(),
|
||||
"principal": {"type": "string", "description": "A test principal granted the publisher role by room_grant."},
|
||||
"objects": {"type": "integer", "minimum": 1, "maximum": 64, "description": "Objects on each track (default 1)."}
|
||||
}),
|
||||
&["principal"],
|
||||
)
|
||||
}
|
||||
fn s_room_principal() -> Value {
|
||||
obj(json!({"target": target_prop(), "principal": {"type": "string", "description": "The test principal's name."}}), &["principal"])
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------- helpers
|
||||
|
||||
pub(crate) fn str_arg<'a>(args: &'a Value, k: &str) -> Result<&'a str, String> {
|
||||
args.get(k).and_then(Value::as_str).ok_or_else(|| format!("{k}: a string is required"))
|
||||
}
|
||||
|
||||
pub(crate) fn opt_str<'a>(args: &'a Value, k: &str) -> Option<&'a str> {
|
||||
args.get(k).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
pub(crate) fn unhex(s: &str) -> Option<Vec<u8>> {
|
||||
if !s.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
/// A JSON value given as an object or as its text.
|
||||
fn json_arg(args: &Value, k: &str) -> Result<Value, String> {
|
||||
match args.get(k) {
|
||||
Some(Value::String(s)) => serde_json::from_str(s).map_err(|e| format!("{k}: not JSON: {e}")),
|
||||
Some(v @ Value::Object(_)) => Ok(v.clone()),
|
||||
_ => Err(format!("{k}: a JSON object, or its text, is required")),
|
||||
}
|
||||
}
|
||||
|
||||
fn label<T: Copy>(v: Option<T>, f: fn(T) -> &'static str) -> Value {
|
||||
v.map_or(Value::Null, |x| json!(f(x)))
|
||||
}
|
||||
|
||||
/// One capability as the registry and this build know it.
|
||||
fn cap_facts(c: &str) -> Value {
|
||||
use wai::codecs as cx;
|
||||
json!({
|
||||
"capability": c,
|
||||
"registered": cx::is_known_capability(c),
|
||||
"media": label(cx::capability_media(c), cx::Media::label),
|
||||
"role": cx::capability_role(c),
|
||||
"tier": cx::capability_tier(c).map(|t| format!("{t:?}")),
|
||||
"licence": label(cx::capability_license(c), cx::LicenseClass::label),
|
||||
"replicate": label(cx::capability_replicate_class(c), cx::ReplicateClass::label),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------- inspect
|
||||
|
||||
fn envelope_inspect(_: &mut Server, args: &Value) -> Result<Value, String> {
|
||||
let path = PathBuf::from(str_arg(args, "path")?);
|
||||
let meta = std::fs::metadata(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if meta.is_dir() {
|
||||
return inspect_package(&path);
|
||||
}
|
||||
let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut v = inspect_envelope(&bytes)?;
|
||||
v["path"] = json!(path.display().to_string());
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn check<E: std::fmt::Display>(r: Result<(), E>) -> Value {
|
||||
match r {
|
||||
Ok(()) => json!("ok"),
|
||||
Err(e) => json!(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// An envelope's structure, its components' capabilities and its static checks.
|
||||
pub(crate) fn inspect_envelope(bytes: &[u8]) -> Result<Value, String> {
|
||||
use wai::container::{detect_version, Wai, WaiMulti};
|
||||
let native = native_capabilities();
|
||||
let version = detect_version(bytes).map_err(|e| format!("not a WAI envelope: {e}"))?;
|
||||
if version == 1 {
|
||||
let w = Wai::from_bytes(bytes).map_err(|e| e.to_string())?;
|
||||
let cap = w.capability();
|
||||
let mut c = cap_facts(cap);
|
||||
c["index"] = json!(0);
|
||||
c["bytes"] = json!(w.payload.len());
|
||||
c["decoded_natively_here"] = json!(native.contains(&cap));
|
||||
let fallback = w.fallback().map(|f| {
|
||||
let mut x = cap_facts(f);
|
||||
x["decoded_natively_here"] = json!(native.contains(&f));
|
||||
x
|
||||
});
|
||||
Ok(json!({
|
||||
"kind": "envelope",
|
||||
"format": "WAI1",
|
||||
"bytes": bytes.len(),
|
||||
"media": w.media(),
|
||||
"intent": w.intent(),
|
||||
"conditioning": w.kind(),
|
||||
"pin": w.prior().map(wai::pin::describe_pin),
|
||||
"components": [c],
|
||||
"fallback": fallback,
|
||||
"checks": {
|
||||
"pin": check(w.check_pin()),
|
||||
"component_roles": check(w.check_component_roles()),
|
||||
"candidates": check(w.check_candidates()),
|
||||
"integer_payload": check(w.check_int_payload_framing()),
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
let m = WaiMulti::from_bytes(bytes).map_err(|e| e.to_string())?;
|
||||
let comps: Vec<Value> = m
|
||||
.manifest
|
||||
.renditions
|
||||
.iter()
|
||||
.zip(m.renditions.iter())
|
||||
.enumerate()
|
||||
.map(|(i, (meta, payload))| {
|
||||
let mut c = cap_facts(&meta.capability);
|
||||
c["index"] = json!(i);
|
||||
c["bytes"] = json!(payload.len());
|
||||
c["kind"] = json!(meta.kind);
|
||||
c["companion_of"] = json!(meta.companion_of);
|
||||
c["id"] = json!(meta.id);
|
||||
c["pin"] = json!(meta.prior.as_ref().map(wai::pin::describe_pin));
|
||||
c["sha256_matches"] = json!(meta.sha256.as_ref().map(|_| meta.payload_digest_matches(payload)));
|
||||
c["decoded_natively_here"] = json!(native.contains(&meta.capability.as_str()));
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"kind": "envelope",
|
||||
"format": "WAI2",
|
||||
"bytes": bytes.len(),
|
||||
"media": m.media(),
|
||||
"intent": m.intent(),
|
||||
"components": comps,
|
||||
"checks": {
|
||||
"pins": match m.check_pins() { Ok(()) => json!("ok"), Err((i, f)) => json!(format!("[{i}] {f}")) },
|
||||
"component_roles": check(m.check_component_roles()),
|
||||
"candidates": check(m.check_candidates()),
|
||||
"integer_payload": match m.check_int_payload_framing() { Ok(()) => json!("ok"), Err((i, e)) => json!(format!("[{i}] {e}")) },
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// The capabilities this binary decodes natively, as `wai inspect` counts them.
|
||||
fn native_capabilities() -> &'static [&'static str] {
|
||||
crate::native_capabilities()
|
||||
}
|
||||
|
||||
/// A package directory: every `.wai` file below it, read as an envelope, and the SHA-256 of
|
||||
/// its listing (SPEC §3.1's form, the pin of a parameter set).
|
||||
fn inspect_package(dir: &Path) -> Result<Value, String> {
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
while let Some(d) = stack.pop() {
|
||||
let mut entries: Vec<_> = std::fs::read_dir(&d).map_err(|e| format!("{}: {e}", d.display()))?.filter_map(Result::ok).collect();
|
||||
entries.sort_by_key(|e| e.path());
|
||||
for e in entries {
|
||||
let p = e.path();
|
||||
let ft = e.file_type().map_err(|e| e.to_string())?;
|
||||
if ft.is_dir() {
|
||||
stack.push(p);
|
||||
} else if ft.is_file() && p.extension().is_some_and(|x| x == "wai") {
|
||||
files.push(p);
|
||||
}
|
||||
if files.len() > 10_000 {
|
||||
return Err("more than 10000 .wai files: inspect a smaller directory".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
let mut caps: Map<String, Value> = Map::new();
|
||||
let envelopes: Vec<Value> = files
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let rel = p.strip_prefix(dir).unwrap_or(p).display().to_string();
|
||||
match std::fs::read(p).map_err(|e| e.to_string()).and_then(|b| inspect_envelope(&b)) {
|
||||
Ok(mut v) => {
|
||||
for c in v["components"].as_array().into_iter().flatten() {
|
||||
if let Some(name) = c["capability"].as_str() {
|
||||
caps.insert(name.to_string(), json!({"licence": c["licence"], "media": c["media"]}));
|
||||
}
|
||||
}
|
||||
v["path"] = json!(rel);
|
||||
v
|
||||
}
|
||||
Err(e) => json!({"path": rel, "error": e}),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let listing = wai::pin::listing_of_dir(dir).map_err(|e| e.to_string())?;
|
||||
Ok(json!({
|
||||
"kind": "package",
|
||||
"path": dir.display().to_string(),
|
||||
"listing_sha256": wai::pin::sha256_hex(&listing),
|
||||
"envelopes": envelopes,
|
||||
"capabilities": caps,
|
||||
}))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------- the registry
|
||||
|
||||
fn registry() -> Result<Vec<Value>, String> {
|
||||
let v: Value = serde_json::from_str(&wai::codecs::registry::capabilities_json()).map_err(|e| e.to_string())?;
|
||||
Ok(v["capabilities"].as_array().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn registry_list(_: &mut Server, args: &Value) -> Result<Value, String> {
|
||||
let want = |k: &str, e: &Value| opt_str(args, k).is_none_or(|w| e[k].as_str().is_some_and(|v| v.eq_ignore_ascii_case(w)));
|
||||
let contains = opt_str(args, "contains").map(str::to_ascii_lowercase);
|
||||
let caps: Vec<Value> = registry()?
|
||||
.into_iter()
|
||||
.filter(|e| want("media", e) && want("licence", e) && want("kind", e) && want("status", e))
|
||||
.filter(|e| contains.as_deref().is_none_or(|c| e["capability"].as_str().is_some_and(|s| s.to_ascii_lowercase().contains(c))))
|
||||
.collect();
|
||||
Ok(json!({"count": caps.len(), "capabilities": caps}))
|
||||
}
|
||||
|
||||
fn registry_show(server: &mut Server, args: &Value) -> Result<Value, String> {
|
||||
use wai::codecs as cx;
|
||||
let c = str_arg(args, "capability")?;
|
||||
let entry = registry()?.into_iter().find(|e| e["capability"].as_str() == Some(c));
|
||||
let Some(entry) = entry else {
|
||||
return Err(format!("{c} is not in the registry; {} lists them", name_of(registry_list)));
|
||||
};
|
||||
let lines = server.spec_root().ok().map(|root| spec_hits(&root, &[c.to_ascii_lowercase()], 10)).transpose()?.map(|(h, _)| h);
|
||||
Ok(json!({
|
||||
"registry": entry,
|
||||
"this_build": cap_facts(c),
|
||||
"decoded_natively_here": native_capabilities().contains(&c),
|
||||
"dispatched_standalone": cx::is_dispatchable(c),
|
||||
"companion_only": cx::is_companion_only(c),
|
||||
"candidate": cx::is_candidate(c),
|
||||
"spec_lines": lines,
|
||||
}))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------ verifiers
|
||||
|
||||
/// `receipt_verify`: the kind given, or the one the arguments name; the verifier's answer with
|
||||
/// the kind beside it.
|
||||
fn receipt_verify(_: &mut Server, args: &Value) -> Result<Value, String> {
|
||||
let kind = match opt_str(args, "kind") {
|
||||
Some(k) => VERIFY_KINDS.iter().find(|(name, _)| *name == k).map(|(name, _)| *name).ok_or_else(|| {
|
||||
format!("kind: one of {}", VERIFY_KINDS.iter().map(|(k, _)| *k).collect::<Vec<_>>().join(", "))
|
||||
})?,
|
||||
None => {
|
||||
let named: Vec<&'static str> = VERIFY_KINDS.iter().filter(|(_, arg)| args.get(*arg).is_some()).map(|(k, _)| *k).collect();
|
||||
match named.as_slice() {
|
||||
[one] => *one,
|
||||
[] => return Err("give what to verify: receipt (a delivery receipt), claim, descriptor (a live-room session) or bundle (an audit bundle)".into()),
|
||||
many => return Err(format!("the arguments name several kinds ({}): give kind", many.join(", "))),
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut v = match kind {
|
||||
"delivery" => verify_delivery(args),
|
||||
"claim" => verify_claim(args),
|
||||
"room_session" => verify_room_session(args),
|
||||
_ => verify_audit_bundle(args),
|
||||
}?;
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("kind".into(), json!(kind));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn verify_delivery(args: &Value) -> Result<Value, String> {
|
||||
use wai::jwp_delivery::{GroupReceipt, ObjectReceipt};
|
||||
let r = json_arg(args, "receipt")?;
|
||||
let refused = |receipt: &str, e: wai::jwp_delivery::DeliveryError| json!({"receipt": receipt, "verdict": "refuse", "reason": e.reason.label(), "step": e.step});
|
||||
match r["kind"].as_str() {
|
||||
Some("group") => {
|
||||
let g = match GroupReceipt::from_json(&r) {
|
||||
Ok(g) => g,
|
||||
Err(e) => return Ok(refused("group", e)),
|
||||
};
|
||||
if let Err(e) = g.verify() {
|
||||
return Ok(refused("group", e));
|
||||
}
|
||||
// Step 3, as the reference runs it: a group that names a parent is refused unless
|
||||
// the previous group is given and is that parent; one given that does not read, or
|
||||
// is another signer's or track's, is not held.
|
||||
let previous = args.get("previous").and_then(|_| json_arg(args, "previous").ok()).and_then(|p| GroupReceipt::from_json(&p).ok());
|
||||
if let Err(e) = g.check_parent(previous.as_ref()) {
|
||||
return Ok(refused("group", e));
|
||||
}
|
||||
Ok(json!({"receipt": "group", "verdict": "accept", "link_hash": hex(&g.link_hash()), "parent_checked": previous.is_some(), "signer_id": r["signer_id"]}))
|
||||
}
|
||||
Some("object") => {
|
||||
let gv = json_arg(args, "group").map_err(|_| "an object receipt is verified against its group receipt: give group".to_string())?;
|
||||
let g = match GroupReceipt::from_json(&gv) {
|
||||
Ok(g) => g,
|
||||
Err(e) => return Ok(refused("group", e)),
|
||||
};
|
||||
let o = match ObjectReceipt::from_json(&r) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return Ok(refused("object", e)),
|
||||
};
|
||||
let envelope = match (opt_str(args, "envelope_hex"), opt_str(args, "envelope_path")) {
|
||||
(Some(h), _) => Some(unhex(h).ok_or("envelope_hex: hexadecimal")?),
|
||||
(None, Some(p)) => Some(std::fs::read(p).map_err(|e| format!("{p}: {e}"))?),
|
||||
(None, None) => None,
|
||||
};
|
||||
match o.verify_against(&g, envelope.as_deref()) {
|
||||
Ok(ev) => Ok(json!({"receipt": "object", "verdict": "accept", "evidence": ev.label(), "content_checked": envelope.is_some(), "group_link_hash": hex(&g.link_hash())})),
|
||||
Err(e) => Ok(refused("object", e)),
|
||||
}
|
||||
}
|
||||
_ => Err("receipt: a JWP delivery receipt with kind group or object".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_claim(args: &Value) -> Result<Value, String> {
|
||||
use wai::claim_receipt::{verify_claim_json, ClaimReceipt, Rejection};
|
||||
let wire = match args.get("claim") {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(v @ Value::Object(_)) => serde_json::to_string(v).map_err(|e| e.to_string())?,
|
||||
_ => return Err("claim: the receipt's wire text, or its JSON object".into()),
|
||||
};
|
||||
let refused = |r: Rejection| json!({"verdict": "refuse", "step": r.step, "reason": r.reason.label()});
|
||||
// Steps 1 to 4: the claim alone.
|
||||
let c = match verify_claim_json(&wire) {
|
||||
Ok(c) => c,
|
||||
Err(r) => return Ok(refused(r)),
|
||||
};
|
||||
// Step 5: the chain. A previous claim that does not read, or does not verify, is not held.
|
||||
let previous = opt_str(args, "previous").and_then(|w| ClaimReceipt::from_json(w).ok());
|
||||
if let Err(r) = c.check_parent(previous.as_ref()) {
|
||||
return Ok(refused(r));
|
||||
}
|
||||
// Step 6, with the context given.
|
||||
let mut checked = vec!["claim", "chain"];
|
||||
if let Some(o) = args.get("object") {
|
||||
let om = o.as_object().ok_or("object: {content_hash, joules_micro, and the leaf's class where it is labelled}")?;
|
||||
let h: [u8; 32] = o.get("content_hash").and_then(Value::as_str).and_then(unhex).and_then(|b| b.try_into().ok()).ok_or(format!("object.content_hash: {HEX64}"))?;
|
||||
let j = o.get("joules_micro").and_then(Value::as_u64).ok_or("object.joules_micro: an integer")?;
|
||||
let label = wai::energy_class::label_from_json(om).map_err(|e| format!("object: its class does not read: {e}"))?;
|
||||
if let Err(r) = c.check_energy_against_labelled_object(&h, j, label.as_ref()) {
|
||||
return Ok(refused(r));
|
||||
}
|
||||
checked.push("object");
|
||||
}
|
||||
if let Some(o) = args.get("oer") {
|
||||
let receipt = o.get("receipt").ok_or("oer: {receipt, keys}")?;
|
||||
let keys = o.get("keys").and_then(Value::as_object).ok_or("oer.keys: an object")?;
|
||||
if let Err(r) = c.check_energy_against_oer(receipt, keys) {
|
||||
return Ok(refused(r));
|
||||
}
|
||||
checked.push("oer");
|
||||
}
|
||||
// A report is its figure signer's only once step 6 has run against its figure, which this
|
||||
// tool does not take: such a claim is not accepted here.
|
||||
if c.needs_figure() {
|
||||
return Ok(json!({"verdict": "refuse", "step": 6, "reason": "figure_not_held", "note": "this claim's figure is in another receipt; a verifier holding it checks the claim against it"}));
|
||||
}
|
||||
if let Some(o) = args.get("gap_object") {
|
||||
let n = |k: &str| o.get(k).and_then(Value::as_u64).ok_or(format!("gap_object.{k}: an integer"));
|
||||
let h: [u8; 32] = o.get("content_hash").and_then(Value::as_str).and_then(unhex).and_then(|b| b.try_into().ok()).ok_or(format!("gap_object.content_hash: {HEX64}"))?;
|
||||
if let Err(r) = c.check_gap_against(n("track_alias")?, n("group_id")?, n("object_id")?, &h) {
|
||||
return Ok(refused(r));
|
||||
}
|
||||
checked.push("gap_object");
|
||||
}
|
||||
Ok(json!({"verdict": "accept", "class": c.class().label(), "link_hash": hex(&c.link_hash()), "subject": hex(&c.subject), "subject_type": c.subject_type.label(), "checked": checked}))
|
||||
}
|
||||
|
||||
fn hex_list(v: &Value, k: &str) -> Result<Vec<Vec<u8>>, String> {
|
||||
v.get(k).and_then(Value::as_array).ok_or(format!("{k}: a list of hexadecimal strings"))?.iter().map(|x| x.as_str().and_then(unhex).ok_or(format!("{k}: hexadecimal"))).collect()
|
||||
}
|
||||
|
||||
fn verify_room_session(args: &Value) -> Result<Value, String> {
|
||||
use wai::live_room::{verify_room, At, RoomOutcome};
|
||||
let descriptor = unhex(str_arg(args, "descriptor")?).ok_or("descriptor: hexadecimal")?;
|
||||
let grants = hex_list(args, "grants")?;
|
||||
let chains: Vec<Vec<Vec<u8>>> = args
|
||||
.get("chains")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or("chains: a list of lists of records")?
|
||||
.iter()
|
||||
.map(|c| c.as_array().ok_or("chains: lists").and_then(|c| c.iter().map(|r| r.as_str().and_then(unhex).ok_or("chains: hexadecimal records")).collect::<Result<Vec<_>, _>>()))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let head = opt_str(args, "key_log_head");
|
||||
let run = verify_room(&descriptor, &grants, head, &chains);
|
||||
let at = |a: &At| match a {
|
||||
At::Descriptor => json!(["descriptor"]),
|
||||
At::KeyLog => json!(["key_log"]),
|
||||
At::Grant(i) => json!(["grant", i]),
|
||||
At::Record { chain, record } => json!(["record", chain, record]),
|
||||
};
|
||||
let (outcome, reason, place, gaps) = match &run.outcome {
|
||||
RoomOutcome::Accept => ("accept", Value::Null, Value::Null, Value::Null),
|
||||
RoomOutcome::Gap(g) => ("gap", json!("delivery_gap"), Value::Null, json!(g.iter().map(|x| json!({"principal": hex(&x.principal), "first": x.first, "last": x.last})).collect::<Vec<_>>())),
|
||||
RoomOutcome::Refuse { at: a, reason } => ("refuse", json!(reason.code()), at(a), Value::Null),
|
||||
};
|
||||
Ok(json!({
|
||||
"outcome": outcome,
|
||||
"reason": reason,
|
||||
"at": place,
|
||||
"gaps": gaps,
|
||||
"room": run.room.map(|r| hex(&r)),
|
||||
"present": run.state.present.keys().map(|p| hex(p)).collect::<Vec<_>>(),
|
||||
"records_in_order": run.order.len(),
|
||||
"grants": run.grants.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn verify_audit_bundle(args: &Value) -> Result<Value, String> {
|
||||
// The bundle as its text, or as JSON: anything else given is read as the bundle's JSON, and
|
||||
// one that is not a bundle is refused by the verifier (audit_bundle_invalid).
|
||||
let bundle = match args.get("bundle") {
|
||||
Some(Value::String(s)) => s.clone().into_bytes(),
|
||||
Some(v) => serde_json::to_vec(v).map_err(|e| e.to_string())?,
|
||||
None => return Err("bundle: the bundle's JSON, or its text".into()),
|
||||
};
|
||||
let owner: [u8; 32] = unhex(str_arg(args, "owner")?).and_then(|b| b.try_into().ok()).ok_or(format!("owner: {HEX64}"))?;
|
||||
let channels: Vec<String> = args.get("channels").and_then(Value::as_array).ok_or("channels: a list of strings")?.iter().filter_map(|c| c.as_str().map(String::from)).collect();
|
||||
let out = wai::stream_audit::verify_bundle(&bundle, &owner, &channels);
|
||||
let mut v = out.to_json();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.entry("outcome").or_insert(json!("accept"));
|
||||
} else {
|
||||
v = json!({"outcome": v});
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------ packaging
|
||||
|
||||
fn package_build(server: &mut Server, args: &Value) -> Result<Value, String> {
|
||||
let input = str_arg(args, "input")?;
|
||||
let cap = str_arg(args, "capability")?;
|
||||
let output = opt_str(args, "output").map(String::from).unwrap_or_else(|| format!("{input}.wai"));
|
||||
let out = server.inside_project(Path::new(&output))?;
|
||||
if out.exists() && !args.get("overwrite").and_then(Value::as_bool).unwrap_or(false) {
|
||||
return Err(format!("{} exists; pass overwrite: true to replace it", out.display()));
|
||||
}
|
||||
let companions: Vec<(String, String)> = match args.get("companions").and_then(Value::as_array) {
|
||||
None => Vec::new(),
|
||||
Some(a) => a
|
||||
.iter()
|
||||
.map(|c| Ok((c["capability"].as_str().ok_or("companions: capability")?.to_string(), c["input"].as_str().ok_or("companions: input")?.to_string())))
|
||||
.collect::<Result<_, String>>()?,
|
||||
};
|
||||
let out_s = out.to_str().ok_or("output: a UTF-8 path")?;
|
||||
let w = crate::wrap(cap, input, out_s, opt_str(args, "fallback"), opt_str(args, "intent"), &companions)?;
|
||||
Ok(w.facts)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------- conformance
|
||||
|
||||
/// Every (verifier, corpus) pair: the `python3 [-I] <script.py> <dir>` lines of the conformance
|
||||
/// workflow, when the tree holds it, and each `*-conformance/*_verify.py` with its directory.
|
||||
fn corpora(root: &Path) -> Vec<(String, String)> {
|
||||
let mut pairs = std::collections::BTreeSet::new();
|
||||
if let Ok(wf) = std::fs::read_to_string(root.join("../.github/workflows/wai-conformance.yml")) {
|
||||
for line in wf.lines() {
|
||||
let t = line.trim().trim_start_matches("run:").trim();
|
||||
let Some(rest) = t.strip_prefix("python3 ") else { continue };
|
||||
let rest = rest.trim_start_matches("-I ").trim();
|
||||
let mut it = rest.split_whitespace();
|
||||
let (Some(script), Some(dir)) = (it.next(), it.next()) else { continue };
|
||||
let script = script.trim_start_matches("wai/");
|
||||
let dir = dir.trim_start_matches("wai/");
|
||||
if script.ends_with("_verify.py") && root.join(script).is_file() && root.join(dir).is_dir() {
|
||||
pairs.insert((dir.to_string(), script.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(rd) = std::fs::read_dir(root) {
|
||||
for e in rd.filter_map(Result::ok) {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if !name.ends_with("-conformance") || !e.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(inner) = std::fs::read_dir(e.path()) {
|
||||
for f in inner.filter_map(Result::ok) {
|
||||
let fname = f.file_name().to_string_lossy().to_string();
|
||||
if fname.ends_with("_verify.py") {
|
||||
pairs.insert((name.clone(), format!("{name}/{fname}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pairs.into_iter().collect()
|
||||
}
|
||||
|
||||
fn conformance_list(server: &mut Server, _: &Value) -> Result<Value, String> {
|
||||
let root = server.spec_root()?;
|
||||
let list: Vec<Value> = corpora(&root).into_iter().map(|(c, v)| json!({"corpus": c, "verifier": v})).collect();
|
||||
Ok(json!({"root": root.display().to_string(), "count": list.len(), "corpora": list}))
|
||||
}
|
||||
|
||||
fn conformance_run(server: &mut Server, args: &Value) -> Result<Value, String> {
|
||||
let root = server.spec_root()?;
|
||||
let corpus = str_arg(args, "corpus")?.trim_end_matches('/').to_string();
|
||||
let known = corpora(&root);
|
||||
let verifier = match opt_str(args, "verifier") {
|
||||
Some(v) => v.to_string(),
|
||||
None => {
|
||||
let m: Vec<&(String, String)> = known.iter().filter(|(c, _)| *c == corpus).collect();
|
||||
match m.as_slice() {
|
||||
[(_, v)] => v.clone(),
|
||||
[] => return Err(format!("no verifier is listed for {corpus}; {} lists the corpora, or give verifier", name_of(conformance_list))),
|
||||
many => return Err(format!("{corpus} has several verifiers: {}; give verifier", many.iter().map(|(_, v)| v.as_str()).collect::<Vec<_>>().join(", "))),
|
||||
}
|
||||
}
|
||||
};
|
||||
// Only a verifier script and a corpus directory inside the tree.
|
||||
let inside = |p: &str| -> Result<PathBuf, String> {
|
||||
let c = root.join(p).canonicalize().map_err(|e| format!("{p}: {e}"))?;
|
||||
if !c.starts_with(&root) {
|
||||
return Err(format!("{p} is outside {}", root.display()));
|
||||
}
|
||||
Ok(c)
|
||||
};
|
||||
let script = inside(&verifier)?;
|
||||
let dir = inside(&corpus)?;
|
||||
if !script.file_name().is_some_and(|n| n.to_string_lossy().ends_with("_verify.py")) || !dir.is_dir() {
|
||||
return Err("verifier: a *_verify.py script; corpus: a directory".into());
|
||||
}
|
||||
let timeout = args.get("timeout_s").and_then(Value::as_u64).unwrap_or(600).clamp(1, 1800);
|
||||
let started = std::time::Instant::now();
|
||||
let mut child = std::process::Command::new("python3")
|
||||
.arg("-I")
|
||||
.arg(&script)
|
||||
.arg(&dir)
|
||||
.current_dir(&root)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("python3: {e}"))?;
|
||||
let (mut so, mut se) = (child.stdout.take().expect("piped"), child.stderr.take().expect("piped"));
|
||||
let out_t = std::thread::spawn(move || {
|
||||
let mut b = Vec::new();
|
||||
let _ = std::io::Read::read_to_end(&mut so, &mut b);
|
||||
b
|
||||
});
|
||||
let err_t = std::thread::spawn(move || {
|
||||
let mut b = Vec::new();
|
||||
let _ = std::io::Read::read_to_end(&mut se, &mut b);
|
||||
b
|
||||
});
|
||||
let status = loop {
|
||||
if let Some(s) = child.try_wait().map_err(|e| e.to_string())? {
|
||||
break Some(s);
|
||||
}
|
||||
if started.elapsed().as_secs() >= timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break None;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
};
|
||||
let mut text = String::from_utf8_lossy(&out_t.join().unwrap_or_default()).to_string();
|
||||
text.push_str(&String::from_utf8_lossy(&err_t.join().unwrap_or_default()));
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let tail = lines[lines.len().saturating_sub(60)..].join("\n");
|
||||
Ok(json!({
|
||||
"corpus": corpus,
|
||||
"verifier": verifier,
|
||||
"passed": status.is_some_and(|s| s.success()),
|
||||
"exit_code": status.and_then(|s| s.code()),
|
||||
"timed_out": status.is_none(),
|
||||
"seconds": started.elapsed().as_secs_f64(),
|
||||
"output_tail": tail,
|
||||
}))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------- the spec text
|
||||
|
||||
/// The specification's Markdown files: SPEC.md, REGISTRY.md, then every file below
|
||||
/// extensions/ and glossary/, sorted.
|
||||
fn spec_files(root: &Path) -> Vec<PathBuf> {
|
||||
let mut out = vec![root.join("SPEC.md"), root.join("REGISTRY.md")];
|
||||
for sub in ["extensions", "glossary"] {
|
||||
let mut found = Vec::new();
|
||||
let mut stack = vec![root.join(sub)];
|
||||
while let Some(d) = stack.pop() {
|
||||
let Ok(rd) = std::fs::read_dir(&d) else { continue };
|
||||
for e in rd.filter_map(Result::ok) {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else if p.extension().is_some_and(|x| x == "md") {
|
||||
found.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
found.sort();
|
||||
out.extend(found);
|
||||
}
|
||||
out.into_iter().filter(|p| p.is_file()).collect()
|
||||
}
|
||||
|
||||
/// The lines holding every word, with their section headings; and how many files were read.
|
||||
fn spec_hits(root: &Path, words: &[String], limit: usize) -> Result<(Vec<Value>, usize), String> {
|
||||
let files = spec_files(root);
|
||||
let mut hits = Vec::new();
|
||||
for f in &files {
|
||||
let text = std::fs::read_to_string(f).map_err(|e| format!("{}: {e}", f.display()))?;
|
||||
let mut section = String::new();
|
||||
for (i, line) in text.lines().enumerate() {
|
||||
if line.starts_with('#') {
|
||||
section = line.trim_start_matches('#').trim().to_string();
|
||||
}
|
||||
let low = line.to_ascii_lowercase();
|
||||
if words.iter().all(|w| low.contains(w.as_str())) {
|
||||
let rel = f.strip_prefix(root).unwrap_or(f).display().to_string();
|
||||
let t: String = line.trim().chars().take(400).collect();
|
||||
hits.push(json!({"file": rel, "line": i + 1, "section": section, "text": t}));
|
||||
if hits.len() >= limit {
|
||||
return Ok((hits, files.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((hits, files.len()))
|
||||
}
|
||||
|
||||
fn docs_search(server: &mut Server, args: &Value) -> Result<Value, String> {
|
||||
let root = server.spec_root()?;
|
||||
let q = str_arg(args, "query")?;
|
||||
let words: Vec<String> = q.split_whitespace().map(str::to_ascii_lowercase).collect();
|
||||
if words.is_empty() {
|
||||
return Err("query: at least one word".into());
|
||||
}
|
||||
let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(20).clamp(1, 200) as usize;
|
||||
let (hits, files) = spec_hits(&root, &words, limit)?;
|
||||
Ok(json!({"query": q, "root": root.display().to_string(), "files_searched": files, "count": hits.len(), "truncated": hits.len() >= limit, "hits": hits}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_tool_is_an_operation_named_by_the_rule_once() {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for t in TOOLS {
|
||||
assert_eq!(wai::ops::check_id(t.op), Ok(()), "{}", t.op);
|
||||
assert!(seen.insert(t.op), "{} twice", t.op);
|
||||
assert_eq!(t.name(), t.op.replace('.', "_"));
|
||||
assert!(!t.description.contains('\n'), "{}: one line", t.op);
|
||||
assert_eq!(t.description.starts_with("DEVELOPMENT ONLY."), t.toolset == Toolset::Dev, "{}: a development tool says so first, and only one", t.op);
|
||||
}
|
||||
for t in Toolset::ALL {
|
||||
assert_eq!(Toolset::named(t.name()), Some(*t));
|
||||
assert!(TOOLS.iter().any(|x| x.toolset == *t), "{}: a toolset with no tool", t.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
568
wai/wai-rs/src/bin/wai_cli/setup_agent.rs
Normal file
568
wai/wai-rs/src/bin/wai_cli/setup_agent.rs
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
//! `wai setup-agent` (catalogue `agent.setup`): make a project ready for coding agents to use
|
||||
//! WAI. In the project directory it writes the instructions agents read (`AGENTS.md`), adds an
|
||||
//! import of them to a `CLAUDE.md` that is already there (one that is not is left uncreated),
|
||||
//! and registers `wai mcp` in the project-level MCP configuration files whose formats are
|
||||
//! documented (`TARGETS`).
|
||||
//!
|
||||
//! ```text
|
||||
//! wai setup-agent [--dir <project>] [--dry-run] [--confirm] [--json] [--all] [--only <file>[,<file>...]]
|
||||
//! [--toolset <set>[,<set>...]] [--command <program>]
|
||||
//! ```
|
||||
//!
|
||||
//! - It writes nothing outside the project directory: every path is checked against the
|
||||
//! directory's canonical path, and a link among the files it would write is refused.
|
||||
//! - A file that does not exist is created. A file that exists is changed only after its diff is
|
||||
//! shown and the change is accepted: asked for on a terminal, or given with `--confirm`.
|
||||
//! Without a terminal and without `--confirm`, nothing that exists is changed and the command
|
||||
//! exits 4, with every proposed change and the command that confirms them.
|
||||
//! - `--dry-run` shows what would change and writes nothing.
|
||||
//! - In Markdown it owns one block between two markers, which it adds or replaces; the rest of
|
||||
//! the file is kept. In JSON it sets one member, `wai`, under the file's server list; the
|
||||
//! rest is kept, in its order. A JSON file it cannot read as JSON (one with comments, say) is
|
||||
//! left alone, and the entry to add by hand is printed.
|
||||
//! - `--toolset` names the toolsets the registered server offers (`wai mcp --toolset ...`);
|
||||
//! without it the server offers its default, `core`.
|
||||
//! - Output is text on a terminal, and otherwise (or with `--json`) the toolchain's JSON
|
||||
//! envelope (`{"wai": "envelope/1", "op": "agent.setup", "ok", ...}`).
|
||||
//!
|
||||
//! Exit status, the toolchain's table: 0 done (or a dry run); 2 a usage error; 3 a file it may
|
||||
//! not read or write; 4 changes to existing files wait for confirmation; 5 the project
|
||||
//! directory is not there; 7 a file could not be read or written otherwise.
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::io::{BufRead, IsTerminal, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The command's usage, for `wai`'s own.
|
||||
pub const USAGE: &str = "wai setup-agent [--dir D] [--dry-run] [--confirm] [--json] [--all] [--only FILE,...] [--toolset core,dev]\n write AGENTS.md (and an import of it in a CLAUDE.md already there) and register `wai mcp`\n in the project's agent configuration";
|
||||
|
||||
/// The operation, as the catalogue names it.
|
||||
const OP: &str = "agent.setup";
|
||||
|
||||
/// The exit statuses of the toolchain's table this command ends with.
|
||||
const USAGE_ERROR: u8 = 2;
|
||||
const PERMISSION: u8 = 3;
|
||||
const CONFIRMATION_REQUIRED: u8 = 4;
|
||||
const NOT_FOUND: u8 = 5;
|
||||
const INTERNAL: u8 = 7;
|
||||
|
||||
/// The markers of the block this command owns in a Markdown file.
|
||||
const BEGIN: &str = "<!-- wai agent instructions: begin -->";
|
||||
const END: &str = "<!-- wai agent instructions: end -->";
|
||||
|
||||
/// How a file holds the MCP server registration.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Form {
|
||||
/// `{"mcpServers": {"wai": {"command", "args"}}}`.
|
||||
McpServers,
|
||||
/// `{"servers": {"wai": {"type": "stdio", "command", "args"}}}`.
|
||||
Servers,
|
||||
/// `{"mcp": {"wai": {"type": "local", "command": [program, args...], "enabled": true}}}`.
|
||||
McpLocal,
|
||||
/// `.gemini/settings.json`'s `mcpServers`, and `context.fileName` naming AGENTS.md so that the
|
||||
/// agent reads it.
|
||||
McpServersAndContext,
|
||||
}
|
||||
|
||||
/// A configuration file: its path in the project, what it is, its form, and when it is written
|
||||
/// without `--all` (always, or when the directory or file it lives in already exists).
|
||||
struct Target {
|
||||
path: &'static str,
|
||||
what: &'static str,
|
||||
form: Form,
|
||||
when_present: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// The MCP configuration files, by their documented formats.
|
||||
const TARGETS: &[Target] = &[
|
||||
Target { path: ".mcp.json", what: "the project's MCP server list (mcpServers), read by several terminal agents", form: Form::McpServers, when_present: None },
|
||||
Target { path: ".cursor/mcp.json", what: "an editor's project MCP file (mcpServers)", form: Form::McpServers, when_present: Some(".cursor") },
|
||||
Target { path: ".vscode/mcp.json", what: "an editor workspace's MCP file (servers, type stdio)", form: Form::Servers, when_present: Some(".vscode") },
|
||||
Target { path: ".gemini/settings.json", what: "a terminal agent's project settings (mcpServers; context.fileName names AGENTS.md)", form: Form::McpServersAndContext, when_present: Some(".gemini") },
|
||||
Target { path: "opencode.json", what: "a terminal agent's project configuration (mcp, type local)", form: Form::McpLocal, when_present: Some("opencode.json") },
|
||||
];
|
||||
|
||||
struct Opts {
|
||||
dir: PathBuf,
|
||||
dry_run: bool,
|
||||
confirm: bool,
|
||||
json: bool,
|
||||
all: bool,
|
||||
only: Option<Vec<String>>,
|
||||
toolsets: Option<Vec<String>>,
|
||||
command: String,
|
||||
}
|
||||
|
||||
/// What would become of one file.
|
||||
struct Plan {
|
||||
path: String,
|
||||
what: String,
|
||||
old: Option<String>,
|
||||
new: Option<String>,
|
||||
/// Set when the file is left alone, and why.
|
||||
skipped: Option<String>,
|
||||
}
|
||||
|
||||
fn usage() -> &'static str {
|
||||
"usage: wai setup-agent [--dir <project>] [--dry-run] [--confirm] [--json] [--all] [--only <file>[,<file>...]] [--toolset <set>[,<set>...]] [--command <program>]"
|
||||
}
|
||||
|
||||
/// The toolsets `wai mcp` serves, for `--toolset`.
|
||||
const TOOLSETS: &[&str] = &["core", "dev"];
|
||||
|
||||
fn parse(args: &[String]) -> Result<Opts, String> {
|
||||
let mut o = Opts { dir: PathBuf::from("."), dry_run: false, confirm: false, json: false, all: false, only: None, toolsets: None, command: "wai".into() };
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let v = args.get(i + 1);
|
||||
match (args[i].as_str(), v) {
|
||||
("--dry-run", _) => o.dry_run = true,
|
||||
("--confirm", _) => o.confirm = true,
|
||||
("--json", _) => o.json = true,
|
||||
("--all", _) => o.all = true,
|
||||
("--dir", Some(v)) => {
|
||||
o.dir = PathBuf::from(v);
|
||||
i += 1;
|
||||
}
|
||||
("--only", Some(v)) => {
|
||||
o.only = Some(v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect());
|
||||
i += 1;
|
||||
}
|
||||
("--toolset", Some(v)) => {
|
||||
let sets: Vec<String> = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
|
||||
if let Some(bad) = sets.iter().find(|s| !TOOLSETS.contains(&s.as_str())) {
|
||||
return Err(format!("--toolset {bad}: no such toolset ({})", TOOLSETS.join(", ")));
|
||||
}
|
||||
o.toolsets = Some(sets);
|
||||
i += 1;
|
||||
}
|
||||
("--command", Some(v)) => {
|
||||
o.command = v.clone();
|
||||
i += 1;
|
||||
}
|
||||
(a, _) => return Err(format!("{a}: not an option here. {}", usage())),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if let Some(only) = &o.only {
|
||||
let known: Vec<&str> = ["AGENTS.md", "CLAUDE.md"].into_iter().chain(TARGETS.iter().map(|t| t.path)).collect();
|
||||
if let Some(bad) = only.iter().find(|x| !known.contains(&x.as_str())) {
|
||||
return Err(format!("--only {bad}: not a file this command writes ({})", known.join(", ")));
|
||||
}
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// The arguments the server is registered with: the verb, and the toolsets when named.
|
||||
fn mcp_args(toolsets: Option<&[String]>) -> Vec<String> {
|
||||
let mut a = vec![super::MCP.to_string()];
|
||||
if let Some(t) = toolsets {
|
||||
a.push("--toolset".into());
|
||||
a.push(t.join(","));
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// AGENTS.md's block: what WAI is and how an agent uses it here.
|
||||
fn agents_block(command: &str, args: &[String]) -> String {
|
||||
let server = std::iter::once(command.to_string()).chain(args.iter().cloned()).collect::<Vec<_>>().join(" ");
|
||||
format!(
|
||||
"{BEGIN}\n## WAI\n\n\
|
||||
This project uses WAI, an open media standard. A `.wai` envelope carries media under a registered capability; \
|
||||
receipts, claims and live-room records are signed so that a verifier can check them.\n\n\
|
||||
- The `wai` MCP server (`{server}`, registered in this project's MCP configuration) gives you WAI's operations as \
|
||||
tools, each named by its operation (`receipt.verify` is the tool `receipt_verify`). The `core` toolset inspects envelopes \
|
||||
and packages, reads the capability registry, verifies receipts, claims, live-room sessions and audit bundles, packages \
|
||||
media, lists and runs conformance corpora, and searches the specification. The `dev` toolset (`{command} {mcp} --toolset core,dev`) \
|
||||
runs a development room on loopback.\n\
|
||||
- Verify with the tools. Do not judge a signature, a receipt or a session yourself: quote the verifier's verdict and its reason code.\n\
|
||||
- The specification's text is the authority. Search it, and cite a rule by its section (`SPEC.md` §N, or the extension and its section).\n\
|
||||
- The development tools are for development only. They run on 127.0.0.1 with keys held in memory and refuse any other \
|
||||
service; never point a deployment at them.\n\
|
||||
- No tool of the server sends anything off this machine.\n\
|
||||
- The same operations are on the command line: `{command} inspect`, `{command} capabilities`, `{command} wrap`, \
|
||||
`{command} verify`, `{command} dev`, `{command} registry export --check`.\n\
|
||||
{END}\n",
|
||||
mcp = super::MCP,
|
||||
)
|
||||
}
|
||||
|
||||
/// CLAUDE.md's block: it imports AGENTS.md, so the instructions live in one file.
|
||||
fn claude_block() -> String {
|
||||
format!("{BEGIN}\nThis project's instructions for coding agents are in AGENTS.md:\n\n@AGENTS.md\n{END}\n")
|
||||
}
|
||||
|
||||
/// `text` with the owned block set to `block`: replaced where the markers are, appended
|
||||
/// otherwise.
|
||||
fn with_block(text: Option<&str>, block: &str) -> String {
|
||||
let Some(t) = text else { return block.to_string() };
|
||||
if let (Some(b), Some(e)) = (t.find(BEGIN), t.find(END))
|
||||
&& b < e
|
||||
{
|
||||
let end = e + END.len();
|
||||
let end = if t[end..].starts_with('\n') { end + 1 } else { end };
|
||||
return format!("{}{block}{}", &t[..b], &t[end..]);
|
||||
}
|
||||
let sep = if t.is_empty() || t.ends_with("\n\n") { "" } else if t.ends_with('\n') { "\n" } else { "\n\n" };
|
||||
format!("{t}{sep}{block}")
|
||||
}
|
||||
|
||||
/// The server's entry in `form`.
|
||||
fn entry(form: Form, command: &str, args: &[String]) -> Value {
|
||||
match form {
|
||||
Form::McpServers | Form::McpServersAndContext => json!({"command": command, "args": args}),
|
||||
Form::Servers => json!({"type": "stdio", "command": command, "args": args}),
|
||||
Form::McpLocal => json!({"type": "local", "command": std::iter::once(command.to_string()).chain(args.iter().cloned()).collect::<Vec<_>>(), "enabled": true}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file's JSON with the server registered, or why it cannot be.
|
||||
fn with_server(text: Option<&str>, form: Form, command: &str, args: &[String]) -> Result<String, String> {
|
||||
let mut v: Value = match text {
|
||||
None => json!({}),
|
||||
Some(t) if t.trim().is_empty() => json!({}),
|
||||
Some(t) => serde_json::from_str(t).map_err(|e| format!("not plain JSON ({e}); add by hand: {}", json!({key(form): {"wai": entry(form, command, args)}})))?,
|
||||
};
|
||||
let root = v.as_object_mut().ok_or("its top level is not a JSON object")?;
|
||||
let list = root.entry(key(form)).or_insert_with(|| json!({}));
|
||||
let list = list.as_object_mut().ok_or_else(|| format!("its {} is not a JSON object", key(form)))?;
|
||||
list.insert("wai".into(), entry(form, command, args));
|
||||
if let Form::McpServersAndContext = form {
|
||||
let ctx = root.entry("context").or_insert_with(|| json!({}));
|
||||
let ctx = ctx.as_object_mut().ok_or("its context is not a JSON object")?;
|
||||
match ctx.get_mut("fileName") {
|
||||
None => {
|
||||
ctx.insert("fileName".into(), json!(["AGENTS.md", "GEMINI.md"]));
|
||||
}
|
||||
Some(Value::String(s)) if s != "AGENTS.md" => {
|
||||
let old = s.clone();
|
||||
ctx.insert("fileName".into(), json!(["AGENTS.md", old]));
|
||||
}
|
||||
Some(Value::Array(a)) if !a.iter().any(|x| x == "AGENTS.md") => a.insert(0, json!("AGENTS.md")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut s = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
|
||||
s.push('\n');
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn key(form: Form) -> &'static str {
|
||||
match form {
|
||||
Form::McpServers | Form::McpServersAndContext => "mcpServers",
|
||||
Form::Servers => "servers",
|
||||
Form::McpLocal => "mcp",
|
||||
}
|
||||
}
|
||||
|
||||
/// `rel` inside the project, checked: every existing ancestor resolves inside the project and
|
||||
/// the file itself is no link.
|
||||
fn checked(project: &Path, rel: &str) -> Result<PathBuf, String> {
|
||||
let p = project.join(rel);
|
||||
let mut existing = p.parent().unwrap_or(project).to_path_buf();
|
||||
while !existing.exists() {
|
||||
existing = existing.parent().map(Path::to_path_buf).ok_or("no ancestor exists")?;
|
||||
}
|
||||
let real = existing.canonicalize().map_err(|e| format!("{}: {e}", existing.display()))?;
|
||||
if !real.starts_with(project) {
|
||||
return Err(format!("{rel} resolves outside the project directory ({}): left alone", real.display()));
|
||||
}
|
||||
if std::fs::symlink_metadata(&p).is_ok_and(|m| m.file_type().is_symlink()) {
|
||||
return Err(format!("{rel} is a link: left alone"));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Why the command stops: its exit status, the error's stable code, its message and a hint.
|
||||
struct Fail {
|
||||
exit: u8,
|
||||
code: &'static str,
|
||||
message: String,
|
||||
hint: &'static str,
|
||||
}
|
||||
|
||||
impl Fail {
|
||||
fn usage(message: String) -> Fail {
|
||||
Fail { exit: USAGE_ERROR, code: "usage", message, hint: "wai setup-agent --dry-run shows what it would write" }
|
||||
}
|
||||
|
||||
/// A file that could not be read or written: permission, or anything else.
|
||||
fn io(p: &Path, e: &std::io::Error) -> Fail {
|
||||
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
Fail { exit: PERMISSION, code: "permission-denied", message: format!("{}: {e}", p.display()), hint: "check the files' permissions, or pass --only to leave a file out" }
|
||||
} else {
|
||||
Fail { exit: INTERNAL, code: "io-error", message: format!("{}: {e}", p.display()), hint: "check the file, or pass --only to leave it out" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read(p: &Path) -> Result<Option<String>, Fail> {
|
||||
match std::fs::read_to_string(p) {
|
||||
Ok(t) => Ok(Some(t)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(Fail::io(p, &e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn plans(o: &Opts, project: &Path) -> Result<Vec<Plan>, Fail> {
|
||||
let selected = |path: &str| o.only.as_ref().is_none_or(|only| only.iter().any(|x| x == path));
|
||||
let mut out = Vec::new();
|
||||
let mut add = |path: &str, what: &str, make: &dyn Fn(Option<&str>) -> Result<String, String>| -> Result<(), Fail> {
|
||||
if !selected(path) {
|
||||
return Ok(());
|
||||
}
|
||||
let p = match checked(project, path) {
|
||||
Ok(p) => p,
|
||||
Err(why) => {
|
||||
out.push(Plan { path: path.into(), what: what.into(), old: None, new: None, skipped: Some(why) });
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let old = read(&p)?;
|
||||
match make(old.as_deref()) {
|
||||
Ok(new) => out.push(Plan { path: path.into(), what: what.into(), old, new: Some(new), skipped: None }),
|
||||
Err(why) => out.push(Plan { path: path.into(), what: what.into(), old, new: None, skipped: Some(why) }),
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let args = mcp_args(o.toolsets.as_deref());
|
||||
add("AGENTS.md", "the instructions coding agents read", &|old| Ok(with_block(old, &agents_block(&o.command, &args))))?;
|
||||
// CLAUDE.md only where the project has one: an agent that reads it reads AGENTS.md when it
|
||||
// is absent, so it gains nothing from a new one.
|
||||
if std::fs::symlink_metadata(project.join("CLAUDE.md")).is_ok() {
|
||||
add("CLAUDE.md", "an instructions file the project has; it imports AGENTS.md", &|old| {
|
||||
// A CLAUDE.md that imports AGENTS.md already needs nothing.
|
||||
let imports = old.is_some_and(|t| t.lines().any(|l| l.trim() == "@AGENTS.md"));
|
||||
Ok(if imports { old.unwrap_or_default().to_string() } else { with_block(old, &claude_block()) })
|
||||
})?;
|
||||
}
|
||||
for t in TARGETS {
|
||||
let present = t.when_present.is_none_or(|p| project.join(p).exists());
|
||||
if !(present || o.all || o.only.as_ref().is_some_and(|only| only.iter().any(|x| x == t.path))) {
|
||||
continue;
|
||||
}
|
||||
let (form, command, args) = (t.form, o.command.clone(), args.clone());
|
||||
add(t.path, t.what, &move |old| with_server(old, form, &command, &args))?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A unified diff of one hunk: the lines between the common head and the common tail, with
|
||||
/// three lines of context.
|
||||
fn diff(path: &str, old: Option<&str>, new: &str) -> String {
|
||||
let a: Vec<&str> = old.map(|t| t.lines().collect()).unwrap_or_default();
|
||||
let b: Vec<&str> = new.lines().collect();
|
||||
let head = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
|
||||
let tail = a[head..].iter().rev().zip(b[head..].iter().rev()).take_while(|(x, y)| x == y).count();
|
||||
let from = head.saturating_sub(3);
|
||||
let a_end = (a.len() - tail + 3).min(a.len());
|
||||
let b_end = (b.len() - tail + 3).min(b.len());
|
||||
let mut s = format!("--- {}\n+++ b/{path}\n", if old.is_some() { format!("a/{path}") } else { "/dev/null".into() });
|
||||
s.push_str(&format!("@@ -{},{} +{},{} @@\n", from + usize::from(!a.is_empty()), a_end - from, from + 1, b_end - from));
|
||||
for l in &a[from..head] {
|
||||
s.push_str(&format!(" {l}\n"));
|
||||
}
|
||||
for l in &a[head..a.len() - tail] {
|
||||
s.push_str(&format!("-{l}\n"));
|
||||
}
|
||||
for l in &b[head..b.len() - tail] {
|
||||
s.push_str(&format!("+{l}\n"));
|
||||
}
|
||||
for l in &b[b.len() - tail..b_end] {
|
||||
s.push_str(&format!(" {l}\n"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn ask(path: &str) -> bool {
|
||||
eprint!("change {path}? [y/N] ");
|
||||
let _ = std::io::stderr().flush();
|
||||
let mut line = String::new();
|
||||
std::io::stdin().lock().read_line(&mut line).is_ok() && matches!(line.trim(), "y" | "Y" | "yes")
|
||||
}
|
||||
|
||||
fn write(project: &Path, path: &str, text: &str) -> Result<(), Fail> {
|
||||
let refused = |why: String| Fail { exit: PERMISSION, code: "outside-project", message: why, hint: "the command writes inside the project directory alone" };
|
||||
let p = checked(project, path).map_err(refused)?;
|
||||
if let Some(dir) = p.parent()
|
||||
&& !dir.exists()
|
||||
{
|
||||
std::fs::create_dir_all(dir).map_err(|e| Fail::io(dir, &e))?;
|
||||
// Created inside the project: check again that it resolves there.
|
||||
checked(project, path).map_err(refused)?;
|
||||
}
|
||||
std::fs::write(&p, text).map_err(|e| Fail::io(&p, &e))
|
||||
}
|
||||
|
||||
/// The toolchain's JSON envelope for this command (`envelope/1`).
|
||||
fn envelope(ok: bool, fail: Option<&Fail>) -> Map<String, Value> {
|
||||
let mut v = Map::new();
|
||||
v.insert("wai".into(), json!("envelope/1"));
|
||||
v.insert("op".into(), json!(OP));
|
||||
v.insert("ok".into(), json!(ok));
|
||||
if let Some(f) = fail {
|
||||
v.insert("error".into(), json!({"code": f.code, "message": f.message, "hint": f.hint, "retryable": false}));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// `wai setup-agent`: its exit status, its output written.
|
||||
pub fn main(args: &[String]) -> u8 {
|
||||
let json_out = args.iter().any(|a| a == "--json") || !std::io::stdout().is_terminal();
|
||||
let fail = |f: Fail| {
|
||||
if json_out {
|
||||
println!("{}", Value::Object(envelope(false, Some(&f))));
|
||||
} else {
|
||||
eprintln!("error: {}\nhint: {}", f.message, f.hint);
|
||||
}
|
||||
f.exit
|
||||
};
|
||||
let o = match parse(args) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return fail(Fail::usage(e)),
|
||||
};
|
||||
let project = match o.dir.canonicalize() {
|
||||
Ok(p) if p.is_dir() => p,
|
||||
Ok(p) => return fail(Fail::usage(format!("{}: not a directory", p.display()))),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return fail(Fail { exit: NOT_FOUND, code: "not-found", message: format!("{}: {e}", o.dir.display()), hint: "give the project's directory with --dir" });
|
||||
}
|
||||
Err(e) => return fail(Fail::io(&o.dir, &e)),
|
||||
};
|
||||
let plans = match plans(&o, &project) {
|
||||
Ok(p) => p,
|
||||
Err(f) => return fail(f),
|
||||
};
|
||||
let interactive = !o.json && !o.confirm && !o.dry_run && std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
|
||||
let mut files = Vec::new();
|
||||
let mut proposed = Vec::new();
|
||||
let mut failed: Option<Fail> = None;
|
||||
for p in &plans {
|
||||
let mut f = Map::new();
|
||||
f.insert("path".into(), json!(p.path));
|
||||
f.insert("what".into(), json!(p.what));
|
||||
let action = match (&p.skipped, &p.new) {
|
||||
(Some(why), _) => {
|
||||
f.insert("reason".into(), json!(why));
|
||||
"skipped"
|
||||
}
|
||||
(None, Some(new)) if p.old.as_deref() == Some(new.as_str()) => "unchanged",
|
||||
(None, Some(new)) => {
|
||||
let d = diff(&p.path, p.old.as_deref(), new);
|
||||
f.insert("diff".into(), json!(d));
|
||||
let exists = p.old.is_some();
|
||||
if o.dry_run {
|
||||
if exists { "would_update" } else { "would_create" }
|
||||
} else {
|
||||
let go = !exists || o.confirm || (interactive && {
|
||||
println!("{d}");
|
||||
ask(&p.path)
|
||||
});
|
||||
if go {
|
||||
match write(&project, &p.path, new) {
|
||||
Ok(()) => if exists { "updated" } else { "created" },
|
||||
Err(e) => {
|
||||
f.insert("reason".into(), json!(e.message));
|
||||
failed.get_or_insert(e);
|
||||
"failed"
|
||||
}
|
||||
}
|
||||
} else if interactive {
|
||||
"declined"
|
||||
} else {
|
||||
proposed.push(json!({"action": "update", "path": p.path, "diff": d}));
|
||||
"needs_confirmation"
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, None) => "skipped",
|
||||
};
|
||||
f.insert("action".into(), json!(action));
|
||||
files.push(Value::Object(f));
|
||||
}
|
||||
let confirm = format!("wai setup-agent --dir {} --confirm{}{}{}", shell_word(&project.display().to_string()),
|
||||
o.only.as_ref().map(|x| format!(" --only {}", x.join(","))).unwrap_or_default(),
|
||||
o.toolsets.as_ref().map(|x| format!(" --toolset {}", x.join(","))).unwrap_or_default(),
|
||||
if o.all { " --all" } else { "" });
|
||||
let waiting = !proposed.is_empty();
|
||||
let pending = Fail {
|
||||
exit: CONFIRMATION_REQUIRED,
|
||||
code: "confirmation-required",
|
||||
message: format!("{} change{} to existing files", proposed.len(), if proposed.len() == 1 { "" } else { "s" }),
|
||||
hint: "read each diff, then re-run with --confirm",
|
||||
};
|
||||
let stop = failed.as_ref().or(if waiting { Some(&pending) } else { None });
|
||||
let code = stop.map_or(0, |f| f.exit);
|
||||
if json_out {
|
||||
let mut v = envelope(code == 0, stop);
|
||||
v.insert("result".into(), json!({"project": project.display().to_string(), "dry_run": o.dry_run, "files": files}));
|
||||
if waiting {
|
||||
v.insert("proposed".into(), json!(proposed));
|
||||
v.insert("confirm".into(), json!(confirm));
|
||||
}
|
||||
println!("{}", Value::Object(v));
|
||||
} else {
|
||||
println!("wai setup-agent — {}", project.display());
|
||||
for f in &files {
|
||||
println!(" {:<22} {:<19} {}", f["path"].as_str().unwrap_or(""), f["action"].as_str().unwrap_or(""), f["what"].as_str().unwrap_or(""));
|
||||
if let Some(r) = f["reason"].as_str() {
|
||||
println!(" {:<22} {r}", "");
|
||||
}
|
||||
if (o.dry_run || f["action"] == "needs_confirmation") && let Some(d) = f["diff"].as_str() {
|
||||
println!("{d}");
|
||||
}
|
||||
}
|
||||
if waiting {
|
||||
println!("existing files would change and nothing confirmed it; to apply: {confirm}");
|
||||
}
|
||||
if let Some(f) = &failed {
|
||||
eprintln!("error: {}\nhint: {}", f.message, f.hint);
|
||||
}
|
||||
}
|
||||
code
|
||||
}
|
||||
|
||||
/// A path as one shell word.
|
||||
fn shell_word(s: &str) -> String {
|
||||
if s.chars().all(|c| c.is_ascii_alphanumeric() || "/._-+".contains(c)) { s.to_string() } else { format!("'{}'", s.replace('\'', "'\\''")) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_block_is_added_once_and_replaced_in_place() {
|
||||
let b1 = format!("{BEGIN}\none\n{END}\n");
|
||||
let b2 = format!("{BEGIN}\ntwo\n{END}\n");
|
||||
let t = with_block(Some("# Mine\n\nkeep\n"), &b1);
|
||||
assert_eq!(t, format!("# Mine\n\nkeep\n\n{b1}"));
|
||||
assert_eq!(with_block(Some(&t), &b1), t, "the same block again changes nothing");
|
||||
let t2 = with_block(Some(&format!("{t}after\n")), &b2);
|
||||
assert_eq!(t2, format!("# Mine\n\nkeep\n\n{b2}after\n"), "replaced where it is, the rest kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_list_keeps_its_other_members() {
|
||||
let t = r#"{"mcpServers": {"other": {"command": "x"}}, "z": 1}"#;
|
||||
let args = mcp_args(None);
|
||||
let v: Value = serde_json::from_str(&with_server(Some(t), Form::McpServers, "wai", &args).unwrap()).unwrap();
|
||||
assert_eq!(v["mcpServers"]["other"]["command"], "x");
|
||||
assert_eq!(v["mcpServers"]["wai"]["args"], json!([super::super::MCP]));
|
||||
assert_eq!(v["z"], 1);
|
||||
assert!(with_server(Some("{ // a comment\n}"), Form::Servers, "wai", &args).is_err());
|
||||
let dev = mcp_args(Some(&["core".to_string(), "dev".to_string()]));
|
||||
let l: Value = serde_json::from_str(&with_server(None, Form::McpLocal, "wai", &dev).unwrap()).unwrap();
|
||||
assert_eq!(l["mcp"]["wai"]["command"], json!(["wai", "mcp", "--toolset", "core,dev"]));
|
||||
let g: Value = serde_json::from_str(&with_server(Some(r#"{"context": {"fileName": "GEMINI.md"}}"#), Form::McpServersAndContext, "wai", &args).unwrap()).unwrap();
|
||||
assert_eq!(g["context"]["fileName"], json!(["AGENTS.md", "GEMINI.md"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_diff_shows_the_changed_lines() {
|
||||
let d = diff("x", Some("a\nb\nc\n"), "a\nB\nc\n");
|
||||
assert!(d.contains("-b\n+B\n"), "{d}");
|
||||
let d = diff("x", None, "a\n");
|
||||
assert!(d.starts_with("--- /dev/null\n+++ b/x\n") && d.contains("+a\n"), "{d}");
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,9 @@ pub mod pin;
|
|||
// The reader limits the extension formats share with SPEC §2: how many samples, counting
|
||||
// every channel, and how many frames a reader must be willing to render. Always on.
|
||||
pub mod limits;
|
||||
// The rule the toolchain's operation names keep (noun.verb, a closed list of verbs) and the
|
||||
// forms the doors derive from a name: an MCP tool's, an OpenAPI operationId. Always on.
|
||||
pub mod ops;
|
||||
#[cfg(feature = "ffi")]
|
||||
pub mod ffi;
|
||||
#[cfg(feature = "codecs")]
|
||||
|
|
|
|||
66
wai/wai-rs/src/ops.rs
Normal file
66
wai/wai-rs/src/ops.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
//! The rule the toolchain's operation names keep (WAI toolchain v1, naming rule N3), and the
|
||||
//! mechanical forms the doors derive from a name.
|
||||
//!
|
||||
//! An operation is named `noun.verb`: both parts lowercase ASCII letters, the noun singular
|
||||
//! (a compound written as one word, as `keylog` is), the verb from a closed list ([`VERBS`]).
|
||||
//! A near-synonym is not a second verb: `get`, `describe` and `info` are `show`; `keygen`,
|
||||
//! `init` and `new` are `create`. The catalogue names a few operations whose last word is not on
|
||||
//! the list ([`NAMED_AS_IS`]); they keep the names it gives them.
|
||||
//!
|
||||
//! Each door takes its form from the name, never a name of its own: an OpenAPI operation's
|
||||
//! `operationId` is the name itself, and an MCP tool's name is the name with `_` for the dot
|
||||
//! ([`mcp_tool_name`]). `wai mcp`'s tools and the room service's API description are held to
|
||||
//! this rule by their tests.
|
||||
|
||||
/// The verbs an operation's name may end with.
|
||||
pub const VERBS: &[&str] = &[
|
||||
"create", "list", "show", "update", "delete", "check", "verify", "run", "build", "export", "import", "start", "stop", "join",
|
||||
"leave", "grant", "invite", "revoke", "rotate", "tail", "search", "test", "load", "encode", "decode", "inspect", "wrap", "unwrap",
|
||||
"fetch", "generate", "promote", "rollback", "lock", "upgrade", "setup", "guide", "stream",
|
||||
];
|
||||
|
||||
/// Names the catalogue gives as they are, though their last word is not on the list: the
|
||||
/// catalogue's table is the authority on its own names.
|
||||
pub const NAMED_AS_IS: &[&str] = &[
|
||||
"room.session", "agent.mcp", "toolchain.hello", "toolchain.doctor", "ops.schema", "edge.derive", "meter.calibrate", "audit.report",
|
||||
];
|
||||
|
||||
/// `Ok` when `id` is an operation's name by the rule (or one the catalogue gives as it is), or
|
||||
/// why it is not.
|
||||
pub fn check_id(id: &str) -> Result<(), String> {
|
||||
if NAMED_AS_IS.contains(&id) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some((noun, verb)) = id.split_once('.') else {
|
||||
return Err(format!("{id:?}: an operation is named noun.verb"));
|
||||
};
|
||||
let letters = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_lowercase());
|
||||
if !letters(noun) || !letters(verb) {
|
||||
return Err(format!("{id:?}: the noun and the verb are lowercase ASCII letters, one dot between them"));
|
||||
}
|
||||
if !VERBS.contains(&verb) {
|
||||
return Err(format!("{id:?}: {verb} is not one of the verbs an operation ends with ({})", VERBS.join(", ")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The MCP tool an operation is served as: its name with `_` for the dot.
|
||||
pub fn mcp_tool_name(id: &str) -> String {
|
||||
id.replace('.', "_")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_keep_the_rule() {
|
||||
for ok in ["room.grant", "envelope.inspect", "keylog.verify", "dev.start", "room.session"] {
|
||||
assert_eq!(check_id(ok), Ok(()), "{ok}");
|
||||
}
|
||||
for no in ["room", "room.get", "Room.grant", "room.grant.now", "room_grant", "room.", ".grant", "key-log.verify", "room.describe", "call.session"] {
|
||||
assert!(check_id(no).is_err(), "{no}");
|
||||
}
|
||||
assert_eq!(mcp_tool_name("receipt.verify"), "receipt_verify");
|
||||
}
|
||||
}
|
||||
420
wai/wai-rs/tests/cli_mcp.rs
Normal file
420
wai/wai-rs/tests/cli_mcp.rs
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
//! `wai mcp` as a coding agent meets it: the binary started as an MCP server, and a client in
|
||||
//! this test speaking the protocol to it over standard input and output (newline-delimited
|
||||
//! JSON-RPC 2.0, MCP 2025-06-18): the handshake, the tool list, and every tool through
|
||||
//! `tools/call`.
|
||||
//!
|
||||
//! - Every tool is an operation of the toolchain's catalogue, named by it: the tool list is
|
||||
//! exactly the operations below, each tool's name its operation's with `_` for the dot, each
|
||||
//! operation's name keeping the rule (`wai::ops::check_id`). `core` is offered by default,
|
||||
//! `dev` when it is asked for, and a toolset that does not exist is refused.
|
||||
//! - `receipt_verify` agrees with the committed corpora vector by vector: every group and object
|
||||
//! vector of jwp-conformance (kind delivery), every claim vector whose context is one the tool
|
||||
//! takes (none, the previous claim, the object, an OER receipt, a gap's object; kind claim),
|
||||
//! every room of live-conformance/rooms (kind room_session) and every session of
|
||||
//! live-conformance/audit (kind audit_bundle); a receipt changed by one character is refused.
|
||||
//! - `package_build` writes an envelope inside the project directory only, never over a file
|
||||
//! without `overwrite`; `envelope_inspect` reads it back, and the directory as a package.
|
||||
//! - `conformance_run` runs a corpus's independent verifier and reports it passed.
|
||||
//! - The `dev` tools refuse a service that is not on a loopback address, before any
|
||||
//! connection, and any target but `local`; with no room they say how to start one. (The room
|
||||
//! itself, joined, published to and verified through these tools, is joule-moq-room's
|
||||
//! tests/mcp_dev_room.rs.)
|
||||
//! - `--read-only` offers no tool that writes, serves or asks for a grant.
|
||||
#![cfg(feature = "mcp")]
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||||
|
||||
/// The `wai/` directory: the specification and the corpora.
|
||||
fn wai_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("..").canonicalize().unwrap()
|
||||
}
|
||||
|
||||
/// An MCP client of a `wai mcp` process.
|
||||
struct Client {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
out: BufReader<ChildStdout>,
|
||||
next: i64,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn start(project: &Path, extra: &[&str]) -> Client {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_wai"))
|
||||
.arg("mcp")
|
||||
.arg("--spec-root")
|
||||
.arg(wai_dir())
|
||||
.arg("--project")
|
||||
.arg(project)
|
||||
.args(extra)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.expect("wai mcp starts");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let out = BufReader::new(child.stdout.take().unwrap());
|
||||
let mut c = Client { child, stdin, out, next: 1 };
|
||||
let init = c.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "cli_mcp test", "version": "1"}}));
|
||||
assert_eq!(init["result"]["protocolVersion"], "2025-06-18", "{init}");
|
||||
c.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}));
|
||||
c
|
||||
}
|
||||
|
||||
fn send(&mut self, v: &Value) {
|
||||
writeln!(self.stdin, "{v}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
}
|
||||
|
||||
fn line(&mut self) -> Value {
|
||||
let mut l = String::new();
|
||||
assert!(self.out.read_line(&mut l).unwrap() > 0, "the server closed its output");
|
||||
serde_json::from_str(&l).unwrap_or_else(|e| panic!("a line that is not JSON on the protocol channel: {e}: {l:?}"))
|
||||
}
|
||||
|
||||
fn request(&mut self, method: &str, params: Value) -> Value {
|
||||
let id = self.next;
|
||||
self.next += 1;
|
||||
self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
|
||||
let v = self.line();
|
||||
assert_eq!(v["id"], json!(id), "answers come in order: {v}");
|
||||
v
|
||||
}
|
||||
|
||||
/// A tool's structured result, or its error text.
|
||||
fn call(&mut self, name: &str, args: Value) -> Result<Value, String> {
|
||||
let v = self.request("tools/call", json!({"name": name, "arguments": args}));
|
||||
let r = &v["result"];
|
||||
assert!(r.is_object(), "{name}: {v}");
|
||||
let text = r["content"][0]["text"].as_str().unwrap_or("").to_string();
|
||||
if r["isError"] == json!(true) {
|
||||
return Err(text);
|
||||
}
|
||||
let s = r["structuredContent"].clone();
|
||||
assert!(s.is_object(), "{name}: a structured result: {v}");
|
||||
assert!(serde_json::from_str::<Value>(&text).is_ok_and(|t| t.is_object()), "{name}: the text block is the result's JSON");
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn ok(&mut self, name: &str, args: Value) -> Value {
|
||||
self.call(name, args.clone()).unwrap_or_else(|e| panic!("{name} {args}: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Client {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn scratch(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("wai-cli-mcp-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.canonicalize().unwrap()
|
||||
}
|
||||
|
||||
fn corpus(rel: &str) -> Value {
|
||||
serde_json::from_str(&std::fs::read_to_string(wai_dir().join(rel)).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// The operations of each toolset, in the order the server lists them. A tool is its
|
||||
/// operation's name with `_` for the dot.
|
||||
const CORE: &[&str] = &["envelope.inspect", "registry.list", "registry.show", "receipt.verify", "package.build", "conformance.list", "conformance.run", "docs.search"];
|
||||
const DEV: &[&str] = &["dev.start", "dev.stop", "room.grant", "room.join", "room.leave", "room.session"];
|
||||
|
||||
fn tool_names(c: &mut Client) -> Vec<String> {
|
||||
let list = c.request("tools/list", json!({}));
|
||||
list["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
fn names_of(ops: &[&[&str]]) -> Vec<String> {
|
||||
ops.iter().flat_map(|s| s.iter()).map(|op| wai::ops::mcp_tool_name(op)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_handshake_and_the_tool_list() {
|
||||
let d = scratch("handshake");
|
||||
// core by default.
|
||||
let mut c = Client::start(&d, &[]);
|
||||
assert_eq!(tool_names(&mut c), names_of(&[CORE]));
|
||||
// core and dev when asked: every tool an operation named by the rule.
|
||||
let mut c = Client::start(&d, &["--toolset", "core,dev"]);
|
||||
let list = c.request("tools/list", json!({}));
|
||||
let tools = list["result"]["tools"].as_array().unwrap();
|
||||
let names: Vec<String> = tools.iter().map(|t| t["name"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(names, names_of(&[CORE, DEV]));
|
||||
for t in tools {
|
||||
let op = t["_meta"]["wai/operation"].as_str().unwrap();
|
||||
assert_eq!(wai::ops::check_id(op), Ok(()), "{t}");
|
||||
assert_eq!(t["name"].as_str().unwrap(), op.replace('.', "_"), "{t}: the tool is its operation, named by it");
|
||||
let set = t["_meta"]["wai/toolset"].as_str().unwrap();
|
||||
assert_eq!(set, if DEV.contains(&op) { "dev" } else { "core" }, "{t}");
|
||||
let d = t["description"].as_str().unwrap();
|
||||
assert!(!d.is_empty() && !d.contains('\n'), "{t}: a one-line description");
|
||||
assert_eq!(t["inputSchema"]["type"], "object", "{t}");
|
||||
assert!(t["annotations"]["readOnlyHint"].is_boolean(), "{t}");
|
||||
assert_eq!(d.starts_with("DEVELOPMENT ONLY."), set == "dev", "{t}: a development tool is marked so, and only one");
|
||||
}
|
||||
let mut only_dev = Client::start(&d, &["--toolset", "dev"]);
|
||||
assert_eq!(tool_names(&mut only_dev), names_of(&[DEV]));
|
||||
// A toolset this server does not have is refused before it serves.
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_wai")).args(["mcp", "--toolset", "core,nonesuch"]).stdin(Stdio::null()).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("nonesuch"), "{out:?}");
|
||||
// The protocol's own answers.
|
||||
assert_eq!(c.request("ping", json!({}))["result"], json!({}));
|
||||
assert_eq!(c.request("nonsense/method", json!({}))["error"]["code"], -32601);
|
||||
assert_eq!(c.request("tools/call", json!({"name": "no_such_tool", "arguments": {}}))["error"]["code"], -32602);
|
||||
c.send(&json!([{"jsonrpc": "2.0", "id": 99, "method": "ping"}]));
|
||||
assert_eq!(c.line()["error"]["code"], -32600, "a batch is refused");
|
||||
writeln!(c.stdin, "{{not json").unwrap();
|
||||
c.stdin.flush().unwrap();
|
||||
assert_eq!(c.line()["error"]["code"], -32700);
|
||||
// An older revision a client asks for is answered in it.
|
||||
let mut old = Client::start(&d, &[]);
|
||||
assert_eq!(old.request("initialize", json!({"protocolVersion": "2024-11-05"}))["result"]["protocolVersion"], "2024-11-05");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_offers_no_tool_that_writes_serves_or_mints() {
|
||||
let d = scratch("readonly");
|
||||
let mut c = Client::start(&d, &["--toolset", "core,dev", "--read-only"]);
|
||||
let names = tool_names(&mut c);
|
||||
for gone in ["package_build", "dev_start", "dev_stop", "room_grant", "room_join", "room_leave", "room_session"] {
|
||||
assert!(!names.iter().any(|n| n == gone), "{gone} is offered read-only");
|
||||
}
|
||||
assert!(names.iter().any(|n| n == "receipt_verify"));
|
||||
let r = c.request("tools/call", json!({"name": "package_build", "arguments": {"input": "x", "capability": "wai.text.zstd"}}));
|
||||
assert_eq!(r["error"]["code"], -32602, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_registry_and_the_specification() {
|
||||
let d = scratch("registry");
|
||||
let mut c = Client::start(&d, &[]);
|
||||
let all = c.ok("registry_list", json!({}));
|
||||
let n = all["count"].as_u64().unwrap();
|
||||
assert!(n > 50 && all["capabilities"].as_array().unwrap().len() as u64 == n);
|
||||
let rf = c.ok("registry_list", json!({"licence": "royalty-free", "media": "image"}));
|
||||
assert!(rf["count"].as_u64().unwrap() > 0);
|
||||
for e in rf["capabilities"].as_array().unwrap() {
|
||||
assert_eq!((e["licence"].as_str(), e["media"].as_str()), (Some("royalty-free"), Some("image")), "{e}");
|
||||
}
|
||||
let png = c.ok("registry_show", json!({"capability": "wai.image.png"}));
|
||||
assert_eq!(png["registry"]["capability"], "wai.image.png");
|
||||
assert_eq!(png["this_build"]["licence"], png["registry"]["licence"]);
|
||||
assert!(!png["spec_lines"].as_array().unwrap().is_empty(), "{png}");
|
||||
let e = c.call("registry_show", json!({"capability": "wai.image.nothing"})).unwrap_err();
|
||||
assert!(e.contains("not in the registry") && e.contains("registry_list"), "{e}");
|
||||
let s = c.ok("docs_search", json!({"query": "capability fallback", "limit": 5}));
|
||||
let hits = s["hits"].as_array().unwrap();
|
||||
assert_eq!(hits.len(), 5);
|
||||
for h in hits {
|
||||
let t = h["text"].as_str().unwrap().to_ascii_lowercase();
|
||||
assert!(t.contains("capability") && t.contains("fallback"), "{h}");
|
||||
assert!(h["line"].as_u64().unwrap() > 0 && h["file"].as_str().unwrap().ends_with(".md"));
|
||||
}
|
||||
assert_eq!(hits[0]["file"], "SPEC.md", "SPEC.md is searched first");
|
||||
assert!(c.call("docs_search", json!({"query": " "})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packaging_and_inspecting_stay_inside_the_project() {
|
||||
let d = scratch("package");
|
||||
let mut c = Client::start(&d, &[]);
|
||||
let input = d.join("hello.txt");
|
||||
std::fs::write(&input, b"hello, world").unwrap();
|
||||
let p = c.ok("package_build", json!({"input": input, "capability": "wai.text.zstd", "intent": "improve"}));
|
||||
assert_eq!((p["format"].as_str(), p["components"][0]["capability"].as_str()), (Some("WAI1"), Some("wai.text.zstd")), "{p}");
|
||||
let out = d.join("hello.txt.wai");
|
||||
assert!(out.is_file());
|
||||
assert!(c.call("package_build", json!({"input": input, "capability": "wai.text.zstd", "intent": "improve"})).unwrap_err().contains("exists"));
|
||||
c.ok("package_build", json!({"input": input, "capability": "wai.text.zstd", "intent": "improve", "overwrite": true}));
|
||||
let outside = std::env::temp_dir().join(format!("wai-cli-mcp-outside-{}.wai", std::process::id()));
|
||||
let e = c.call("package_build", json!({"input": input, "capability": "wai.text.zstd", "output": outside})).unwrap_err();
|
||||
assert!(e.contains("outside the project directory") && !outside.exists(), "{e}");
|
||||
assert!(c.call("package_build", json!({"input": input, "capability": "wai.text.zstd", "output": "../escape.wai"})).is_err());
|
||||
assert!(c.call("package_build", json!({"input": input, "capability": "not.a.capability"})).is_err());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&outside, d.join("link.wai")).unwrap();
|
||||
assert!(c.call("package_build", json!({"input": input, "capability": "wai.text.zstd", "output": "link.wai", "overwrite": true})).unwrap_err().contains("a link"));
|
||||
assert!(!outside.exists(), "nothing written through a link");
|
||||
std::fs::remove_file(d.join("link.wai")).unwrap();
|
||||
}
|
||||
let i = c.ok("envelope_inspect", json!({"path": out}));
|
||||
assert_eq!((i["kind"].as_str(), i["format"].as_str(), i["intent"].as_str()), (Some("envelope"), Some("WAI1"), Some("improve")), "{i}");
|
||||
let comp = &i["components"][0];
|
||||
assert_eq!(comp["capability"], "wai.text.zstd");
|
||||
assert_eq!(comp["bytes"], 12);
|
||||
assert!(comp["licence"].is_string() && comp["tier"].is_string(), "{comp}");
|
||||
assert_eq!(i["checks"]["component_roles"], "ok");
|
||||
let pkg = c.ok("envelope_inspect", json!({"path": d}));
|
||||
assert_eq!(pkg["kind"], "package");
|
||||
assert_eq!(pkg["envelopes"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(pkg["listing_sha256"].as_str().unwrap().len(), 64);
|
||||
assert!(pkg["capabilities"]["wai.text.zstd"].is_object(), "{pkg}");
|
||||
assert!(c.call("envelope_inspect", json!({"path": input})).unwrap_err().contains("not a WAI envelope"));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// `reject step=N reason=X` or `accept` as the corpus states it.
|
||||
fn expected_receipt(e: &Value) -> (String, Option<u64>, Option<String>) {
|
||||
(e["outcome"].as_str().unwrap().replace("reject", "refuse"), e["step"].as_u64(), e["reason"].as_str().map(String::from))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipts_agree_with_the_jwp_corpus_and_a_changed_one_is_refused() {
|
||||
let d = scratch("receipts");
|
||||
let mut c = Client::start(&d, &[]);
|
||||
let doc = corpus("jwp-conformance/vectors.json");
|
||||
let mut n = 0;
|
||||
for v in doc["vectors"].as_array().unwrap() {
|
||||
let i = &v["input"];
|
||||
let args = match v["procedure"].as_str() {
|
||||
Some("group") => {
|
||||
let mut a = json!({"receipt": i["group"]});
|
||||
if let Some(p) = i.get("previous") {
|
||||
a["previous"] = p.clone();
|
||||
}
|
||||
a
|
||||
}
|
||||
Some("object") => json!({"receipt": i["object"], "group": i["group"], "envelope_hex": i["envelope"]}),
|
||||
_ => continue,
|
||||
};
|
||||
let got = c.ok("receipt_verify", args);
|
||||
assert_eq!(got["kind"], "delivery", "{}: the kind read from the arguments", v["name"]);
|
||||
let (want, step, reason) = expected_receipt(&v["expected"]);
|
||||
assert_eq!(got["verdict"].as_str(), Some(want.as_str()), "{}: {got}", v["name"]);
|
||||
if want == "refuse" {
|
||||
assert_eq!((got["step"].as_u64(), got["reason"].as_str().map(String::from)), (step, reason), "{}: {got}", v["name"]);
|
||||
} else if let Some(h) = v["expected"]["link_hash"].as_str() {
|
||||
assert_eq!(got["link_hash"], h, "{}", v["name"]);
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
assert_eq!(n, 54, "every group and object vector");
|
||||
// A group receipt that verifies, its figure changed: refused.
|
||||
let good = doc["vectors"].as_array().unwrap().iter().find(|v| v["procedure"] == "group" && v["expected"]["outcome"] == "accept" && v["input"].get("previous").is_none()).unwrap();
|
||||
let mut bad = good["input"]["group"].clone();
|
||||
bad["joules_total"] = json!(bad["joules_total"].as_u64().unwrap() + 1);
|
||||
let r = c.ok("receipt_verify", json!({"receipt": bad}));
|
||||
assert_eq!((r["verdict"].as_str(), r["reason"].as_str()), (Some("refuse"), Some("signature_verify_failed")), "{r}");
|
||||
let r = c.ok("receipt_verify", json!({"kind": "delivery", "receipt": good["input"]["group"].to_string()}));
|
||||
assert_eq!((r["kind"].as_str(), r["receipt"].as_str(), r["verdict"].as_str()), (Some("delivery"), Some("group"), Some("accept")), "a receipt given as its text: {r}");
|
||||
assert!(c.call("receipt_verify", json!({"receipt": {"kind": "elsewhere"}})).is_err());
|
||||
// A kind the tool does not take, arguments naming none, and arguments naming two.
|
||||
assert!(c.call("receipt_verify", json!({"kind": "letter", "receipt": bad})).unwrap_err().contains("kind"));
|
||||
assert!(c.call("receipt_verify", json!({})).is_err());
|
||||
assert!(c.call("receipt_verify", json!({"receipt": bad, "claim": "x"})).unwrap_err().contains("give kind"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claims_agree_with_the_claim_corpus() {
|
||||
let d = scratch("claims");
|
||||
let mut c = Client::start(&d, &[]);
|
||||
let doc = corpus("claim-conformance/vectors.json");
|
||||
let mut n = 0;
|
||||
let taken = ["previous", "object", "oer", "gap_object"];
|
||||
for v in doc["vectors"].as_array().unwrap() {
|
||||
let ctx = v.get("context").cloned().unwrap_or(json!({}));
|
||||
if v["procedure"] != "claim" || ctx.as_object().unwrap().keys().any(|k| !taken.contains(&k.as_str())) {
|
||||
continue;
|
||||
}
|
||||
let mut args = ctx.clone();
|
||||
args["claim"] = v["receipt"].clone();
|
||||
let got = c.ok("receipt_verify", args);
|
||||
assert_eq!(got["kind"], "claim", "{}", v["name"]);
|
||||
let e = &v["expected"];
|
||||
if e["outcome"] == "accept" {
|
||||
assert_eq!(got["verdict"], "accept", "{}: {got}", v["name"]);
|
||||
if let Some(h) = e["link_hash"].as_str() {
|
||||
assert_eq!(got["link_hash"], h, "{}", v["name"]);
|
||||
}
|
||||
} else {
|
||||
assert_eq!((got["verdict"].as_str(), got["step"].as_u64(), got["reason"].as_str()), (Some("refuse"), e["step"].as_u64(), e["reason"].as_str()), "{}: {got}", v["name"]);
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
assert!(n > 340, "{n} claim vectors, alone or with the context the tool takes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rooms_agree_with_the_live_rooms_corpus() {
|
||||
let d = scratch("rooms");
|
||||
let mut c = Client::start(&d, &[]);
|
||||
let doc = corpus("live-conformance/rooms/vectors.json");
|
||||
for v in doc["vectors"].as_array().unwrap() {
|
||||
let got = c.ok("receipt_verify", json!({"kind": "room_session", "descriptor": v["descriptor"], "grants": v["grants"], "key_log_head": v["key_log_head"], "chains": v["chains"]}));
|
||||
assert_eq!(got["kind"], "room_session");
|
||||
assert_eq!(got["outcome"], v["outcome"], "{}: {got}", v["name"]);
|
||||
match v["outcome"].as_str().unwrap() {
|
||||
"refuse" => assert_eq!((&got["reason"], &got["at"]), (&v["reason"], &v["at"]), "{}", v["name"]),
|
||||
"gap" => assert_eq!((&got["gaps"], &got["room"]), (&v["gaps"], &v["room"]), "{}", v["name"]),
|
||||
_ => assert_eq!(got["room"], v["room"], "{}", v["name"]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_bundles_agree_with_the_stream_audit_corpus() {
|
||||
let d = scratch("audit");
|
||||
let mut c = Client::start(&d, &[]);
|
||||
let doc = corpus("live-conformance/audit/vectors.json");
|
||||
for s in doc["sessions"].as_array().unwrap() {
|
||||
let got = c.ok("receipt_verify", json!({"bundle": s["bundle"], "owner": doc["owner"], "channels": doc["channels"]}));
|
||||
assert_eq!(got["kind"], "audit_bundle");
|
||||
assert_eq!(got["outcome"], s["outcome"]["outcome"], "{}: {got}", s["name"]);
|
||||
if s["outcome"]["outcome"] == "refuse" {
|
||||
assert_eq!(got["reason"], s["outcome"]["reason"], "{}", s["name"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corpus_runs_through_its_independent_verifier() {
|
||||
let d = scratch("conformance");
|
||||
let mut c = Client::start(&d, &[]);
|
||||
let l = c.ok("conformance_list", json!({}));
|
||||
let pairs: Vec<(String, String)> = l["corpora"].as_array().unwrap().iter().map(|p| (p["corpus"].as_str().unwrap().into(), p["verifier"].as_str().unwrap().into())).collect();
|
||||
assert!(pairs.contains(&("live-conformance/rooms".into(), "live-conformance/live_rooms_verify.py".into())), "{pairs:?}");
|
||||
assert!(pairs.contains(&("claim-conformance".into(), "claim-conformance/claim_verify.py".into())), "{pairs:?}");
|
||||
let r = c.ok("conformance_run", json!({"corpus": "live-conformance/rooms", "timeout_s": 600}));
|
||||
assert_eq!(r["passed"], true, "{r}");
|
||||
assert_eq!(r["exit_code"], 0);
|
||||
assert!(c.call("conformance_run", json!({"corpus": "no-such-conformance"})).unwrap_err().contains("no verifier"));
|
||||
let e = c.call("conformance_run", json!({"corpus": "live-conformance/rooms", "verifier": "../scripts/check-spec-refs.py"})).unwrap_err();
|
||||
assert!(e.contains("outside") || e.contains("_verify.py"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn development_tools_refuse_any_service_off_this_machine() {
|
||||
let d = scratch("dev");
|
||||
let mut c = Client::start(&d, &["--toolset", "core,dev"]);
|
||||
for remote in ["http://10.0.0.5:9682", "https://127.0.0.1:9682", "http://room.example/room-api", "http://127.0.0.1.example:80"] {
|
||||
let e = c.call("room_grant", json!({"principal": "alice", "role": "publisher", "service": remote})).unwrap_err();
|
||||
assert!(e.starts_with("DEVELOPMENT ONLY"), "{remote}: {e}");
|
||||
}
|
||||
// A target other than local is refused, whatever the service.
|
||||
for t in ["room_grant", "room_join", "room_leave", "room_session"] {
|
||||
let e = c.call(t, json!({"target": "cloud", "principal": "alice", "role": "publisher"})).unwrap_err();
|
||||
assert!(e.contains("target local alone"), "{t}: {e}");
|
||||
}
|
||||
// A loopback service is asked: with nothing listening, the connection is what fails.
|
||||
let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
let e = c.call("room_grant", json!({"target": "local", "principal": "alice", "role": "publisher", "service": format!("http://127.0.0.1:{port}")})).unwrap_err();
|
||||
assert!(!e.starts_with("DEVELOPMENT ONLY") && e.contains(&port.to_string()), "{e}");
|
||||
// With no room running, each tool says how to start one.
|
||||
for t in ["room_join", "room_leave"] {
|
||||
assert!(c.call(t, json!({"principal": "alice"})).is_err());
|
||||
}
|
||||
assert!(c.call("room_session", json!({})).unwrap_err().contains("dev_start"));
|
||||
assert!(c.call("dev_stop", json!({})).is_err());
|
||||
assert!(c.call("room_grant", json!({"principal": "alice", "role": "publisher"})).unwrap_err().contains("dev_start"));
|
||||
let e = c.call("dev_start", json!({"room_bin": d.join("no-such-program")})).unwrap_err();
|
||||
assert!(e.contains("no-such-program"), "{e}");
|
||||
}
|
||||
192
wai/wai-rs/tests/cli_setup_agent.rs
Normal file
192
wai/wai-rs/tests/cli_setup_agent.rs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
//! `wai setup-agent` in a scratch project, run as an agent runs it (no terminal, so its output
|
||||
//! is the toolchain's JSON envelope, `envelope/1`, and it asks nothing):
|
||||
//!
|
||||
//! - a fresh project gets AGENTS.md and `.mcp.json`, and the configuration files of the agents
|
||||
//! whose directories it holds, and no CLAUDE.md; a second run changes nothing;
|
||||
//! - a CLAUDE.md the project has gains an import of AGENTS.md;
|
||||
//! - a file that exists is changed only with `--confirm`: without it nothing that exists is
|
||||
//! written, the command exits 4 (confirmation required) with each proposed change and the
|
||||
//! command that confirms; with it the block is added and everything else in the file is kept;
|
||||
//! `--dry-run` writes nothing;
|
||||
//! - `--toolset` registers the server with those toolsets;
|
||||
//! - a JSON file it cannot read as JSON is left alone, with the entry to add by hand;
|
||||
//! - nothing outside the project directory is touched, through a link or otherwise;
|
||||
//! - its errors carry the table's exit status and a stable code: 2 usage, 5 not found.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn scratch(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("wai-cli-setup-agent-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.canonicalize().unwrap()
|
||||
}
|
||||
|
||||
/// Run it in `dir`: its exit status and its JSON.
|
||||
fn run(dir: &Path, extra: &[&str]) -> (i32, Value) {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_wai")).arg("setup-agent").arg("--dir").arg(dir).args(extra).output().expect("run wai");
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let v: Value = serde_json::from_str(text.trim()).unwrap_or_else(|e| panic!("JSON when not on a terminal: {e}: {text}"));
|
||||
(out.status.code().unwrap(), v)
|
||||
}
|
||||
|
||||
fn files(v: &Value) -> &Vec<Value> {
|
||||
assert_eq!((v["wai"].as_str(), v["op"].as_str()), (Some("envelope/1"), Some("agent.setup")), "the toolchain's envelope: {v}");
|
||||
v["result"]["files"].as_array().unwrap()
|
||||
}
|
||||
|
||||
fn action(v: &Value, path: &str) -> String {
|
||||
files(v).iter().find(|f| f["path"] == path).map(|f| f["action"].as_str().unwrap().to_string()).unwrap_or_else(|| "absent".into())
|
||||
}
|
||||
|
||||
fn read(p: PathBuf) -> String {
|
||||
std::fs::read_to_string(p).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_project_is_set_up_and_a_second_run_changes_nothing() {
|
||||
let d = scratch("fresh");
|
||||
std::fs::create_dir(d.join(".vscode")).unwrap();
|
||||
let (code, v) = run(&d, &[]);
|
||||
assert_eq!(code, 0, "{v}");
|
||||
assert_eq!(v["ok"], true);
|
||||
for p in ["AGENTS.md", ".mcp.json", ".vscode/mcp.json"] {
|
||||
assert_eq!(action(&v, p), "created", "{p}: {v}");
|
||||
}
|
||||
assert_eq!(action(&v, ".cursor/mcp.json"), "absent", "an agent whose directory is not there is left out");
|
||||
assert_eq!(action(&v, "CLAUDE.md"), "absent", "no CLAUDE.md is made where the project has none");
|
||||
assert!(!d.join("CLAUDE.md").exists());
|
||||
let agents = read(d.join("AGENTS.md"));
|
||||
assert!(agents.contains("## WAI") && agents.contains("wai mcp") && agents.contains("receipt_verify"), "{agents}");
|
||||
let mcp: Value = serde_json::from_str(&read(d.join(".mcp.json"))).unwrap();
|
||||
assert_eq!(mcp["mcpServers"]["wai"], json!({"command": "wai", "args": ["mcp"]}));
|
||||
let vs: Value = serde_json::from_str(&read(d.join(".vscode/mcp.json"))).unwrap();
|
||||
assert_eq!(vs["servers"]["wai"], json!({"type": "stdio", "command": "wai", "args": ["mcp"]}));
|
||||
let (code, v) = run(&d, &[]);
|
||||
assert_eq!(code, 0);
|
||||
assert!(files(&v).iter().all(|f| f["action"] == "unchanged"), "{v}");
|
||||
// --all writes every agent's file, a directory it needs made inside the project.
|
||||
let (code, v) = run(&d, &["--all", "--confirm"]);
|
||||
assert_eq!(code, 0, "{v}");
|
||||
for p in [".cursor/mcp.json", ".gemini/settings.json", "opencode.json"] {
|
||||
assert_eq!(action(&v, p), "created", "{p}");
|
||||
}
|
||||
let g: Value = serde_json::from_str(&read(d.join(".gemini/settings.json"))).unwrap();
|
||||
assert_eq!(g["context"]["fileName"][0], "AGENTS.md", "{g}");
|
||||
let oc: Value = serde_json::from_str(&read(d.join("opencode.json"))).unwrap();
|
||||
assert_eq!(oc["mcp"]["wai"], json!({"type": "local", "command": ["wai", "mcp"], "enabled": true}));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_existing_file_changes_only_when_confirmed() {
|
||||
let d = scratch("existing");
|
||||
let mine = "# My project\n\nRun `make test` before you commit.\n";
|
||||
let claude = "# Notes for one agent\n";
|
||||
std::fs::write(d.join("AGENTS.md"), mine).unwrap();
|
||||
std::fs::write(d.join("CLAUDE.md"), claude).unwrap();
|
||||
std::fs::write(d.join(".mcp.json"), r#"{"mcpServers": {"other": {"command": "other-server"}}}"#).unwrap();
|
||||
// A dry run writes nothing, existing or not.
|
||||
let (code, v) = run(&d, &["--dry-run"]);
|
||||
assert_eq!(code, 0, "{v}");
|
||||
assert_eq!((action(&v, "AGENTS.md"), action(&v, "CLAUDE.md")), ("would_update".into(), "would_update".into()));
|
||||
assert_eq!(read(d.join("AGENTS.md")), mine);
|
||||
// Without --confirm: what exists waits, and the command says how to confirm (exit 4).
|
||||
let (code, v) = run(&d, &[]);
|
||||
assert_eq!(code, 4, "confirmation required: {v}");
|
||||
assert_eq!((v["ok"].as_bool(), v["error"]["code"].as_str(), v["error"]["retryable"].as_bool()), (Some(false), Some("confirmation-required"), Some(false)), "{v}");
|
||||
for p in ["AGENTS.md", "CLAUDE.md", ".mcp.json"] {
|
||||
assert_eq!(action(&v, p), "needs_confirmation", "{p}");
|
||||
}
|
||||
assert_eq!(read(d.join("AGENTS.md")), mine, "nothing that exists was changed");
|
||||
assert_eq!(read(d.join("CLAUDE.md")), claude, "nothing that exists was changed");
|
||||
let proposed = v["proposed"].as_array().unwrap();
|
||||
assert_eq!(proposed.len(), 3, "{v}");
|
||||
let diff = proposed.iter().find(|f| f["path"] == "AGENTS.md").unwrap()["diff"].as_str().unwrap().to_string();
|
||||
assert!(diff.starts_with("--- a/AGENTS.md\n+++ b/AGENTS.md\n") && diff.contains("+## WAI"), "{diff}");
|
||||
let confirm = v["confirm"].as_str().unwrap();
|
||||
assert!(confirm.ends_with("--confirm") && confirm.contains(d.to_str().unwrap()), "{confirm}");
|
||||
// With it, the block is added and the rest kept.
|
||||
let (code, v) = run(&d, &["--confirm"]);
|
||||
assert_eq!(code, 0, "{v}");
|
||||
assert_eq!(action(&v, "AGENTS.md"), "updated");
|
||||
let c = read(d.join("CLAUDE.md"));
|
||||
assert!(c.starts_with(claude) && c.lines().any(|l| l == "@AGENTS.md"), "a CLAUDE.md the project has imports AGENTS.md: {c}");
|
||||
let agents = read(d.join("AGENTS.md"));
|
||||
assert!(agents.starts_with(mine) && agents.contains("## WAI"), "{agents}");
|
||||
let mcp: Value = serde_json::from_str(&read(d.join(".mcp.json"))).unwrap();
|
||||
assert_eq!(mcp["mcpServers"]["other"]["command"], "other-server", "the other server is kept");
|
||||
assert_eq!(mcp["mcpServers"]["wai"]["args"][0], "mcp");
|
||||
// Once more: the block is replaced in place, not added twice.
|
||||
let (code, _) = run(&d, &["--confirm"]);
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(read(d.join("AGENTS.md")).matches("## WAI").count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_not_plain_json_is_left_alone() {
|
||||
let d = scratch("jsonc");
|
||||
std::fs::create_dir(d.join(".vscode")).unwrap();
|
||||
let jsonc = "{\n // the user's comment\n \"servers\": {}\n}\n";
|
||||
std::fs::write(d.join(".vscode/mcp.json"), jsonc).unwrap();
|
||||
let (code, v) = run(&d, &["--confirm"]);
|
||||
assert_eq!(code, 0, "{v}");
|
||||
assert_eq!(action(&v, ".vscode/mcp.json"), "skipped");
|
||||
let f = files(&v).iter().find(|f| f["path"] == ".vscode/mcp.json").unwrap();
|
||||
assert!(f["reason"].as_str().unwrap().contains("add by hand"), "{f}");
|
||||
assert_eq!(read(d.join(".vscode/mcp.json")), jsonc);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn nothing_outside_the_project_is_touched() {
|
||||
let d = scratch("links");
|
||||
let outside = scratch("links-outside");
|
||||
std::fs::write(outside.join("AGENTS.md"), "outside\n").unwrap();
|
||||
std::os::unix::fs::symlink(outside.join("AGENTS.md"), d.join("AGENTS.md")).unwrap();
|
||||
std::os::unix::fs::symlink(&outside, d.join(".cursor")).unwrap();
|
||||
let (code, v) = run(&d, &["--confirm", "--all"]);
|
||||
assert_eq!(code, 0, "{v}");
|
||||
assert_eq!(action(&v, "AGENTS.md"), "skipped");
|
||||
assert_eq!(action(&v, ".cursor/mcp.json"), "skipped");
|
||||
assert_eq!(read(outside.join("AGENTS.md")), "outside\n");
|
||||
assert!(!outside.join("mcp.json").exists(), "nothing written through the linked directory");
|
||||
let names: Vec<_> = std::fs::read_dir(&outside).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||
assert_eq!(names.len(), 1, "{names:?}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_toolsets_named_are_registered() {
|
||||
let d = scratch("toolsets");
|
||||
std::fs::write(d.join("opencode.json"), "{}\n").unwrap();
|
||||
let (code, v) = run(&d, &["--toolset", "core,dev", "--confirm"]);
|
||||
assert_eq!(code, 0, "{v}");
|
||||
let mcp: Value = serde_json::from_str(&read(d.join(".mcp.json"))).unwrap();
|
||||
assert_eq!(mcp["mcpServers"]["wai"], json!({"command": "wai", "args": ["mcp", "--toolset", "core,dev"]}));
|
||||
let oc: Value = serde_json::from_str(&read(d.join("opencode.json"))).unwrap();
|
||||
assert_eq!(oc["mcp"]["wai"]["command"], json!(["wai", "mcp", "--toolset", "core,dev"]));
|
||||
assert!(read(d.join("AGENTS.md")).contains("`wai mcp --toolset core,dev`"));
|
||||
let (code, v) = run(&d, &["--toolset", "quantum"]);
|
||||
assert_eq!((code, v["error"]["code"].as_str()), (2, Some("usage")), "{v}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_usage_error_is_structured() {
|
||||
let d = scratch("usage");
|
||||
let (code, v) = run(&d, &["--frobnicate"]);
|
||||
assert_eq!(code, 2);
|
||||
assert_eq!((v["wai"].as_str(), v["op"].as_str(), v["ok"].as_bool(), v["error"]["code"].as_str()), (Some("envelope/1"), Some("agent.setup"), Some(false), Some("usage")), "{v}");
|
||||
assert!(v["error"]["message"].as_str().unwrap().contains("--frobnicate") && v["error"]["hint"].is_string(), "{v}");
|
||||
let (code, v) = run(&d, &["--only", "README.md"]);
|
||||
assert_eq!((code, v["error"]["code"].as_str()), (2, Some("usage")), "{v}");
|
||||
let (code, v) = run(&d.join("not-there"), &[]);
|
||||
assert_eq!((code, v["error"]["code"].as_str()), (5, Some("not-found")), "{v}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
Loading…
Reference in a new issue