diff --git a/crates/buzz-cli/KEYLESS.md b/crates/buzz-cli/KEYLESS.md new file mode 100644 index 0000000000..f6afbf1699 --- /dev/null +++ b/crates/buzz-cli/KEYLESS.md @@ -0,0 +1,72 @@ +# Keyless mode (agent broker client) + +Run the `buzz` CLI without a signing key. In keyless mode the CLI holds no nsec +and opens no relay connection — every operation is sent to a **broker host** that +performs it on the agent's behalf. This is the client side of the agent-broker +contract ([#6790](https://github.com/block/buzz/pull/6790)). + +The backend is selected by `--agent-mode` (env `BUZZ_AGENT_MODE`), default +`local`: + +- `local` — hold the key, sign locally, talk to the relay (the unchanged default). +- `broker` — keyless; route every operation through a broker host. + +This first slice covers the wake→reply loop: `messages get` (with +`--mentions-only`, the wake path) and `messages send` / reply. + +## Build + +```sh +source bin/activate-hermit +cargo build -p buzz-cli --bin buzz --example mock_broker +``` + +## Try it against the bundled mock host + +The repo ships a throwaway mock host so you can exercise the round trip without a +real broker. It signs nothing and returns canned outcomes — it exists only to +prove the client wiring end to end. + +Terminal 1 — the mock host: + +```sh +cargo run -p buzz-cli --example mock_broker +# listening on http://127.0.0.1:8787 +``` + +Terminal 2 — the keyless client (note: no key in the environment): + +```sh +export BUZZ_AGENT_MODE=broker +export BUZZ_BROKER_URL=http://127.0.0.1:8787 +export BUZZ_BROKER_CREDENTIAL=dev-token +unset BUZZ_PRIVATE_KEY + +CH= +buzz messages send --channel "$CH" --content "hello from a keyless client" +buzz messages send --channel "$CH" --reply-to --content "on it" +buzz messages get --channel "$CH" --mentions-only --limit 10 +``` + +Terminal 1 logs each action, the bearer credential, and the args it received. + +## Point it at your own broker + +Swap the two broker vars for your host's endpoint and a credential it issued: + +```sh +export BUZZ_BROKER_URL=https://your-broker.example +export BUZZ_BROKER_CREDENTIAL= +``` + +Your host must accept `POST /v1/action` with `Authorization: Bearer ` +and return a broker-result envelope per the contract. `examples/mock_broker.rs` +is a minimal reference for the wire shape. + +## Notes + +- Keyless mode **fails closed** if a private key is present (`--private-key` / + `BUZZ_PRIVATE_KEY`): supplying a key in broker mode is a provisioning error, + not silently ignored. +- Credential issuance, authorization, and custody are the host's concern; the + client only needs an endpoint and a token to present. diff --git a/crates/buzz-cli/examples/mock_broker.rs b/crates/buzz-cli/examples/mock_broker.rs new file mode 100644 index 0000000000..df62beb00a --- /dev/null +++ b/crates/buzz-cli/examples/mock_broker.rs @@ -0,0 +1,85 @@ +//! A throwaway broker host for local keyless-CLI development. +//! +//! It speaks just enough of the agent-broker contract (block/buzz#6790) to let +//! `buzz --agent-mode broker …` complete a round trip before the real beekeeper +//! host exists: it accepts `POST /v1/action`, echoes the request's `requestId` +//! and `action` back (so correlation holds), and returns a canned success +//! outcome per action. It signs nothing and touches no relay — it exists only to +//! prove the client wiring end to end. +//! +//! Run it in one terminal: +//! cargo run -p buzz-cli --example mock_broker +//! then drive the CLI from another (see the crate's keyless docs). + +use axum::body::{Body, Bytes}; +use axum::http::StatusCode; +use axum::response::Response; +use axum::routing::post; +use axum::Router; +use tokio::net::TcpListener; + +const ADDR: &str = "127.0.0.1:8787"; +const FAKE_EVENT_ID: &str = "cacf5f811cc8ef3f4af3f92cc222f92a86cdf6a26728a144c8e63b74ab6db359"; + +#[tokio::main] +async fn main() { + let app = Router::new().route("/v1/action", post(action)); + let listener = TcpListener::bind(ADDR).await.expect("bind"); + eprintln!("mock broker listening on http://{ADDR} (Ctrl-C to stop)"); + axum::serve(listener, app).await.expect("serve"); +} + +/// Answer one action with a canned success envelope for the wake→reply slice. +async fn action(headers: axum::http::HeaderMap, body: Bytes) -> Response { + let request: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); + let request_id = request + .get("requestId") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let action = request.get("action").and_then(|v| v.as_str()).unwrap_or(""); + let credential = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + eprintln!( + "→ {action} requestId={request_id} auth={credential}\n args={}", + request.get("args").unwrap_or(&serde_json::Value::Null) + ); + + // A host verdict always rides in the body at HTTP 200; only the shape varies. + let body = match action { + "message.post" | "message.reply" => succeeded( + request_id, + action, + serde_json::json!({ "eventId": FAKE_EVENT_ID, "kind": 9, "createdAt": 1_700_000_000u64 }), + ), + "channel.read" => succeeded(request_id, action, serde_json::json!({ "messages": [] })), + other => serde_json::json!({ + "type": "broker_result", + "protocolVersion": 1, + "requestId": request_id, + "status": "failed", + "error": { + "code": "unimplemented", + "message": format!("mock broker does not implement '{other}'"), + }, + }), + }; + + Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("response") +} + +fn succeeded(request_id: &str, action: &str, outcome: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "type": "broker_result", + "protocolVersion": 1, + "requestId": request_id, + "status": "succeeded", + "action": action, + "outcome": outcome, + }) +}