From 2678ed2ab30abbae009f35772649f2e23db11fe1 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 16:20:03 +0800 Subject: [PATCH 1/3] feat(core): host-agnostic broker client (W0/X0 of the web-wiring plan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First implementation slice of docs/plan/web-flow/wire-real-paths.md (merged #162): a typed, host-agnostic `agentkeys-core::broker::BrokerClient` that the daemon ui-bridge, the future WASM CoreBackend (web), and the mobile UniFFI shell all share — so the browser/phone never re-implement broker calls in TS/Swift ("consistency is structural"). Covers the master-plane endpoints the web wiring proxies: - cap-mint: /v1/cap/{memory-put,memory-get,cred-store,cred-fetch} → CapToken - pairing (§10.2 method A, master-side): /v1/agent/pairing/claim, /v1/agent/pending-bindings, /v1/agent/pending-bindings/ack Design: builds on init_flow's conventions (BrokerError{Transport,Rejected,Decode}; trim trailing slash); stores no secret (bearer passed per call); host-agnostic (no fs/clock/env) so it compiles for the wasm32 browser-fetch target — feature- gating the crate's reqwest for wasm is the separate X1 build step. The email/OAuth/SIWE auth flow already lives in init_flow and is unchanged. Tests: axum-stub server per method (cap round-trip + bearer; pairing claim; pending→ack; non-2xx → Rejected mapping). cargo test -p agentkeys-core: 145+3 ok; clippy --all-targets -D warnings clean; fmt clean. Next (separate PRs): X1 wasm-pack build + reqwest wasm feature-gate; daemon ui-bridge consumes this client; X2 CoreBackend behind AgentKeysClient. --- crates/agentkeys-core/src/broker.rs | 368 ++++++++++++++++++++++++++++ crates/agentkeys-core/src/lib.rs | 1 + 2 files changed, 369 insertions(+) create mode 100644 crates/agentkeys-core/src/broker.rs diff --git a/crates/agentkeys-core/src/broker.rs b/crates/agentkeys-core/src/broker.rs new file mode 100644 index 0000000..d3309ae --- /dev/null +++ b/crates/agentkeys-core/src/broker.rs @@ -0,0 +1,368 @@ +//! Host-agnostic broker HTTP client for the master control plane. +//! +//! W0/X0 of [`docs/plan/web-flow/wire-real-paths.md`]: one typed broker client +//! that the daemon ui-bridge, the future WASM `CoreBackend` (web), and the +//! mobile UniFFI shell all share — so the browser/phone never re-implement +//! broker calls in TypeScript/Swift (the "consistency is structural" rule). +//! +//! Scope: the pairing (arch.md §10.2 method A, master-side) + cap-mint +//! endpoints the web wiring proxies. The email/OAuth/SIWE auth flow already +//! lives in [`crate::init_flow`]; this module is the net-new surface plus a +//! reusable typed client others can build on. +//! +//! WASM note: `reqwest` has a browser-`fetch` target, so this compiles for the +//! web host. Feature-gating the crate's `reqwest` for `wasm32` (drop the native +//! TLS/tokio default features) is the X1 build step — out of scope here; this +//! module is host-agnostic by construction (no filesystem, no clock, no env). + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum BrokerError { + #[error("transport: {0}")] + Transport(String), + #[error("broker rejected {endpoint}: status={status} body={body}")] + Rejected { + endpoint: String, + status: u16, + body: String, + }, + #[error("decode response: {0}")] + Decode(String), +} + +type R = Result; + +/// A reusable, host-agnostic broker client. Holds a `reqwest::Client` + the +/// broker base URL; every method takes the caller's bearer (the operator's J1 +/// session JWT) explicitly — the client itself stores no secret. +#[derive(Clone)] +pub struct BrokerClient { + http: reqwest::Client, + base_url: String, +} + +impl BrokerClient { + pub fn new(base_url: impl Into) -> Self { + Self::with_client(reqwest::Client::new(), base_url) + } + + /// Reuse a pre-built `reqwest::Client` (connection pooling, timeouts, or a + /// wasm-configured client in the browser host). + pub fn with_client(http: reqwest::Client, base_url: impl Into) -> Self { + Self { + http, + base_url: base_url.into().trim_end_matches('/').to_string(), + } + } + + // ── Cap-mint (operator-J1-gated). One typed call per (op, data_class); + // the route is the source of truth for the data_class (per CLAUDE.md). ── + + pub async fn cap_memory_put(&self, bearer: &str, req: &CapRequest) -> R { + self.post_json("/v1/cap/memory-put", Some(bearer), req) + .await + } + pub async fn cap_memory_get(&self, bearer: &str, req: &CapRequest) -> R { + self.post_json("/v1/cap/memory-get", Some(bearer), req) + .await + } + pub async fn cap_cred_store(&self, bearer: &str, req: &CapRequest) -> R { + self.post_json("/v1/cap/cred-store", Some(bearer), req) + .await + } + pub async fn cap_cred_fetch(&self, bearer: &str, req: &CapRequest) -> R { + self.post_json("/v1/cap/cred-fetch", Some(bearer), req) + .await + } + + // ── Pairing, master-side (arch.md §10.2 method A). The agent-side + // request/poll lives in the daemon's one-shot modes, not here. ── + + /// Master claims an agent-shown pairing code (J1_master-gated). Binds the + /// unbound request to the HDKD child omni; returns the device artifacts the + /// master needs to submit the on-chain bind. + pub async fn pairing_claim( + &self, + bearer: &str, + req: &PairingClaimRequest, + ) -> R { + self.post_json("/v1/agent/pairing/claim", Some(bearer), req) + .await + } + + /// Master polls for claimed-but-unbound agents — the pairing-page bell / + /// notification source. + pub async fn pending_bindings(&self, bearer: &str) -> R> { + let resp: PendingBindings = self + .get_json("/v1/agent/pending-bindings", Some(bearer)) + .await?; + Ok(resp.pending) + } + + /// Master acks an on-chain bind, clearing the rendezvous. + pub async fn ack_binding(&self, bearer: &str, request_id: &str) -> R { + self.post_json( + "/v1/agent/pending-bindings/ack", + Some(bearer), + &AckRequest { + request_id: request_id.to_string(), + }, + ) + .await + } + + // ── internals ── + + async fn post_json( + &self, + path: &str, + bearer: Option<&str>, + body: &B, + ) -> R { + let url = format!("{}{}", self.base_url, path); + let mut rb = self.http.post(&url).json(body); + if let Some(b) = bearer { + rb = rb.bearer_auth(b); + } + let resp = rb + .send() + .await + .map_err(|e| BrokerError::Transport(format!("POST {path}: {e}")))?; + Self::decode(path, resp).await + } + + async fn get_json(&self, path: &str, bearer: Option<&str>) -> R { + let url = format!("{}{}", self.base_url, path); + let mut rb = self.http.get(&url); + if let Some(b) = bearer { + rb = rb.bearer_auth(b); + } + let resp = rb + .send() + .await + .map_err(|e| BrokerError::Transport(format!("GET {path}: {e}")))?; + Self::decode(path, resp).await + } + + async fn decode(path: &str, resp: reqwest::Response) -> R { + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(BrokerError::Rejected { + endpoint: path.to_string(), + status: status.as_u16(), + body, + }); + } + resp.json::() + .await + .map_err(|e| BrokerError::Decode(format!("{path}: {e}"))) + } +} + +// ─── Cap-mint types (mirror crates/agentkeys-broker-server handlers/cap.rs) ── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CapRequest { + pub operator_omni: String, + pub actor_omni: String, + pub service: String, + pub device_key_hash: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ttl_seconds: Option, +} + +/// Broker-signed cap token. `payload` is the signed `CapPayload` (op, data_class, +/// k3_epoch, expiry, …) — kept as opaque JSON here; the worker re-parses + the +/// broker_sig is re-verified downstream, so the client does not need the shape. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CapToken { + pub payload: serde_json::Value, + pub broker_sig: String, +} + +// ─── Pairing types (mirror handlers/agent/{claim,pending}.rs) ── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PairingClaimRequest { + pub pairing_code: String, + pub label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub requested_scope: Option, +} + +/// The record the master sees for a claimed-but-unbound agent — carries +/// everything needed to submit `registerAgentDevice` on chain. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaimedBinding { + pub request_id: String, + pub child_omni: String, + pub operator_omni: String, + pub label: String, + #[serde(default)] + pub requested_scope: String, + pub device_pubkey: String, + pub pop_sig: String, + pub device_key_hash: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct PendingBindings { + #[serde(default)] + pending: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct AckRequest { + request_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AckResponse { + pub acked: bool, + pub request_id: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + extract::Json as AxJson, + http::{HeaderMap, StatusCode}, + routing::{get, post}, + Router, + }; + use serde_json::json; + + // Spin a tiny broker stub on an ephemeral port; return its base URL. + async fn stub() -> String { + let app = Router::new() + .route( + "/v1/cap/memory-put", + post( + |headers: HeaderMap, AxJson(body): AxJson| async move { + // Require the operator bearer. + if !headers.contains_key("authorization") { + return ( + StatusCode::UNAUTHORIZED, + AxJson(json!({"error":"no-bearer"})), + ); + } + let svc = body["service"].as_str().unwrap_or(""); + ( + StatusCode::OK, + AxJson(json!({ + "payload": {"op":"store","data_class":"memory","service":svc}, + "broker_sig":"c2ln" + })), + ) + }, + ), + ) + .route( + "/v1/agent/pairing/claim", + post(|AxJson(body): AxJson| async move { + let code = body["pairing_code"].as_str().unwrap_or(""); + AxJson(json!({ + "request_id":"req-1","child_omni":"0xchild","operator_omni":"0xop", + "label": body["label"], "requested_scope":"memory", + "device_pubkey":"0xdpub","pop_sig":"0xpop","device_key_hash":"0xdkh", + "_echo_code": code + })) + }), + ) + .route( + "/v1/agent/pending-bindings", + get(|| async { + AxJson(json!({"pending":[{ + "request_id":"req-1","child_omni":"0xchild","operator_omni":"0xop", + "label":"demo","requested_scope":"memory","device_pubkey":"0xdpub", + "pop_sig":"0xpop","device_key_hash":"0xdkh" + }]})) + }), + ) + .route( + "/v1/agent/pending-bindings/ack", + post(|AxJson(body): AxJson| async move { + AxJson(json!({"acked":true,"request_id": body["request_id"]})) + }), + ) + .route( + "/v1/cap/cred-store", + post(|| async { (StatusCode::INTERNAL_SERVER_ERROR, "boom") }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + format!("http://{addr}") + } + + fn cap_req() -> CapRequest { + CapRequest { + operator_omni: "0xop".into(), + actor_omni: "0xactor".into(), + service: "memory".into(), + device_key_hash: "0xdkh".into(), + ttl_seconds: Some(900), + } + } + + #[tokio::test] + async fn cap_memory_put_roundtrips_and_sends_bearer() { + let c = BrokerClient::new(stub().await); + let tok = c.cap_memory_put("J1", &cap_req()).await.unwrap(); + assert_eq!(tok.broker_sig, "c2ln"); + assert_eq!(tok.payload["data_class"], "memory"); + assert_eq!(tok.payload["service"], "memory"); + } + + #[tokio::test] + async fn non_2xx_maps_to_rejected() { + // The cred-store stub route returns 500 → BrokerError::Rejected with the + // status + endpoint preserved (so callers can fail closed / surface it). + let c = BrokerClient::new(stub().await); + let err = c.cap_cred_store("J1", &cap_req()).await.unwrap_err(); + match err { + BrokerError::Rejected { + status, endpoint, .. + } => { + assert_eq!(status, 500); + assert_eq!(endpoint, "/v1/cap/cred-store"); + } + other => panic!("expected Rejected, got {other:?}"), + } + } + + #[tokio::test] + async fn pairing_claim_roundtrips() { + let c = BrokerClient::new(stub().await); + let claimed = c + .pairing_claim( + "J1", + &PairingClaimRequest { + pairing_code: "ABCD-1234".into(), + label: "demo-agent".into(), + requested_scope: Some("memory".into()), + }, + ) + .await + .unwrap(); + assert_eq!(claimed.child_omni, "0xchild"); + assert_eq!(claimed.device_key_hash, "0xdkh"); + assert_eq!(claimed.label, "demo-agent"); + } + + #[tokio::test] + async fn pending_then_ack() { + let c = BrokerClient::new(stub().await); + let pending = c.pending_bindings("J1").await.unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].request_id, "req-1"); + let ack = c.ack_binding("J1", "req-1").await.unwrap(); + assert!(ack.acked); + assert_eq!(ack.request_id, "req-1"); + } +} diff --git a/crates/agentkeys-core/src/lib.rs b/crates/agentkeys-core/src/lib.rs index 5b5926c..fc4d1cb 100644 --- a/crates/agentkeys-core/src/lib.rs +++ b/crates/agentkeys-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod actor_omni; pub mod audit; pub mod auth_request; pub mod backend; +pub mod broker; pub mod chain_profile; pub mod clear_signing; pub mod device_crypto; From 6b1c4242a2541eb252dbc446683d7e9da288f917 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 17:20:44 +0800 Subject: [PATCH 2/3] feat(web-core): WASM-ready broker core + browser CoreBackend + dev.sh build (X0/X1/X2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evolves the #172 broker client into the phone-first host model. agentkeys-core is native-heavy (aws-sdk, keyring) → can't compile to wasm, so the host-agnostic broker client moves to a new minimal crate that builds for BOTH native and wasm32. - NEW crate `agentkeys-web-core`: the broker client (moved from agentkeys-core via git mv — cap-mint + pairing) + wasm-bindgen exports (`WebCore`, behind `--features wasm`). reqwest is default-features=false with the browser fetch backend on wasm32 and rustls-tls on native (target-gated). crate-type = [cdylib, rlib]. - agentkeys-core: drops the broker module (no consumer yet; future native consumers depend on agentkeys-web-core directly). Workspace member + dep added. - web app: `lib/client/core.ts` — `CoreBackend` lazy-loads the WASM pkg + talks to the broker directly (X1); exposes the cap/pairing calls for the onboarding/pairing slices. Registered as `NEXT_PUBLIC_AGENTKEYS_BACKEND=core` (default broker broker.litentry.org). The AgentKeysClient read endpoints inherit EmptyBackend's disconnected state until the later W-phases wire them (honest empty states). - dev.sh: `build_wasm` step — wasm-pack build, cached by a src+Cargo.toml+wasm-pack version hash (skip when unchanged), copies the .wasm to public/wasm; graceful no-op if wasm-pack absent. Generated pkg + public/wasm are gitignored. Verified: cargo test/clippy (-D warnings)/fmt on agentkeys-web-core + agentkeys-core; wasm32 build + wasm-pack pkg generate; apps/parent-control tsc + next build clean; bash -n dev.sh. --- Cargo.lock | 132 ++++++++++++++++++ Cargo.toml | 2 + apps/parent-control/.gitignore | 5 + apps/parent-control/lib/client/core.ts | 85 +++++++++++ apps/parent-control/lib/client/index.ts | 10 +- crates/agentkeys-core/src/lib.rs | 1 - crates/agentkeys-web-core/Cargo.toml | 37 +++++ .../src/broker.rs | 21 ++- crates/agentkeys-web-core/src/lib.rs | 17 +++ crates/agentkeys-web-core/src/wasm.rs | 115 +++++++++++++++ dev.sh | 43 ++++++ 11 files changed, 455 insertions(+), 13 deletions(-) create mode 100644 apps/parent-control/lib/client/core.ts create mode 100644 crates/agentkeys-web-core/Cargo.toml rename crates/{agentkeys-core => agentkeys-web-core}/src/broker.rs (94%) create mode 100644 crates/agentkeys-web-core/src/lib.rs create mode 100644 crates/agentkeys-web-core/src/wasm.rs diff --git a/Cargo.lock b/Cargo.lock index b627016..6a7d614 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,6 +295,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "agentkeys-web-core" +version = "0.1.0" +dependencies = [ + "axum", + "reqwest", + "serde", + "serde-wasm-bindgen", + "serde_json", + "thiserror 2.0.18", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "agentkeys-worker-audit" version = "0.1.0" @@ -1405,6 +1420,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "ciborium" version = "0.2.2" @@ -2230,9 +2251,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -2576,6 +2599,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "tower-service", + "webpki-roots 1.0.7", ] [[package]] @@ -2959,6 +2983,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -3537,6 +3567,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.37", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls 0.23.37", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -3687,6 +3772,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls 0.23.37", "rustls-pki-types", "serde", "serde_json", @@ -3694,6 +3781,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls 0.26.4", "tower 0.5.3", "tower-http 0.6.8", "tower-service", @@ -3701,6 +3789,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 1.0.7", ] [[package]] @@ -3752,6 +3841,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3855,6 +3950,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -4016,6 +4112,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_cbor_2" version = "0.13.0" @@ -4458,6 +4565,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.51.1" @@ -5017,6 +5139,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webauthn-attestation-ca" version = "0.5.5" diff --git a/Cargo.toml b/Cargo.toml index 660e67b..3c9b3c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "crates/agentkeys-types", "crates/agentkeys-core", + "crates/agentkeys-web-core", "crates/agentkeys-mock-server", "crates/agentkeys-cli", "crates/agentkeys-daemon", @@ -19,6 +20,7 @@ members = [ [workspace.dependencies] agentkeys-types = { path = "crates/agentkeys-types" } agentkeys-core = { path = "crates/agentkeys-core" } +agentkeys-web-core = { path = "crates/agentkeys-web-core" } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } diff --git a/apps/parent-control/.gitignore b/apps/parent-control/.gitignore index 3e8c24e..e52cf79 100644 --- a/apps/parent-control/.gitignore +++ b/apps/parent-control/.gitignore @@ -6,3 +6,8 @@ dist/ *.tsbuildinfo .env*.local .vercel + +# Generated by dev.sh `build_wasm` (agentkeys-web-core via wasm-pack). Cached + +# rebuilt only when the Rust source hash changes; never committed. +lib/wasm/ +public/wasm/ diff --git a/apps/parent-control/lib/client/core.ts b/apps/parent-control/lib/client/core.ts new file mode 100644 index 0000000..4544f27 --- /dev/null +++ b/apps/parent-control/lib/client/core.ts @@ -0,0 +1,85 @@ +'use client'; + +import { EmptyBackend } from './empty'; +import type { ConnectionStatus } from './types'; + +// Lazy, client-only load of the WASM master-plane core (agentkeys-web-core). +// The dynamic import keeps the wasm glue out of the server bundle; init() fetches +// the .wasm from /wasm/ (served from public/, written by dev.sh's build_wasm). +let coreP: Promise | null = null; +async function loadCore(brokerUrl: string) { + if (!coreP) { + coreP = (async () => { + const wasm = await import('@/lib/wasm/agentkeys-web-core/agentkeys_web_core.js'); + await wasm.default('/wasm/agentkeys_web_core_bg.wasm'); + return new wasm.WebCore(brokerUrl); + })(); + } + return coreP; +} + +/** + * CoreBackend — the phone-first host (browser → WASM core → broker DIRECTLY, no + * daemon). X1 of docs/plan/web-flow/wire-real-paths.md: it loads the + * `agentkeys-web-core` WASM module and exposes the broker calls (cap-mint, + * pairing) the onboarding/pairing slices use. + * + * The `AgentKeysClient` READ endpoints (actors, audit, memory, …) are wired in + * the later W-phases, so they inherit EmptyBackend's disconnected behaviour for + * now (the UI shows honest empty states); `status()` exercises the full path + * (load WASM + probe the broker) and reports what happened. + */ +export class CoreBackend extends EmptyBackend { + private brokerUrl: string; + + constructor(brokerUrl: string) { + super(); + this.brokerUrl = brokerUrl.replace(/\/+$/, ''); + } + + async status(): Promise { + try { + await loadCore(this.brokerUrl); + } catch (e) { + return { + kind: 'disconnected', + reason: 'no-backend-configured', + detail: `WASM core failed to load: ${String(e)}`, + }; + } + try { + const r = await fetch(`${this.brokerUrl}/healthz`, { cache: 'no-store' }); + return { + kind: 'disconnected', + reason: r.ok ? 'no-backend-configured' : 'unreachable', + detail: r.ok + ? `WASM core loaded; broker ${this.brokerUrl} reachable. The AgentKeysClient read endpoints wire in later W-phases (wire-real-paths.md).` + : `WASM core loaded; broker /healthz → ${r.status}.`, + }; + } catch (e) { + return { + kind: 'disconnected', + reason: 'unreachable', + detail: `broker ${this.brokerUrl} unreachable: ${String(e)}`, + }; + } + } + + // ── Broker calls in the browser (X1). Beyond the AgentKeysClient interface; + // the onboarding/pairing slices call these directly via the CoreBackend. + async capMemoryPut(bearer: string, req: unknown): Promise { + return (await loadCore(this.brokerUrl)).capMemoryPut(bearer, req); + } + async capMemoryGet(bearer: string, req: unknown): Promise { + return (await loadCore(this.brokerUrl)).capMemoryGet(bearer, req); + } + async pairingClaim(bearer: string, req: unknown): Promise { + return (await loadCore(this.brokerUrl)).pairingClaim(bearer, req); + } + async pendingBindings(bearer: string): Promise { + return (await loadCore(this.brokerUrl)).pendingBindings(bearer); + } + async ackBinding(bearer: string, requestId: string): Promise { + return (await loadCore(this.brokerUrl)).ackBinding(bearer, requestId); + } +} diff --git a/apps/parent-control/lib/client/index.ts b/apps/parent-control/lib/client/index.ts index d269141..3cd54c8 100644 --- a/apps/parent-control/lib/client/index.ts +++ b/apps/parent-control/lib/client/index.ts @@ -1,11 +1,18 @@ +import { CoreBackend } from './core'; import { DaemonBackend } from './daemon'; import { EmptyBackend } from './empty'; import type { AgentKeysClient } from './types'; -export type BackendKind = 'empty' | 'daemon'; +export type BackendKind = 'empty' | 'daemon' | 'core'; export function selectBackend(): AgentKeysClient { const kind = (process.env.NEXT_PUBLIC_AGENTKEYS_BACKEND ?? 'empty') as BackendKind; + if (kind === 'core') { + // Phone-first host: the WASM core talks to the broker directly (no daemon). X1. + return new CoreBackend( + process.env.NEXT_PUBLIC_AGENTKEYS_BROKER_URL ?? 'https://broker.litentry.org', + ); + } if (kind === 'daemon') { return new DaemonBackend(process.env.NEXT_PUBLIC_AGENTKEYS_DAEMON_URL); } @@ -15,3 +22,4 @@ export function selectBackend(): AgentKeysClient { export * from './types'; export { EmptyBackend } from './empty'; export { DaemonBackend } from './daemon'; +export { CoreBackend } from './core'; diff --git a/crates/agentkeys-core/src/lib.rs b/crates/agentkeys-core/src/lib.rs index fc4d1cb..5b5926c 100644 --- a/crates/agentkeys-core/src/lib.rs +++ b/crates/agentkeys-core/src/lib.rs @@ -2,7 +2,6 @@ pub mod actor_omni; pub mod audit; pub mod auth_request; pub mod backend; -pub mod broker; pub mod chain_profile; pub mod clear_signing; pub mod device_crypto; diff --git a/crates/agentkeys-web-core/Cargo.toml b/crates/agentkeys-web-core/Cargo.toml new file mode 100644 index 0000000..75fff08 --- /dev/null +++ b/crates/agentkeys-web-core/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "agentkeys-web-core" +version = "0.1.0" +edition = "2021" +description = "Host-agnostic master-plane core (broker client + ceremony logic) shared by the daemon, the WASM web host, and the mobile shell." + +# cdylib: required by wasm-pack to emit the browser .wasm. rlib: so native +# crates (agentkeys-core, the daemon) depend on it as a normal Rust lib. +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +# default-features off so the wasm32 build picks the browser `fetch` backend +# (no native-tls/hyper, which don't compile for wasm). The native build adds a +# TLS backend in the target table below. +reqwest = { version = "0.12", default-features = false, features = ["json"] } + +# WASM bindings — only compiled for the browser core (`--features wasm`). +wasm-bindgen = { version = "0.2", optional = true } +wasm-bindgen-futures = { version = "0.4", optional = true } +serde-wasm-bindgen = { version = "0.6", optional = true } + +# Native builds (daemon/CLI/mobile) need a TLS backend; rustls avoids the +# system OpenSSL dep. On wasm32 reqwest auto-uses the browser fetch backend, so +# no TLS crate is pulled there. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } + +[features] +wasm = ["dep:wasm-bindgen", "dep:wasm-bindgen-futures", "dep:serde-wasm-bindgen"] + +[dev-dependencies] +tokio = { workspace = true } +axum = { version = "0.7", features = ["json"] } diff --git a/crates/agentkeys-core/src/broker.rs b/crates/agentkeys-web-core/src/broker.rs similarity index 94% rename from crates/agentkeys-core/src/broker.rs rename to crates/agentkeys-web-core/src/broker.rs index d3309ae..143a8c9 100644 --- a/crates/agentkeys-core/src/broker.rs +++ b/crates/agentkeys-web-core/src/broker.rs @@ -1,19 +1,18 @@ //! Host-agnostic broker HTTP client for the master control plane. //! -//! W0/X0 of [`docs/plan/web-flow/wire-real-paths.md`]: one typed broker client -//! that the daemon ui-bridge, the future WASM `CoreBackend` (web), and the -//! mobile UniFFI shell all share — so the browser/phone never re-implement -//! broker calls in TypeScript/Swift (the "consistency is structural" rule). +//! W0/X0 of `docs/plan/web-flow/wire-real-paths.md`: one typed broker client +//! that the daemon ui-bridge, the WASM `CoreBackend` (web), and the mobile +//! UniFFI shell all share — so the browser/phone never re-implement broker +//! calls in TypeScript/Swift (the "consistency is structural" rule). //! //! Scope: the pairing (arch.md §10.2 method A, master-side) + cap-mint -//! endpoints the web wiring proxies. The email/OAuth/SIWE auth flow already -//! lives in [`crate::init_flow`]; this module is the net-new surface plus a +//! endpoints the web wiring proxies. The email/OAuth/SIWE auth flow lives in +//! `agentkeys_core::init_flow`; this module is the net-new surface plus a //! reusable typed client others can build on. //! -//! WASM note: `reqwest` has a browser-`fetch` target, so this compiles for the -//! web host. Feature-gating the crate's `reqwest` for `wasm32` (drop the native -//! TLS/tokio default features) is the X1 build step — out of scope here; this -//! module is host-agnostic by construction (no filesystem, no clock, no env). +//! WASM: this crate pins `reqwest` to `default-features = false` so `wasm32` +//! uses the browser `fetch` backend (native adds `rustls-tls`); the client is +//! host-agnostic by construction (no filesystem, clock, or env). use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -220,7 +219,7 @@ struct AckRequest { request_id: String, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct AckResponse { pub acked: bool, pub request_id: String, diff --git a/crates/agentkeys-web-core/src/lib.rs b/crates/agentkeys-web-core/src/lib.rs new file mode 100644 index 0000000..a745cb1 --- /dev/null +++ b/crates/agentkeys-web-core/src/lib.rs @@ -0,0 +1,17 @@ +//! agentkeys-web-core — the host-agnostic master-plane core. +//! +//! Compiles for **native** (daemon / CLI / mobile-via-UniFFI) AND **`wasm32`** +//! (the browser web host, via `wasm-pack build --features wasm`). It holds no +//! filesystem / clock / env state, so the same orchestration logic runs +//! unchanged in every host — the "consistency is structural" rule from +//! `docs/plan/web-flow/wire-real-paths.md` §0.5. The auth bearer + base URL are +//! always passed in by the host; this crate stores no secret. +//! +//! Today it holds the broker client (W0/X0). The WebAuthn-UserOp builder and the +//! ceremony state machines land here too as later slices, so the WASM +//! `CoreBackend`, the daemon ui-bridge, and the mobile shell share one impl. + +pub mod broker; + +#[cfg(feature = "wasm")] +pub mod wasm; diff --git a/crates/agentkeys-web-core/src/wasm.rs b/crates/agentkeys-web-core/src/wasm.rs new file mode 100644 index 0000000..86217fe --- /dev/null +++ b/crates/agentkeys-web-core/src/wasm.rs @@ -0,0 +1,115 @@ +//! `wasm-bindgen` exports for the browser `CoreBackend` (X1). +//! +//! Compiled only under `--features wasm` (e.g. `wasm-pack build --target web +//! --features wasm`). Wraps [`crate::broker::BrokerClient`] in a JS-constructable +//! `WebCore` whose async methods take/return plain JSON (`serde-wasm-bindgen`) +//! and reject with the broker error string on failure. The Next.js +//! `CoreBackend` (`lib/client/core.ts`) imports the generated `pkg`. +//! +//! No secret is stored: the operator's J1 bearer is passed per call, exactly as +//! on the native side. + +use wasm_bindgen::prelude::*; + +use crate::broker::{BrokerClient, CapRequest, PairingClaimRequest}; + +fn to_js(e: E) -> JsValue { + JsValue::from_str(&e.to_string()) +} + +/// The host-agnostic master-plane core, exposed to the browser. One per broker +/// base URL; holds no secret. +#[wasm_bindgen] +pub struct WebCore { + broker: BrokerClient, +} + +#[wasm_bindgen] +impl WebCore { + /// `new WebCore("https://broker.litentry.org")`. + #[wasm_bindgen(constructor)] + pub fn new(broker_base_url: String) -> WebCore { + WebCore { + broker: BrokerClient::new(broker_base_url), + } + } + + // ── cap-mint (one method per route; `req` is a CapRequest-shaped object) ── + + #[wasm_bindgen(js_name = capMemoryPut)] + pub async fn cap_memory_put(&self, bearer: String, req: JsValue) -> Result { + let req: CapRequest = serde_wasm_bindgen::from_value(req).map_err(to_js)?; + let tok = self + .broker + .cap_memory_put(&bearer, &req) + .await + .map_err(to_js)?; + serde_wasm_bindgen::to_value(&tok).map_err(to_js) + } + + #[wasm_bindgen(js_name = capMemoryGet)] + pub async fn cap_memory_get(&self, bearer: String, req: JsValue) -> Result { + let req: CapRequest = serde_wasm_bindgen::from_value(req).map_err(to_js)?; + let tok = self + .broker + .cap_memory_get(&bearer, &req) + .await + .map_err(to_js)?; + serde_wasm_bindgen::to_value(&tok).map_err(to_js) + } + + #[wasm_bindgen(js_name = capCredStore)] + pub async fn cap_cred_store(&self, bearer: String, req: JsValue) -> Result { + let req: CapRequest = serde_wasm_bindgen::from_value(req).map_err(to_js)?; + let tok = self + .broker + .cap_cred_store(&bearer, &req) + .await + .map_err(to_js)?; + serde_wasm_bindgen::to_value(&tok).map_err(to_js) + } + + #[wasm_bindgen(js_name = capCredFetch)] + pub async fn cap_cred_fetch(&self, bearer: String, req: JsValue) -> Result { + let req: CapRequest = serde_wasm_bindgen::from_value(req).map_err(to_js)?; + let tok = self + .broker + .cap_cred_fetch(&bearer, &req) + .await + .map_err(to_js)?; + serde_wasm_bindgen::to_value(&tok).map_err(to_js) + } + + // ── pairing (master-side, arch §10.2 method A) ── + + #[wasm_bindgen(js_name = pairingClaim)] + pub async fn pairing_claim(&self, bearer: String, req: JsValue) -> Result { + let req: PairingClaimRequest = serde_wasm_bindgen::from_value(req).map_err(to_js)?; + let claimed = self + .broker + .pairing_claim(&bearer, &req) + .await + .map_err(to_js)?; + serde_wasm_bindgen::to_value(&claimed).map_err(to_js) + } + + #[wasm_bindgen(js_name = pendingBindings)] + pub async fn pending_bindings(&self, bearer: String) -> Result { + let pending = self.broker.pending_bindings(&bearer).await.map_err(to_js)?; + serde_wasm_bindgen::to_value(&pending).map_err(to_js) + } + + #[wasm_bindgen(js_name = ackBinding)] + pub async fn ack_binding( + &self, + bearer: String, + request_id: String, + ) -> Result { + let ack = self + .broker + .ack_binding(&bearer, &request_id) + .await + .map_err(to_js)?; + serde_wasm_bindgen::to_value(&ack).map_err(to_js) + } +} diff --git a/dev.sh b/dev.sh index 60a7c58..e6b443a 100755 --- a/dev.sh +++ b/dev.sh @@ -238,6 +238,48 @@ cleanup() { } trap cleanup INT TERM EXIT +# Build the WASM master-plane core (agentkeys-web-core → apps/parent-control via +# wasm-pack) iff the Rust source / Cargo.toml / wasm-pack version changed since +# the last build. Cached via a src-hash stamp in the (gitignored) out dir; the +# generated pkg + served .wasm are never committed. Graceful no-op if wasm-pack +# isn't installed (the UI then runs with the daemon/empty backend; only the +# `core` backend needs the WASM module). +build_wasm() { + local crate_dir="$REPO_ROOT/crates/agentkeys-web-core" + local out_dir="$REPO_ROOT/apps/parent-control/lib/wasm/agentkeys-web-core" + local pub_dir="$REPO_ROOT/apps/parent-control/public/wasm" + local stamp="$out_dir/.src-hash" + + if ! command -v wasm-pack >/dev/null 2>&1; then + warn "wasm-pack not installed — skipping WASM core (cargo install wasm-pack && rustup target add wasm32-unknown-unknown). 'core' backend unavailable; UI uses daemon/empty." + return 0 + fi + + # Version key: every .rs under src/ + Cargo.toml + the wasm-pack version. Any + # change ⇒ rebuild; otherwise reuse the cached pkg (the "verify same version"). + local cur + cur="$( { find "$crate_dir/src" -type f -name '*.rs' -exec shasum -a 256 {} +; + shasum -a 256 "$crate_dir/Cargo.toml"; + wasm-pack --version; } | shasum -a 256 | awk '{print $1}' )" + + if [ -f "$out_dir/agentkeys_web_core_bg.wasm" ] && [ -f "$pub_dir/agentkeys_web_core_bg.wasm" ] \ + && [ -f "$stamp" ] && [ "$(cat "$stamp" 2>/dev/null)" = "$cur" ]; then + printf "%b[dev]%b WASM core up-to-date (%s…) — skip build\n" "$C_DIM" "$C_RESET" "${cur:0:12}" + return 0 + fi + + rustup target list --installed 2>/dev/null | grep -q wasm32-unknown-unknown \ + || rustup target add wasm32-unknown-unknown >/dev/null 2>&1 || true + say "building WASM core (agentkeys-web-core → lib/wasm)…" + ( cd "$REPO_ROOT" && wasm-pack build crates/agentkeys-web-core --dev --target web \ + --out-dir "$out_dir" -- --features wasm ) \ + || { err "wasm-pack build failed"; exit 1; } + mkdir -p "$pub_dir" + cp "$out_dir/agentkeys_web_core_bg.wasm" "$pub_dir/agentkeys_web_core_bg.wasm" + printf '%s' "$cur" > "$stamp" + say "WASM core built + cached (${cur:0:12}…)." +} + # ─── Preflight ───────────────────────────────────────────────────── free_port "$UI_PORT" free_port "$DAEMON_PORT" @@ -246,6 +288,7 @@ build_if_needed "$DAEMON_BIN" "agentkeys-daemon" "agentkeys-daemon" \ "$REPO_ROOT/crates/agentkeys-daemon" build_if_needed "$MCP_BIN" "agentkeys-mcp-server" "agentkeys-mcp-server" \ "$REPO_ROOT/crates/agentkeys-mcp" "$REPO_ROOT/crates/agentkeys-mcp-server" +build_wasm # ─── Start daemon ────────────────────────────────────────────────── # From 6a7a656d111c2ca8756ba1e6c7a681864ff03954 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 18:07:22 +0800 Subject: [PATCH 3/3] fix(web-core): address codex adversarial-review findings (#172) - core.ts: memoize the WASM core per broker URL (Map) + evict on reject, so a second CoreBackend with a different broker gets its own instance and a transient load/broker failure no longer poisons the cache forever (HIGH). - broker.rs: native client gets a 20s request timeout (wasm keeps the fetch backend) so a stalled broker can't hang a worker thread (MED). - broker.rs: bound the echoed broker error body to 512 chars so an oversized response can't bloat a JS rejection/log line; status + endpoint preserved. Adds a regression test (MED). - dev.sh: stabilize the wasm src-hash -- sort the file list (filesystem order no longer flips the hash) + add workspace Cargo.toml/Cargo.lock + rustc version so a transitive-dep bump busts the cache (MED). Deferred (tracked): broker CORS for the browser-direct path + typed TS DTOs land with the live web e2e slice. status() stays disconnected by design until the read endpoints are wired. Verified: cargo test (5 passed) + clippy -D warnings + fmt + wasm-pack build + tsc --noEmit + next build all green. --- apps/parent-control/lib/client/core.ts | 23 ++++++++---- crates/agentkeys-web-core/src/broker.rs | 50 ++++++++++++++++++++++++- dev.sh | 12 +++--- 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/apps/parent-control/lib/client/core.ts b/apps/parent-control/lib/client/core.ts index 4544f27..808ba1a 100644 --- a/apps/parent-control/lib/client/core.ts +++ b/apps/parent-control/lib/client/core.ts @@ -3,19 +3,26 @@ import { EmptyBackend } from './empty'; import type { ConnectionStatus } from './types'; -// Lazy, client-only load of the WASM master-plane core (agentkeys-web-core). -// The dynamic import keeps the wasm glue out of the server bundle; init() fetches -// the .wasm from /wasm/ (served from public/, written by dev.sh's build_wasm). -let coreP: Promise | null = null; -async function loadCore(brokerUrl: string) { - if (!coreP) { - coreP = (async () => { +// Lazy, client-only load of the WASM master-plane core (agentkeys-web-core), +// memoized per broker URL. The dynamic import keeps the wasm glue out of the +// server bundle; init() fetches the .wasm from /wasm/ (served from public/, +// written by dev.sh's build_wasm). Keying by URL means a second CoreBackend with +// a different broker gets its own instance; on failure the entry is evicted so +// the next call retries (a transient load/broker failure must not poison it). +type LoadedCore = import('@/lib/wasm/agentkeys-web-core/agentkeys_web_core').WebCore; +const coreByUrl = new Map>(); +function loadCore(brokerUrl: string): Promise { + let p = coreByUrl.get(brokerUrl); + if (!p) { + p = (async () => { const wasm = await import('@/lib/wasm/agentkeys-web-core/agentkeys_web_core.js'); await wasm.default('/wasm/agentkeys_web_core_bg.wasm'); return new wasm.WebCore(brokerUrl); })(); + coreByUrl.set(brokerUrl, p); + void p.catch(() => coreByUrl.delete(brokerUrl)); } - return coreP; + return p; } /** diff --git a/crates/agentkeys-web-core/src/broker.rs b/crates/agentkeys-web-core/src/broker.rs index 143a8c9..6d85b77 100644 --- a/crates/agentkeys-web-core/src/broker.rs +++ b/crates/agentkeys-web-core/src/broker.rs @@ -43,9 +43,26 @@ pub struct BrokerClient { base_url: String, } +/// Default `reqwest::Client`. Native hosts (daemon/CLI/mobile) get a request +/// timeout so a stalled broker can't hang a worker thread forever; the +/// wasm/browser host uses the `fetch` backend (per-request timeouts there need +/// an `AbortController`, wired in the web host — not the `ClientBuilder`). +#[cfg(not(target_arch = "wasm32"))] +fn default_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +#[cfg(target_arch = "wasm32")] +fn default_client() -> reqwest::Client { + reqwest::Client::new() +} + impl BrokerClient { pub fn new(base_url: impl Into) -> Self { - Self::with_client(reqwest::Client::new(), base_url) + Self::with_client(default_client(), base_url) } /// Reuse a pre-built `reqwest::Client` (connection pooling, timeouts, or a @@ -149,7 +166,12 @@ impl BrokerClient { async fn decode(path: &str, resp: reqwest::Response) -> R { let status = resp.status(); if !status.is_success() { - let body = resp.text().await.unwrap_or_default(); + // Bound the echoed body: it's our own broker, reached over the + // operator's own session (no cross-tenant data; the bearer is never + // echoed back), but an unbounded error string shouldn't flow into a + // JS rejection / log line. 512 chars preserves the broker error code. + let raw = resp.text().await.unwrap_or_default(); + let body: String = raw.chars().take(512).collect(); return Err(BrokerError::Rejected { endpoint: path.to_string(), status: status.as_u16(), @@ -292,6 +314,11 @@ mod tests { .route( "/v1/cap/cred-store", post(|| async { (StatusCode::INTERNAL_SERVER_ERROR, "boom") }), + ) + .route( + // Returns an oversized body so the truncation guard can be tested. + "/v1/cap/cred-fetch", + post(|| async { (StatusCode::BAD_REQUEST, "x".repeat(1000)) }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -335,6 +362,25 @@ mod tests { } } + #[tokio::test] + async fn rejected_body_is_bounded() { + // A broker error body longer than the cap is truncated to 512 chars so it + // can't bloat a JS rejection / log line (status + endpoint preserved). + let c = BrokerClient::new(stub().await); + let err = c.cap_cred_fetch("J1", &cap_req()).await.unwrap_err(); + match err { + BrokerError::Rejected { status, body, .. } => { + assert_eq!(status, 400); + assert_eq!( + body.chars().count(), + 512, + "body should be capped at 512 chars" + ); + } + other => panic!("expected Rejected, got {other:?}"), + } + } + #[tokio::test] async fn pairing_claim_roundtrips() { let c = BrokerClient::new(stub().await); diff --git a/dev.sh b/dev.sh index e6b443a..25e95d0 100755 --- a/dev.sh +++ b/dev.sh @@ -255,12 +255,14 @@ build_wasm() { return 0 fi - # Version key: every .rs under src/ + Cargo.toml + the wasm-pack version. Any - # change ⇒ rebuild; otherwise reuse the cached pkg (the "verify same version"). + # Version key: every .rs under src/ (sorted, so the filesystem walk order can't + # change the hash) + this crate's Cargo.toml + the workspace Cargo.toml & + # Cargo.lock (so a transitive-dep bump busts the cache) + the rustc & wasm-pack + # versions. Any change ⇒ rebuild; otherwise reuse the cached pkg. local cur - cur="$( { find "$crate_dir/src" -type f -name '*.rs' -exec shasum -a 256 {} +; - shasum -a 256 "$crate_dir/Cargo.toml"; - wasm-pack --version; } | shasum -a 256 | awk '{print $1}' )" + cur="$( { find "$crate_dir/src" -type f -name '*.rs' -exec shasum -a 256 {} + | sort; + shasum -a 256 "$crate_dir/Cargo.toml" "$REPO_ROOT/Cargo.toml" "$REPO_ROOT/Cargo.lock"; + rustc -Vv; wasm-pack --version; } | shasum -a 256 | awk '{print $1}' )" if [ -f "$out_dir/agentkeys_web_core_bg.wasm" ] && [ -f "$pub_dir/agentkeys_web_core_bg.wasm" ] \ && [ -f "$stamp" ] && [ "$(cat "$stamp" 2>/dev/null)" = "$cur" ]; then