diff --git a/.github/workflows/joule-moq-room.yml b/.github/workflows/joule-moq-room.yml index a59e4933d..d5417bc75 100644 --- a/.github/workflows/joule-moq-room.yml +++ b/.github/workflows/joule-moq-room.yml @@ -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 diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 5a7df690b..f19903a71 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -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 diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml index 752429920..08f506d17 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml @@ -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" diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/README.md b/joule-code/joule-code-rs/crates/joule-moq-room/README.md index aa5f39df9..98ee078a6 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-room/README.md @@ -183,6 +183,8 @@ joule-moq-room relay-lines those lines again joule-moq-room invite [] an invitation to host a remote desktop room, signed by the room's authority, good for seconds (3600) joule-moq-room --config serve (room.example.toml shows every field) +joule-moq-room dev [--publishers ] 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 diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/openapi.json b/joule-code/joule-code-rs/crates/joule-moq-room/openapi.json new file mode 100644 index 000000000..eea38c92b --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-room/openapi.json @@ -0,0 +1,3838 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "WAI room service", + "version": "0.1.0", + "license": { + "name": "Apache-2.0", + "identifier": "Apache-2.0" + }, + "description": "The HTTP API of the room service (`joule-moq-room`), which keeps WAI live rooms (`wai/extensions/live-rooms.md`) over a relay: it issues grants, authors each room's lifecycle on its clock's events track, and serves the signed room. A configuration turns on the modes a room keeps: end-to-end encryption (`[room] e2ee`), admission by age (`[age]`), co-performance (`[co]`), a watch room (`[room] watch_source`), a remote desktop room (`[remote]`), a conversation (`[messaging]`) or calls (`[call]`). Each mode's paths answer 404 in a service that does not keep it.\n\nEvery answer but a watch room's source is JSON with `Cache-Control: no-store`. Each operation's `operationId` is its name in the WAI toolchain's catalogue, `noun.verb` (`room.grant`), the name every other door derives its own from: the MCP tool `room_grant`, the command `wai room grant`. A path answers 405 to a method it does not take (`/msg/*` and `/call/*` paths answer 404). The statuses listed for each operation are those its handler can return; `tests/openapi_contract.rs` holds this description to the service, route by route and status by status.\n\nRequests that a principal authorises carry its grant as `Authorization: Bearer ` and a proof of possession of the grant's key in `WAI-Room-Proof`, made over a challenge the service issued in `WAI-Room-Challenge` (live-rooms §10.5.3); a request that proves only a key carries the proof alone, naming no grant. The service listens on loopback behind a front that serves it under `public_prefix`; a proof names the path the client addressed." + }, + "servers": [ + { + "url": "http://127.0.0.1:9682", + "description": "the example configuration's address (`room.example.toml`)" + }, + { + "url": "http://127.0.0.1:8080/room-api", + "description": "the service behind `wai dev`'s front, on its default port" + } + ], + "tags": [ + { + "name": "room", + "description": "every room" + }, + { + "name": "age", + "description": "admission by age (age-assurance §5.6)" + }, + { + "name": "e2ee", + "description": "end-to-end encrypted rooms (confidentiality)" + }, + { + "name": "co-performance", + "description": "co-performance" + }, + { + "name": "watch", + "description": "watch rooms (co-watch)" + }, + { + "name": "remote", + "description": "remote desktop rooms (remote-desktop)" + }, + { + "name": "messaging", + "description": "a conversation (live-messaging)" + }, + { + "name": "calls", + "description": "calls (live-messaging §10)" + } + ], + "paths": { + "/": { + "get": { + "operationId": "service.show", + "tags": [ + "room" + ], + "summary": "The service's health", + "description": "As `GET /health`.", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "relay": { + "type": "boolean", + "description": "the clock's session on the relay is open" + }, + "room": { + "$ref": "#/components/schemas/Hex32" + } + }, + "required": [ + "ok", + "relay", + "room" + ] + } + } + } + } + } + } + }, + "/health": { + "get": { + "operationId": "service.check", + "tags": [ + "room" + ], + "summary": "The service's health", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "relay": { + "type": "boolean", + "description": "the clock's session on the relay is open" + }, + "room": { + "$ref": "#/components/schemas/Hex32" + } + }, + "required": [ + "ok", + "relay", + "room" + ] + } + } + } + } + } + } + }, + "/room": { + "get": { + "operationId": "room.show", + "tags": [ + "room" + ], + "summary": "The current room", + "description": "The room a page verifies before it enters (live-rooms §9): its id, `t0_us`, the signed descriptor and composition descriptor, the publishers' grants, how many publish and may, and the record of each mode the room keeps (end-to-end encryption, remote desktop, admission by age, co-performance, watch, shared world), with the service's clock in `now_us`.", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Room" + } + } + } + } + } + } + }, + "/grant": { + "post": { + "operationId": "room.grant", + "tags": [ + "room" + ], + "summary": "A grant for a key", + "description": "A short-lived WRG1 grant for the key, which a relay honours only beside a proof of possession made with that key (live-rooms §10.5). The body is at most 1 KiB. In a room that admits by age the request carries `Authorization: PrivateToken token=\"…\"` (RFC 9577): without one the answer is 401 with a `WWW-Authenticate: PrivateToken` challenge for each key of the room's policy. A call room's grants come with its call (`/call/place`, `/call/answer`): there it is 403.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "principal": { + "$ref": "#/components/schemas/Hex32" + }, + "role": { + "type": "string", + "enum": [ + "publisher", + "viewer", + "host", + "controller" + ], + "description": "publisher or viewer; in a remote desktop room host, controller or viewer" + }, + "ai": { + "$ref": "#/components/schemas/Hex32", + "description": "a publisher's, in a room with co-performance that takes AI participants: the digest of the model that acts for it (live-rooms §4.4)" + }, + "invitation": { + "type": "string", + "description": "a remote desktop room's host without a configured key: an invitation the room's authority signed" + } + }, + "required": [ + "principal", + "role" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrantAnswer" + } + } + } + }, + "400": { + "description": "the request does not read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "a room that admits by age, and no token: `{\"error\": \"token_required\", \"age\"}` with its challenges", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "a token refused (age-assurance §11's reason), a remote desktop room's host or controller refused (`not_the_host`, `invitation_required`, `invitation_used`, `invitation_expired`, `invitation_bad_signature`, `control_not_allowed`), or a call room", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "`full` (the room has its publishers), `busy` (it has taken its grants), or `hosted` (a remote room's host holds its place)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "the body is larger than the request takes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "past the client's rate: retry after retry_after_s", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "`relay`: the clock's session on the relay is not open; or, admitting by age, the window's nonces are at their bound", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/age/receipt": { + "get": { + "operationId": "admission.show", + "tags": [ + "age" + ], + "summary": "The room's admission receipt so far", + "description": "In a room that admits by age (`[age]`); 404 in any other.", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "receipt": { + "type": "object", + "description": "the wai-age-admissions receipt (age-assurance §7), signed by the room's clock, every count under 50 suppressed" + }, + "policy": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "past the client's rate: retry after retry_after_s", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/mls": { + "get": { + "operationId": "mls.tail", + "tags": [ + "e2ee" + ], + "summary": "The room's log of MLS messages", + "description": "An end-to-end encrypted room's log (confidentiality §5.1, §15.2) from entry `from`: the clock's lifecycle records (`event`) and every GroupInfo, commit and Welcome carried (`mls`), in the order the clock took them; 404 in a room that is not end-to-end encrypted.", + "parameters": [ + { + "name": "room", + "in": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/Hex32" + }, + "description": "the room's id" + }, + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0 + }, + "description": "the first entry wanted (default 0)" + }, + { + "name": "wait_ms", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 8000 + }, + "description": "how long to hold the request while the log has nothing past `from` (default and most 8000)" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "room": { + "$ref": "#/components/schemas/Hex32" + }, + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "seq": { + "type": "integer" + }, + "event": { + "$ref": "#/components/schemas/Hex" + }, + "mls": { + "$ref": "#/components/schemas/Hex" + } + } + } + }, + "next": { + "type": "integer" + }, + "end": { + "type": [ + "integer", + "boolean" + ] + } + } + } + } + } + }, + "400": { + "description": "the request does not read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "410": { + "description": "a room since replaced", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/mls/key-package": { + "post": { + "operationId": "mls.import", + "tags": [ + "e2ee" + ], + "summary": "A KeyPackage for a granted principal's Add", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "room": { + "$ref": "#/components/schemas/Hex32" + }, + "key_package": { + "$ref": "#/components/schemas/Hex" + } + }, + "required": [ + "room", + "key_package" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "principal": { + "$ref": "#/components/schemas/Hex32" + } + } + } + } + } + }, + "400": { + "description": "the request does not read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "`not granted`: the KeyPackage's principal holds no grant of the room", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "410": { + "description": "a room since replaced", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "the body is larger than the request takes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/mls/post": { + "post": { + "operationId": "mls.update", + "tags": [ + "e2ee" + ], + "summary": "A GroupInfo, or a commit and its Welcome", + "description": "Checked by the room's clock (confidentiality §5.5), then carried in the log. The body is at most 128 KiB.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "room": { + "$ref": "#/components/schemas/Hex32" + }, + "frames": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Hex" + }, + "minItems": 1 + } + }, + "required": [ + "room", + "frames" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "seq": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "the request does not read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "the clock refuses it (its reason); it is not carried", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "410": { + "description": "a room since replaced", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "the body is larger than the request takes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/co/objects": { + "get": { + "operationId": "performance.list", + "tags": [ + "co-performance" + ], + "summary": "The clock's log of objects", + "description": "Co-performance §9.1, from entry `from`, at most 5000 an answer; 404 in a room without co-performance.", + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0 + }, + "description": "the first entry wanted (default 0)" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "room": { + "$ref": "#/components/schemas/Hex32" + }, + "objects": { + "type": "array", + "items": { + "type": "object" + } + }, + "next": { + "type": "integer" + }, + "end": { + "type": "boolean" + }, + "unlogged": { + "type": "integer" + } + } + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/co/state": { + "get": { + "operationId": "performance.show", + "tags": [ + "co-performance" + ], + "summary": "The room's co-performance as the clock holds it", + "description": "Its windows, the marks it signed and those it owes, its log's length and its own records, at room-clock `now`.", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/co/schedule": { + "post": { + "operationId": "schedule.create", + "tags": [ + "co-performance" + ], + "summary": "A turn schedule the clock signs", + "description": "Co-performance §6.1, for a grant with the direct power; `from_us` at least `lead_ms` ahead. Authorised by a grant of the room (`Authorization: Bearer` and the grant's record in base64url) and its proof of possession (`WAI-Room-Proof`) over a challenge the service issued in `WAI-Room-Challenge`, on a 401 and on every answer it accepts (live-rooms §10.5.3); the proof's context is the method and the path the client addressed. The body is at most 4 KiB.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "mark", + "refuse" + ] + }, + "from_us": { + "type": "integer" + }, + "beat_us": { + "type": "integer" + }, + "beats_per_bar": { + "type": "integer" + }, + "segments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "start_us": { + "type": "integer" + }, + "length": { + "type": "integer" + }, + "turns": { + "type": "integer" + }, + "pairs": { + "type": "boolean" + }, + "holders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Hex32" + } + } + } + } + } + }, + "required": [ + "mode", + "segments" + ] + } + } + } + }, + "security": [ + { + "roomGrant": [], + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "event": { + "$ref": "#/components/schemas/Hex32" + }, + "t": { + "type": "integer" + }, + "statement": { + "$ref": "#/components/schemas/Hex" + } + } + } + } + } + }, + "400": { + "description": "the request does not read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "the grant's powers do not allow it", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "the body is larger than the request takes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "the clock could not sign it now", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/co/handover": { + "post": { + "operationId": "handover.create", + "tags": [ + "co-performance" + ], + "summary": "A handover the clock signs", + "description": "Co-performance §6.3, for the holder of the turn at `at_us` (now and `lead_ms` when left out) or a grant with the direct power. Authorised by a grant of the room (`Authorization: Bearer` and the grant's record in base64url) and its proof of possession (`WAI-Room-Proof`) over a challenge the service issued in `WAI-Room-Challenge`, on a 401 and on every answer it accepts (live-rooms §10.5.3); the proof's context is the method and the path the client addressed. The body is at most 4 KiB.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "to": { + "$ref": "#/components/schemas/Hex32" + }, + "at_us": { + "type": "integer" + } + }, + "required": [ + "to" + ] + } + } + } + }, + "security": [ + { + "roomGrant": [], + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "event": { + "$ref": "#/components/schemas/Hex32" + }, + "t": { + "type": "integer" + }, + "statement": { + "$ref": "#/components/schemas/Hex" + } + } + } + } + } + }, + "400": { + "description": "the request does not read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "the grant's powers do not allow it", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "the body is larger than the request takes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "the clock could not sign it now", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/co/window": { + "post": { + "operationId": "window.create", + "tags": [ + "co-performance" + ], + "summary": "A selection window the clock signs", + "description": "Co-performance §7.2, for a grant with the direct power; a draw's seed is drawn by the clock and kept until its selection. Authorised by a grant of the room (`Authorization: Bearer` and the grant's record in base64url) and its proof of possession (`WAI-Room-Proof`) over a challenge the service issued in `WAI-Room-Challenge`, on a 401 and on every answer it accepts (live-rooms §10.5.3); the proof's context is the method and the path the client addressed. The body is at most 4 KiB.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rule": { + "type": "string", + "enum": [ + "plurality", + "reactions", + "draw" + ] + }, + "types": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "prompt", + "sketch", + "vote", + "movement" + ] + } + }, + "options": { + "type": "integer" + }, + "k": { + "type": "integer" + }, + "close_us": { + "type": "integer" + }, + "seconds": { + "type": "integer" + }, + "question": { + "type": "string" + } + }, + "required": [ + "rule" + ] + } + } + } + }, + "security": [ + { + "roomGrant": [], + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "event": { + "$ref": "#/components/schemas/Hex32" + }, + "t": { + "type": "integer" + }, + "statement": { + "$ref": "#/components/schemas/Hex" + } + } + } + } + } + }, + "400": { + "description": "the request does not read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "the grant's powers do not allow it", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "the body is larger than the request takes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "the clock could not sign it now", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/co/fidelity": { + "post": { + "operationId": "fidelity.create", + "tags": [ + "co-performance" + ], + "summary": "A fidelity statement now", + "description": "Co-performance §5.6, for a grant with the direct power; `{\"ok\": false, \"reason\"}` while no intent is covered yet. Authorised by a grant of the room (`Authorization: Bearer` and the grant's record in base64url) and its proof of possession (`WAI-Room-Proof`) over a challenge the service issued in `WAI-Room-Challenge`, on a 401 and on every answer it accepts (live-rooms §10.5.3); the proof's context is the method and the path the client addressed. The body is at most 4 KiB.", + "security": [ + { + "roomGrant": [], + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "event": { + "$ref": "#/components/schemas/Hex32" + }, + "t": { + "type": "integer" + }, + "statement": { + "$ref": "#/components/schemas/Hex" + } + } + }, + { + "type": "object", + "properties": { + "ok": { + "const": false + }, + "reason": { + "type": "string" + } + } + } + ] + } + } + } + }, + "400": { + "description": "a body that is not JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "the grant's powers do not allow it", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "the body is larger than the request takes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "the service could not read its own record", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "the clock could not sign it now", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/watch/time": { + "get": { + "operationId": "time.show", + "tags": [ + "watch" + ], + "summary": "The service's clock", + "description": "For a page's estimate of the room clock (co-watch).", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "now_us": { + "type": "integer" + }, + "t0_us": { + "type": "integer" + } + } + } + } + } + } + } + } + }, + "/watch/source": { + "get": { + "operationId": "source.fetch", + "tags": [ + "watch" + ], + "summary": "The watch room's source, whole", + "description": "The source's bytes, of its media type; a page checks them by their SHA-256, the content hash its policy names (co-watch §3.5). 404 in a room that watches nothing.", + "responses": { + "200": { + "description": "the source's bytes", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/watch/challenge": { + "get": { + "operationId": "watchchallenge.create", + "tags": [ + "watch" + ], + "summary": "A challenge for a join's proof", + "description": "Single use, good for a minute (live-rooms §10.5.3).", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "challenge": { + "$ref": "#/components/schemas/Hex32" + } + } + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/watch": { + "get": { + "operationId": "watch.tail", + "tags": [ + "watch" + ], + "summary": "The watch room's log", + "parameters": [ + { + "name": "room", + "in": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/Hex32" + }, + "description": "the room's id" + }, + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0 + }, + "description": "the first entry wanted (default 0)" + }, + { + "name": "wait_ms", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 8000 + }, + "description": "how long to hold the request while the log has nothing past `from` (default and most 8000)" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "room": { + "$ref": "#/components/schemas/Hex32" + }, + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "seq": { + "type": "integer" + }, + "event": { + "$ref": "#/components/schemas/Hex" + }, + "mls": { + "$ref": "#/components/schemas/Hex" + } + } + } + }, + "next": { + "type": "integer" + }, + "end": { + "type": [ + "integer", + "boolean" + ] + } + } + } + } + } + }, + "400": { + "description": "the request does not read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "410": { + "description": "a room since replaced", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/watch/room": { + "get": { + "operationId": "watch.show", + "tags": [ + "watch" + ], + "summary": "The room as a co-watch verifier is given it", + "description": "Co-watch §11.1, with the latest skew statements read.", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/watch/join": { + "post": { + "operationId": "watch.join", + "tags": [ + "watch" + ], + "summary": "A viewer enters by a proof of its key", + "description": "The proof over a challenge from `GET /watch/challenge` for `POST ///watch/join`; the clock signs the viewer's `join`. The body is at most 4 KiB.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "room": { + "$ref": "#/components/schemas/Hex32" + }, + "grant": { + "$ref": "#/components/schemas/Hex" + }, + "proof": { + "$ref": "#/components/schemas/Hex" + } + }, + "required": [ + "room", + "grant", + "proof" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "t": { + "type": "integer" + }, + "again": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "the request does not read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "a proof that fails (its reason)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "410": { + "description": "a room since replaced", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "the body is larger than the request takes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "the clock could not sign the join now", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/watch/post": { + "post": { + "operationId": "watch.update", + "tags": [ + "watch" + ], + "summary": "A viewer's events, checked and carried in order", + "description": "Each event checked as co-watch §10 has it. The body is at most 16 KiB.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "room": { + "$ref": "#/components/schemas/Hex32" + }, + "events": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Hex" + }, + "minItems": 1, + "maxItems": 8 + } + }, + "required": [ + "room", + "events" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "carried": { + "type": "integer" + }, + "log": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "the request does not read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "the first event refused (its reason, and `carried`: how many before it were carried)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "410": { + "description": "a room since replaced", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "the body is larger than the request takes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/remote/control": { + "post": { + "operationId": "control.grant", + "tags": [ + "remote" + ], + "summary": "The host hands control over", + "description": "A grant exchange (remote-desktop §7.1), the host's grant and proof presented as a co-performance request presents them; it takes effect at the first tick at least `lead_ms` ahead.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "to": { + "$ref": "#/components/schemas/Hex32" + }, + "control": { + "type": "integer", + "minimum": 0, + "maximum": 15, + "description": "classes: 1 pointer, 2 keys, 4 touch, 8 clipboard" + } + }, + "required": [ + "to", + "control" + ] + } + } + } + }, + "security": [ + { + "roomGrant": [], + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "grant": { + "$ref": "#/components/schemas/Hex" + }, + "digest": { + "$ref": "#/components/schemas/Hex32" + }, + "effective": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "the request does not read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "a grant that is not the room's host's", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "the exchange is refused (its reason, `not_present` among them)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "410": { + "description": "a room since replaced", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "the body is larger than the request takes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "the exchange could not be signed at its tick", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/remote/room": { + "get": { + "operationId": "remote.show", + "tags": [ + "remote" + ], + "summary": "The session as the clock holds it", + "description": "What a verifier is given (remote-desktop §11.1). To a grant this room issued and its proof, presented as a co-performance request presents them; bounded per client.", + "security": [ + { + "roomGrant": [], + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "room": { + "$ref": "#/components/schemas/Hex32" + }, + "descriptor": { + "$ref": "#/components/schemas/Hex" + }, + "grants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Hex" + } + }, + "key_log": { + "type": "null" + }, + "chains": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Hex" + } + } + } + } + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "a grant of another room", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "410": { + "description": "a room since replaced", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "past the client's rate: retry after retry_after_s", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/remote/state": { + "get": { + "operationId": "remote.verify", + "tags": [ + "remote" + ], + "summary": "That session verified", + "description": "Remote-desktop §11.3: the outcome, the floor's changes and the floor statements refused, each input's fate, the latencies and each host's totals, with the clock's own floor and the principals it cut. To a grant this room issued and its proof, presented as a co-performance request presents them; bounded per client.", + "security": [ + { + "roomGrant": [], + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "a grant of another room", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "410": { + "description": "a room since replaced", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "past the client's rate: retry after retry_after_s", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/msg": { + "get": { + "operationId": "conversation.show", + "tags": [ + "messaging" + ], + "summary": "The conversation", + "description": "Its descriptor, records and members (live-messaging); 404 when the service keeps no conversation.", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/msg/challenge": { + "get": { + "operationId": "conversationchallenge.create", + "tags": [ + "messaging" + ], + "summary": "A challenge for a proof", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "challenge": { + "$ref": "#/components/schemas/Hex32" + } + } + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/msg/join": { + "post": { + "operationId": "conversation.join", + "tags": [ + "messaging" + ], + "summary": "A key that proves itself is admitted", + "description": "A proof of possession naming no grant (live-rooms §10.5.3); the conversation's first member owns it. The clock signs its `join` (live-messaging §3.4).", + "security": [ + { + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "grant": { + "$ref": "#/components/schemas/Hex" + }, + "digest": { + "$ref": "#/components/schemas/Hex32" + }, + "exp": { + "type": "integer" + }, + "role": { + "type": "string", + "enum": [ + "owner", + "member" + ] + }, + "conversation": { + "type": "object" + } + } + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "`not_listed` or `removed`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "`full`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "past the client's rate: retry after retry_after_s", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "the service could not read its own record", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "the history store could not take it", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/msg/log": { + "get": { + "operationId": "conversation.tail", + "tags": [ + "messaging" + ], + "summary": "The log as it is owed to the caller", + "description": "Live-messaging §9.3, from position `from`, at most 256 entries, held up to 8 s while there is nothing new.", + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0 + }, + "description": "the first entry wanted (default 0)" + }, + { + "name": "wait_ms", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 8000 + }, + "description": "how long to hold the request while the log has nothing past `from` (default and most 8000)" + } + ], + "security": [ + { + "roomGrant": [], + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "room": { + "$ref": "#/components/schemas/Hex32" + }, + "entries": { + "type": "array", + "items": { + "type": "object" + } + }, + "withheld": { + "type": "array", + "items": { + "type": "integer" + } + }, + "next": { + "type": "integer" + }, + "end": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "`not_a_member`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/msg/key-package": { + "post": { + "operationId": "conversation.import", + "tags": [ + "messaging" + ], + "summary": "A member's KeyPackage", + "description": "Kept for the member's Add. A member's request: its grant (`Authorization: Bearer`, the record in base64url) and its proof (`WAI-Room-Proof`) over a challenge the service issued; every answer carries a fresh one in `WAI-Room-Challenge`. The body is at most 128 KiB.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "key_package": { + "$ref": "#/components/schemas/Hex" + } + }, + "required": [ + "key_package" + ] + } + } + } + }, + "security": [ + { + "roomGrant": [], + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "seq": { + "type": "integer" + }, + "end": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "the request does not read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "`not_a_member`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "the body is larger than the request takes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "the history store could not take it", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/msg/mls": { + "post": { + "operationId": "conversation.update", + "tags": [ + "messaging" + ], + "summary": "A GroupInfo, or a commit and its Welcome", + "description": "Checked by the clock and carried; it then signs an epoch statement (live-messaging §7.3). A member's request: its grant (`Authorization: Bearer`, the record in base64url) and its proof (`WAI-Room-Proof`) over a challenge the service issued; every answer carries a fresh one in `WAI-Room-Challenge`. The body is at most 128 KiB.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "frames": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Hex" + }, + "minItems": 1 + } + }, + "required": [ + "frames" + ] + } + } + } + }, + "security": [ + { + "roomGrant": [], + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "seq": { + "type": "integer" + }, + "end": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "the request does not read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "`not_a_member`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "the clock refuses it (its reason)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "the body is larger than the request takes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "the history store could not take it", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/msg/post": { + "post": { + "operationId": "message.create", + "tags": [ + "messaging" + ], + "summary": "A message", + "description": "Its sealed object and receipts, taken after the checks of live-messaging §9.2, once on stable storage. A member's request: its grant (`Authorization: Bearer`, the record in base64url) and its proof (`WAI-Room-Proof`) over a challenge the service issued; every answer carries a fresh one in `WAI-Room-Challenge`. The body is at most 128 KiB.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "group": { + "type": "integer" + }, + "properties": { + "$ref": "#/components/schemas/Hex" + }, + "payload": { + "$ref": "#/components/schemas/Hex" + }, + "receipts": { + "$ref": "#/components/schemas/Hex" + } + }, + "required": [ + "group", + "properties", + "payload", + "receipts" + ] + } + } + } + }, + "security": [ + { + "roomGrant": [], + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "seq": { + "type": "integer" + }, + "end": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "the request does not read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "`not_a_member`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "refused by §9.2 (its reason)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "the body is larger than the request takes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "the history store could not take it", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/msg/ack": { + "post": { + "operationId": "acknowledgement.create", + "tags": [ + "messaging" + ], + "summary": "An acknowledgement", + "description": "The caller's own (live-messaging §9.1). A member's request: its grant (`Authorization: Bearer`, the record in base64url) and its proof (`WAI-Room-Proof`) over a challenge the service issued; every answer carries a fresh one in `WAI-Room-Challenge`. The body is at most 128 KiB.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ack": { + "$ref": "#/components/schemas/Hex" + } + }, + "required": [ + "ack" + ] + } + } + } + }, + "security": [ + { + "roomGrant": [], + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "seq": { + "type": "integer" + }, + "end": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "the request does not read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "`not_a_member`, or another member's acknowledgement", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "refused (its reason)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "the body is larger than the request takes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "the history store could not take it", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/msg/leave": { + "post": { + "operationId": "conversation.leave", + "tags": [ + "messaging" + ], + "summary": "The caller leaves", + "description": "The clock signs its `leave` (reason 1). A member's request: its grant (`Authorization: Bearer`, the record in base64url) and its proof (`WAI-Room-Proof`) over a challenge the service issued; every answer carries a fresh one in `WAI-Room-Challenge`. The body is at most 128 KiB.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + } + } + } + }, + "security": [ + { + "roomGrant": [], + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "seq": { + "type": "integer" + }, + "end": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "a body that is not JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "`not_a_member`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "the body is larger than the request takes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "the service could not read its own record", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "the history store could not take it", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/msg/remove": { + "post": { + "operationId": "member.delete", + "tags": [ + "messaging" + ], + "summary": "The owner removes a member", + "description": "The clock signs its `leave` (reason 2). A member's request: its grant (`Authorization: Bearer`, the record in base64url) and its proof (`WAI-Room-Proof`) over a challenge the service issued; every answer carries a fresh one in `WAI-Room-Challenge`. The body is at most 128 KiB.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "principal": { + "$ref": "#/components/schemas/Hex32" + } + }, + "required": [ + "principal" + ] + } + } + } + }, + "security": [ + { + "roomGrant": [], + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "seq": { + "type": "integer" + }, + "end": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "the request does not read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "`not_a_member`, or `power_not_granted`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "`not_present`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "the body is larger than the request takes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "the service could not read its own record", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "the history store could not take it", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/call/challenge": { + "get": { + "operationId": "callchallenge.create", + "tags": [ + "calls" + ], + "summary": "A challenge for a proof", + "description": "In a call room (`[call]`); 404 in any other.", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "challenge": { + "$ref": "#/components/schemas/Hex32" + } + } + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/call/place": { + "post": { + "operationId": "call.create", + "tags": [ + "calls" + ], + "summary": "Place a call", + "description": "A key proves itself (a proof naming no grant) and names its invitees; the authority signs the call descriptor (WCT1) for the room, and the caller's publisher grant (live-messaging §10). In a call room (`[call]`); 404 in any other.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "invitees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Hex32" + }, + "minItems": 1 + } + }, + "required": [ + "invitees" + ] + } + } + } + }, + "security": [ + { + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "the request does not read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "`busy` (a call is in progress) or `full`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "the body is larger than the request takes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/call/ring": { + "post": { + "operationId": "call.invite", + "tags": [ + "calls" + ], + "summary": "Ring invitees", + "description": "The caller's invitations (WCI1), with its grant and proof; each is checked (§10.2) and held in its callee's inbox. In a call room (`[call]`); 404 in any other.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "invitations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Hex" + }, + "minItems": 1 + } + }, + "required": [ + "invitations" + ] + } + } + } + }, + "security": [ + { + "roomGrant": [], + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "rung": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "the request does not read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "not the caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "an invitation refused (its reason and `at`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "410": { + "description": "no call in this room", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "the body is larger than the request takes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/inbox": { + "get": { + "operationId": "inbox.show", + "tags": [ + "calls" + ], + "summary": "The caller's inbox", + "description": "On a proof of the key (§10.4). In a call room (`[call]`); 404 in any other.", + "security": [ + { + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "principal": { + "$ref": "#/components/schemas/Hex32" + }, + "invitations": { + "type": "array", + "items": { + "type": "object" + } + }, + "now_us": { + "type": "integer" + }, + "t0_us": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/call/answer": { + "post": { + "operationId": "answer.create", + "tags": [ + "calls" + ], + "summary": "Accept, decline or cancel", + "description": "A callee's accept or decline, or the caller's cancel (WCA1, §10.3); on an accept the callee's publisher grant. In a call room (`[call]`); 404 in any other.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "answer": { + "$ref": "#/components/schemas/Hex" + } + }, + "required": [ + "answer" + ] + } + } + } + }, + "security": [ + { + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "the request does not read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "an answer of another key, or a missed call (the clock's)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "this service keeps no such room, or nothing at that path in this service's mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "the answer refused (its reason)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "410": { + "description": "no call in this room", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "the body is larger than the request takes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/call": { + "get": { + "operationId": "call.show", + "tags": [ + "calls" + ], + "summary": "The current call", + "description": "To a party to it, on a proof of its key: the call's records, which a party verifies offline (§10). In a call room (`[call]`); 404 in any other.", + "security": [ + { + "roomProof": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "description": "no proof of possession that passes; a fresh challenge is in WAI-Room-Challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "not a party to the call", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "no call, or not a call room", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "roomGrant": { + "type": "http", + "scheme": "bearer", + "description": "a grant of the room: its WRG1 record in base64url" + }, + "roomProof": { + "type": "apiKey", + "in": "header", + "name": "WAI-Room-Proof", + "description": "a proof of possession over the service's challenge (live-rooms §10.5.3), in base64url" + } + }, + "schemas": { + "Hex": { + "type": "string", + "pattern": "^([0-9a-fA-F]{2})*$", + "description": "bytes in hexadecimal" + }, + "Hex32": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "32 bytes, 64 lowercase hexadecimal digits" + }, + "Error": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "a reason code or a short statement of what is wrong" + }, + "reason": { + "type": "string" + }, + "retry_after_s": { + "type": "number" + } + }, + "required": [ + "error" + ] + }, + "Room": { + "type": "object", + "properties": { + "room": { + "$ref": "#/components/schemas/Hex32" + }, + "generation": { + "type": "integer" + }, + "t0_us": { + "type": "integer" + }, + "now_us": { + "type": "integer" + }, + "descriptor": { + "$ref": "#/components/schemas/Hex", + "description": "the signed WRD1 descriptor" + }, + "compose": { + "$ref": "#/components/schemas/Hex", + "description": "the signed WCD1 composition descriptor" + }, + "grants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Hex" + }, + "description": "the publishers' WRG1 grants" + }, + "present": { + "type": "integer" + }, + "publishers": { + "type": "integer" + }, + "max_publishers": { + "type": "integer" + }, + "e2ee": { + "type": [ + "object", + "null" + ] + }, + "remote": { + "type": [ + "object", + "null" + ] + }, + "age": { + "type": [ + "object", + "null" + ] + }, + "co": { + "type": [ + "object", + "null" + ] + }, + "watch": { + "type": [ + "object", + "null" + ] + }, + "world": { + "type": [ + "object", + "null" + ] + } + }, + "required": [ + "room", + "t0_us", + "descriptor", + "grants" + ] + }, + "GrantAnswer": { + "type": "object", + "properties": { + "grant": { + "$ref": "#/components/schemas/Hex", + "description": "the WRG1 record" + }, + "digest": { + "$ref": "#/components/schemas/Hex32" + }, + "exp": { + "type": "integer" + }, + "role": { + "type": "string" + }, + "room": { + "$ref": "#/components/schemas/Room" + } + }, + "required": [ + "grant", + "digest", + "exp", + "role", + "room" + ] + } + } + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/dev.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/dev.rs new file mode 100644 index 000000000..9e52e5999 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/dev.rs @@ -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": }`: +//! +//! | `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": [[, ...]]}`, 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, + /// The service's HTTP address (127.0.0.1). + pub http: SocketAddr, + /// The relay's WebSocket endpoint, `ws://127.0.0.1:`. + pub relay_url: String, + joined: Mutex>, + /// A viewer's session, and the room its grant is for, from which the room's chain is read. + observer: Mutex>, + tasks: Vec>, +} + +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 { + 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 { + 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>), 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 = 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, 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, 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, objects: u64) -> Result { + 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 { + 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 { + 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::>()], + })) + } + + /// 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 ]`: 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 ]"); + 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::(&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 +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs index 6e23dc44e..192b2c1b8 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs @@ -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; diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs index 720944e73..2ad4d84eb 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs @@ -8,6 +8,10 @@ //! (`[remote]` with no `host`), signed by the //! room's authority, good for seconds (3600) //! joule-moq-room --config serve +//! joule-moq-room dev [--publishers ] 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 | relay-lines | invite [] | --config )"); + eprintln!("usage: joule-moq-room (keygen | relay-lines | invite [] | --config | dev [--publishers ])"); 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 = 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::>().as_slice() { ["keygen", path] => match Keys::create(Path::new(path)) { Ok(k) => { diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/tests/mcp_dev_room.rs b/joule-code/joule-code-rs/crates/joule-moq-room/tests/mcp_dev_room.rs new file mode 100644 index 000000000..da5af5d1f --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-room/tests/mcp_dev_room.rs @@ -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, + 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 { + 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 = 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 = 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 = 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); +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/tests/openapi_contract.rs b/joule-code/joule-code-rs/crates/joule-moq-room/tests/openapi_contract.rs new file mode 100644 index 000000000..54958117f --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-room/tests/openapi_contract.rs @@ -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> { + 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 = 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 { + 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 { + let s = source(file); + let lines: Vec<&str> = s.lines().collect(); + let starts: Vec = 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 = documented().into_keys().collect(); + let code: BTreeSet = ["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 = 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> = BTreeMap::new(); + for (m, p, _, fns) in HANDLERS { + let listed = &doc[&(m.to_string(), p.to_string())]; + let mut can: BTreeSet = 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 = 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, + json: Value, +} + +async fn send(addr: SocketAddr, method: &str, path: &str, headers: &[(&str, String)], body: Option>) -> 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>, + seen: BTreeMap>, + 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>, 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 { + 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, 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| 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>, 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::>()).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> = 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()); +} diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 2241bf598..424dd6d06 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -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 diff --git a/wai/README.md b/wai/README.md index bcf907c98..908d338bf 100644 --- a/wai/README.md +++ b/wai/README.md @@ -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 "`. +## 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) — diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index f7aa41ab8..58e886347 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -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" diff --git a/wai/wai-rs/src/bin/wai.rs b/wai/wai-rs/src/bin/wai.rs index 3b9c61205..71323fccd 100644 --- a/wai/wai-rs/src/bin/wai.rs +++ b/wai/wai-rs/src/bin/wai.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 { 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, + #[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 { /// 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, out: &str, fallback: Option<&str>, - intent: &str, companions: &[(String, String)]) -> Result<(), String> { + intent: &str, companions: &[(String, String)]) -> Result { 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, 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 = 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 { + 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 [-o payload] [--component ]` — 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), } } diff --git a/wai/wai-rs/src/bin/wai_cli/dx/dev.rs b/wai/wai-rs/src/bin/wai_cli/dx/dev.rs index 4fbae8a9f..1048930e5 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/dev.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/dev.rs @@ -213,8 +213,9 @@ fn find_web(flag_value: Option<&str>, exe_dir: Option<&Path>) -> Result, env: &str, exe_dir: Option<&Path>) -> Result { +/// 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 { 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())) }; } diff --git a/wai/wai-rs/src/bin/wai_cli/dx/mod.rs b/wai/wai-rs/src/bin/wai_cli/dx/mod.rs index d12893008..3d396b45a 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/mod.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/mod.rs @@ -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), + /// 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, + 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> { +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 { + mcp::main(args) +} + +#[cfg(not(feature = "mcp"))] +fn mcp_main(_: &[String]) -> Result { + 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> { 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. diff --git a/wai/wai-rs/src/bin/wai_cli/mcp/dev_room.rs b/wai/wai-rs/src/bin/wai_cli/mcp/dev_room.rs new file mode 100644 index 000000000..255ad8ac6 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/mcp/dev_room.rs @@ -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)>, +} + +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, + ready: Value, + next: u64, +} + +impl Dev { + fn spawn(bin: &PathBuf, publishers: u64) -> Result { + 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 { + 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 { + 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 { + 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 { + 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::().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::() { + Ok(a) => IpAddr::V6(a), + Err(_) => return refuse("does not name a loopback address"), + }, + h => match h.parse::() { + 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 { + 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 { + 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 { + 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 { + 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://user@127.0.0.1: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); + } +} diff --git a/wai/wai-rs/src/bin/wai_cli/mcp/mod.rs b/wai/wai-rs/src/bin/wai_cli/mcp/mod.rs new file mode 100644 index 000000000..d28aeda75 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/mcp/mod.rs @@ -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 [,...]] [--read-only] [--project ] [--spec-root ] [--room-bin ] +//! ``` +//! +//! `--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, + pub(crate) read_only: bool, + pub(crate) project: PathBuf, + pub(crate) spec_root: Option, + pub(crate) spec_root_from: &'static str, + pub(crate) room_bin: Option, + pub(crate) dev: Option, + pub(crate) principals: std::collections::BTreeMap, + pub(crate) client: Option, +} + +impl Server { + fn new(toolsets: Vec, spec_root: Option, room_bin: Option, 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 { + 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 { + let root = self.spec_root.as_ref().ok_or( + "no specification tree: start the server with --spec-root , 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 { + 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 [,...]] [--read-only] [--project ] [--spec-root ] [--room-bin ]\n\ + toolsets: {} (core when none is named)", + tools::Toolset::ALL.iter().map(|t| t.name()).collect::>().join(", ") + ) +} + +/// `wai mcp`: serve until standard input closes. +pub fn main(args: &[String]) -> Result { + let mut toolsets: Vec = 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::(&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) +} diff --git a/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs b/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs new file mode 100644 index 000000000..aefd37bfe --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs @@ -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::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, +} + +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 ) 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 + '_ { + 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 { + 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> { + 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) -> 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::>(), "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 .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> { + 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 { + 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(v: Option, 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 { + 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(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 { + 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 = 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 { + 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 = Map::new(); + let envelopes: Vec = 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, 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 { + 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 = 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 { + 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 { + 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::>().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 { + 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 { + 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>, 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 { + 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>> = 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::, _>>())) + .collect::>()?; + 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::>())), + 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::>(), + "records_in_order": run.order.len(), + "grants": run.grants.len(), + })) +} + +fn verify_audit_bundle(args: &Value) -> Result { + // 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 = 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 { + 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::>()?, + }; + 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] ` 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 { + let root = server.spec_root()?; + let list: Vec = 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 { + 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::>().join(", "))), + } + } + }; + // Only a verifier script and a corpus directory inside the tree. + let inside = |p: &str| -> Result { + 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 { + 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, 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 { + let root = server.spec_root()?; + let q = str_arg(args, "query")?; + let words: Vec = 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()); + } + } +} diff --git a/wai/wai-rs/src/bin/wai_cli/setup_agent.rs b/wai/wai-rs/src/bin/wai_cli/setup_agent.rs new file mode 100644 index 000000000..4e87562fb --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/setup_agent.rs @@ -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 ] [--dry-run] [--confirm] [--json] [--all] [--only [,...]] +//! [--toolset [,...]] [--command ] +//! ``` +//! +//! - 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 = ""; +const END: &str = ""; + +/// 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>, + toolsets: Option>, + command: String, +} + +/// What would become of one file. +struct Plan { + path: String, + what: String, + old: Option, + new: Option, + /// Set when the file is left alone, and why. + skipped: Option, +} + +fn usage() -> &'static str { + "usage: wai setup-agent [--dir ] [--dry-run] [--confirm] [--json] [--all] [--only [,...]] [--toolset [,...]] [--command ]" +} + +/// The toolsets `wai mcp` serves, for `--toolset`. +const TOOLSETS: &[&str] = &["core", "dev"]; + +fn parse(args: &[String]) -> Result { + 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 = 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 { + 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::>().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::>(), "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 { + 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 { + 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, 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, 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| -> 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 { + 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 = 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}"); + } +} diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index 23c49b639..7f9f8e709 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -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")] diff --git a/wai/wai-rs/src/ops.rs b/wai/wai-rs/src/ops.rs new file mode 100644 index 000000000..270f3be64 --- /dev/null +++ b/wai/wai-rs/src/ops.rs @@ -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"); + } +} diff --git a/wai/wai-rs/tests/cli_mcp.rs b/wai/wai-rs/tests/cli_mcp.rs new file mode 100644 index 000000000..056c2d750 --- /dev/null +++ b/wai/wai-rs/tests/cli_mcp.rs @@ -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, + 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 { + 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::(&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 { + 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 { + 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 = 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, Option) { + (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}"); +} diff --git a/wai/wai-rs/tests/cli_setup_agent.rs b/wai/wai-rs/tests/cli_setup_agent.rs new file mode 100644 index 000000000..bbc21d144 --- /dev/null +++ b/wai/wai-rs/tests/cli_setup_agent.rs @@ -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 { + 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); +}