From 1821e9f46f224a4bfe28989941e5820d29c106ca Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Sat, 13 Jun 2026 01:06:10 +0800 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20#275=20B1=20re-land=20=E2=80=94?= =?UTF-8?q?=20agentkeys-protocol=20wire=20crate=20(wasm-safe),=20web-core?= =?UTF-8?q?=20shares=20the=20cap-mint=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-lands #215's B1 against current main (the original branch rotted 56 commits behind; protocol.rs had grown +256 lines that the move had to re-port). Extract the broker/worker wire types (backend-client's protocol module) into a standalone pure-serde agentkeys-protocol crate (no reqwest/tokio/aws — compiles to wasm32). agentkeys-backend-client re-exports it as ::protocol, so every existing agentkeys_backend_client::protocol::* path still resolves. agentkeys-web-core (browser/wasm) now aliases the SHARED BrokerCapRequest as CapRequest instead of carrying its own copy. That kills TWO live drifts: ttl_seconds (required u64 native vs Option browser — the shared type is Option + skip, faithful to the broker's serde default; native callers always send Some, wire byte-identical) and the #76 K10 cap-PoP fields (client_sig/client_nonce/client_ts), which web-core's copy was missing entirely (browser sends None — verified-when-present). web-core must NOT depend on backend-client directly (pulls aws-sdk-sts + tokio + native reqwest via the provisioner; breaks the wasm build): new harness-ci rust-checks gate cargo-checks web-core for wasm32 (default + --features wasm). The wasm32 target itself is pinned in rust-toolchain.toml (single SoT; rustup toolchain install picks it up — no dtolnay action, per the #276 pin rule). Verified: cargo build/test --workspace, clippy -D warnings, fmt, wasm32 checks (default + wasm), dump-protocol-fixtures --check (cap_mint_request.json byte-unchanged), check-backend-fixture-drift.sh, check-web-api-drift.sh. --- .github/workflows/harness-ci.yml | 14 ++++ AGENTS.md | 4 +- Cargo.lock | 10 +++ Cargo.toml | 2 + crates/agentkeys-backend-client/Cargo.toml | 6 ++ crates/agentkeys-backend-client/src/client.rs | 9 ++- .../agentkeys-backend-client/src/fixtures.rs | 2 +- crates/agentkeys-backend-client/src/lib.rs | 2 +- crates/agentkeys-daemon/src/proxy.rs | 4 +- crates/agentkeys-protocol/Cargo.toml | 9 +++ .../src/lib.rs} | 75 ++++++++++++++++--- crates/agentkeys-web-core/Cargo.toml | 6 ++ crates/agentkeys-web-core/src/broker.rs | 36 ++++----- rust-toolchain.toml | 5 ++ 14 files changed, 150 insertions(+), 34 deletions(-) create mode 100644 crates/agentkeys-protocol/Cargo.toml rename crates/{agentkeys-backend-client/src/protocol.rs => agentkeys-protocol/src/lib.rs} (86%) diff --git a/.github/workflows/harness-ci.yml b/.github/workflows/harness-ci.yml index ab7670a2..a8ea037a 100644 --- a/.github/workflows/harness-ci.yml +++ b/.github/workflows/harness-ci.yml @@ -230,6 +230,20 @@ jobs: - name: Frontend↔harness plant contract has not drifted (issue #203 / #206) run: bash scripts/check-web-api-drift.sh + # B1 (#215 re-land / #275): the browser host (agentkeys-web-core — a + # cdylib+rlib that compiles to wasm32) shares the cap-mint wire shape with + # the native client through the pure-serde `agentkeys-protocol` crate. + # This gate fails if that shared crate (or web-core) ever pulls a + # native-only dep (tokio / native reqwest / aws-sdk-sts, the last via the + # provisioner) that breaks the browser build — exactly the regression that + # would occur if web-core depended on the native backend-client directly. + # Default + `wasm` bindings. The wasm32 target itself is pinned in + # rust-toolchain.toml (installed by the `rustup toolchain install` above). + - name: web-core compiles to wasm32 (no native transport leaked into the browser) + run: | + cargo check --target wasm32-unknown-unknown -p agentkeys-web-core + cargo check --target wasm32-unknown-unknown -p agentkeys-web-core --features wasm + detect-changes: # Issue #101: path-conditional triggers for auto-deploy of the test broker. # Computes `broker_changed` so deploy-test-broker can skip when a PR only diff --git a/AGENTS.md b/AGENTS.md index f5f1b3ab..5a27ca1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -219,10 +219,10 @@ Cap-tokens are **data-class-explicit**: six storage endpoints (`/v1/cap/{cred,me ## Broker/worker request shapes have ONE owner (issue #203) -The broker/worker client protocol — the six `/v1/cap/*` mint endpoints, the STS relay, worker put/get body types, audit append, the `memory:` service builder, the `0x`-omni normalizer — is owned by [`agentkeys-backend-client`](crates/agentkeys-backend-client/) (field types co-owned with [`agentkeys-types`](crates/agentkeys-types/)). **Never re-type a cap/worker body in a second Rust path or in bash** (the #200 drift-bug class). All Rust callers (MCP `HttpBackend`, daemon `ui_bridge`) delegate to `BackendClient`, so a drifted shape is a compile error; bash and the web app are fixture-gated in CI. +The broker/worker client protocol — the six `/v1/cap/*` mint endpoints, the STS relay, worker put/get body types, audit append, the `memory:` service builder, the `0x`-omni normalizer — has ONE definition, split across two crates by transport-safety: the wire **types** live in [`agentkeys-protocol`](crates/agentkeys-protocol/) — pure serde, transport-free, compiles to `wasm32` — and the native **client** (cap-mint → STS → worker) in [`agentkeys-backend-client`](crates/agentkeys-backend-client/), which re-exports the types as `agentkeys_backend_client::protocol` (field types co-owned with [`agentkeys-types`](crates/agentkeys-types/)). The browser host [`agentkeys-web-core`](crates/agentkeys-web-core/) (wasm) depends on the SAME `agentkeys-protocol`, so the cap-mint body cannot drift across native vs browser — it used to (`ttl_seconds` required-`u64` vs `Option`, and web-core's copy was missing the #76 K10 PoP fields). web-core must NOT depend on `agentkeys-backend-client` directly: that crate pulls `aws-sdk-sts` + `tokio` + native `reqwest` (via the provisioner) and breaks the wasm build — the `wasm32` CI gate in `harness-ci.yml` enforces this. **Never re-type a cap/worker body in a second Rust path or in bash** (the #200 drift-bug class). All Rust callers (the MCP server's `BackendClient`, daemon `ui_bridge`, web-core) compile against the shared types, so a drifted shape is a compile error; bash and the web app are fixture-gated in CI. **Rules when you touch this surface:** -- Wire-field change → edit the serde type in `agentkeys-backend-client::protocol`, regenerate the committed fixtures (`cargo run -p agentkeys-backend-client --bin dump-protocol-fixtures`), update the frozen key-set test in `fixtures.rs`. +- Wire-field change → edit the serde type in [`agentkeys-protocol`](crates/agentkeys-protocol/) (the single definition; re-exported as `agentkeys_backend_client::protocol`), regenerate the committed fixtures (`cargo run -p agentkeys-backend-client --bin dump-protocol-fixtures`), update the frozen key-set test in `fixtures.rs`. The native callers AND the browser host recompile against the new shape automatically; the `wasm32` gate proves the browser still builds. - Harness steps drive the `agentkeys` CLI, not hand-rolled curls. Raw curls only for negative / HTTP-status tests; a body that mirrors a canonical shape carries `# @backend-fixture: ` ([`scripts/check-backend-fixture-drift.sh`](scripts/check-backend-fixture-drift.sh) diffs it against [`harness/fixtures/backend-protocol/`](harness/fixtures/backend-protocol/) in CI); deliberately-malformed negative payloads are NOT annotated. - The daemon's web-API plant contract is pinned the same way: route + `ApiMemoryEntry` body SoT in [`ui_bridge.rs`](crates/agentkeys-daemon/src/ui_bridge.rs), fixture [`harness/fixtures/web-api/master_memory_plant.json`](harness/fixtures/web-api/master_memory_plant.json), both non-Rust consumers annotated `@web-fixture: master_memory_plant` and gated by [`scripts/check-web-api-drift.sh`](scripts/check-web-api-drift.sh). - Field names are the arch.md canonical spellings — never invent a synonym in a new body. diff --git a/Cargo.lock b/Cargo.lock index bfd3dbe9..d4f20e44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,6 +42,7 @@ name = "agentkeys-backend-client" version = "0.1.0" dependencies = [ "agentkeys-core", + "agentkeys-protocol", "agentkeys-provisioner", "async-trait", "reqwest", @@ -340,6 +341,14 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "agentkeys-protocol" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "agentkeys-provisioner" version = "0.1.0" @@ -373,6 +382,7 @@ dependencies = [ name = "agentkeys-web-core" version = "0.1.0" dependencies = [ + "agentkeys-protocol", "axum", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index 4da756b7..50d2ed53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "crates/agentkeys-mcp-server", "crates/agentkeys-provisioner", "crates/agentkeys-backend-client", + "crates/agentkeys-protocol", "crates/agentkeys-broker-server", "crates/agentkeys-worker-creds", "crates/agentkeys-worker-memory", @@ -31,6 +32,7 @@ agentkeys-memory-engine = { path = "crates/agentkeys-memory-engine" } agentkeys-memory-openviking = { path = "crates/agentkeys-memory-openviking" } agentkeys-web-core = { path = "crates/agentkeys-web-core" } agentkeys-backend-client = { path = "crates/agentkeys-backend-client" } +agentkeys-protocol = { path = "crates/agentkeys-protocol" } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } diff --git a/crates/agentkeys-backend-client/Cargo.toml b/crates/agentkeys-backend-client/Cargo.toml index b558e98b..4464abf7 100644 --- a/crates/agentkeys-backend-client/Cargo.toml +++ b/crates/agentkeys-backend-client/Cargo.toml @@ -23,6 +23,12 @@ path = "src/bin/dump_protocol_fixtures.rs" agentkeys-provisioner = { path = "../agentkeys-provisioner" } # K10 cap-mint proof-of-possession (issue #76): DeviceKey + cap_pop_now signing. agentkeys-core = { workspace = true } +# The transport-agnostic wire shapes (cap-mint + worker bodies). Moved out of +# this crate's `protocol` module into a standalone, wasm-safe crate so the +# browser host (agentkeys-web-core) can share them without pulling in this +# crate's native transport (reqwest/tokio + the provisioner's aws-sdk-sts). +# Re-exported below as `agentkeys_backend_client::protocol` for back-compat. +agentkeys-protocol = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } diff --git a/crates/agentkeys-backend-client/src/client.rs b/crates/agentkeys-backend-client/src/client.rs index 9626f7b6..6ed51684 100644 --- a/crates/agentkeys-backend-client/src/client.rs +++ b/crates/agentkeys-backend-client/src/client.rs @@ -216,7 +216,12 @@ impl BackendClient { actor_omni: req.actor_omni, service: req.service, device_key_hash, - ttl_seconds: req.ttl_seconds, + // Caller-side `CapMintRequest` always carries an explicit ttl, + // so the wire body always sends it (`Some`) — byte-identical + // to before the on-wire field became `Option`. Only a direct + // on-wire caller (the browser) may send `None` to take the + // broker default. + ttl_seconds: Some(req.ttl_seconds), client_sig: Some(pop.client_sig), client_nonce: Some(pop.client_nonce), client_ts: Some(pop.client_ts), @@ -227,7 +232,7 @@ impl BackendClient { actor_omni: req.actor_omni, service: req.service, device_key_hash: req.device_key_hash, - ttl_seconds: req.ttl_seconds, + ttl_seconds: Some(req.ttl_seconds), client_sig: None, client_nonce: None, client_ts: None, diff --git a/crates/agentkeys-backend-client/src/fixtures.rs b/crates/agentkeys-backend-client/src/fixtures.rs index 6c58bbaa..9d924100 100644 --- a/crates/agentkeys-backend-client/src/fixtures.rs +++ b/crates/agentkeys-backend-client/src/fixtures.rs @@ -42,7 +42,7 @@ pub fn canonical_fixtures() -> Vec { actor_omni: "0x".into(), service: "memory:".into(), device_key_hash: "0x".into(), - ttl_seconds: 300, + ttl_seconds: Some(300), client_sig: None, client_nonce: None, client_ts: None, diff --git a/crates/agentkeys-backend-client/src/lib.rs b/crates/agentkeys-backend-client/src/lib.rs index 2929719d..143fec8b 100644 --- a/crates/agentkeys-backend-client/src/lib.rs +++ b/crates/agentkeys-backend-client/src/lib.rs @@ -13,7 +13,7 @@ pub mod client; pub mod fixtures; -pub mod protocol; +pub use agentkeys_protocol as protocol; pub use client::{BackendClient, BackendError}; pub use protocol::{ diff --git a/crates/agentkeys-daemon/src/proxy.rs b/crates/agentkeys-daemon/src/proxy.rs index 9cad84ec..2ca44bac 100644 --- a/crates/agentkeys-daemon/src/proxy.rs +++ b/crates/agentkeys-daemon/src/proxy.rs @@ -227,7 +227,7 @@ async fn handle_cap( actor_omni: req.actor_omni.clone(), service: req.service.clone(), device_key_hash: device_key.device_key_hash().unwrap_or_default(), - ttl_seconds: req.ttl_seconds.unwrap_or(300), + ttl_seconds: Some(req.ttl_seconds.unwrap_or(300)), client_sig: Some(pop.client_sig), client_nonce: Some(pop.client_nonce), client_ts: Some(pop.client_ts), @@ -238,7 +238,7 @@ async fn handle_cap( actor_omni: req.actor_omni.clone(), service: req.service.clone(), device_key_hash: req.device_key_hash.clone(), - ttl_seconds: req.ttl_seconds.unwrap_or(300), + ttl_seconds: Some(req.ttl_seconds.unwrap_or(300)), client_sig: None, client_nonce: None, client_ts: None, diff --git a/crates/agentkeys-protocol/Cargo.toml b/crates/agentkeys-protocol/Cargo.toml new file mode 100644 index 00000000..45350e40 --- /dev/null +++ b/crates/agentkeys-protocol/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "agentkeys-protocol" +version = "0.1.0" +edition = "2021" +description = "Broker/worker wire protocol — the transport-agnostic, wasm-safe serde shapes shared by agentkeys-backend-client (native client + STS) and agentkeys-web-core (browser fetch client). Pure serde (no reqwest/tokio/aws), so it compiles to wasm32. The single owner of the cap-mint + worker request/response shapes (#203); the parity-ladder rung-3 split that lets the browser share the wire types without dragging native transport into the wasm build." + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/agentkeys-backend-client/src/protocol.rs b/crates/agentkeys-protocol/src/lib.rs similarity index 86% rename from crates/agentkeys-backend-client/src/protocol.rs rename to crates/agentkeys-protocol/src/lib.rs index eea54b8b..2bc9c4b2 100644 --- a/crates/agentkeys-backend-client/src/protocol.rs +++ b/crates/agentkeys-protocol/src/lib.rs @@ -1,20 +1,33 @@ //! The broker/worker wire protocol — the **single owner** of every request //! and response shape the cap-mint + worker chain serializes (issue #203). //! -//! Before this crate the same JSON was hand-typed in three places (the MCP -//! `HttpBackend`, the daemon `ui_bridge`, and bash `jq -n` bodies in the -//! harness), which is the structural cause of the drift bugs #200 fixed +//! Pure serde, **no transport** (no reqwest/tokio/aws), so it compiles to +//! `wasm32`: the native client `agentkeys-backend-client` re-exports it as +//! `::protocol`, and the browser host `agentkeys-web-core` (wasm) depends on +//! it directly. That split is the parity-ladder rung-3 move for the wire +//! shapes — the browser and the native client share ONE definition and cannot +//! drift (they used to: `ttl_seconds` was a required `u64` in backend-client +//! but `Option` in web-core's own copy, and web-core's copy was missing +//! the #76 K10 cap-PoP fields entirely). web-core must NOT depend on +//! `agentkeys-backend-client` instead: that crate pulls `aws-sdk-sts` + +//! `tokio` + native `reqwest` via the provisioner and breaks the wasm build +//! (the `wasm32` CI gate in `harness-ci.yml` enforces this). +//! +//! Before the one-owner discipline the same JSON was hand-typed in three +//! places (the MCP backend, the daemon `ui_bridge`, and bash `jq -n` bodies in +//! the harness), which is the structural cause of the drift bugs #200 fixed //! (`evm_address` vs `{address,chain_id}`, bare-vs-`0x` omni, per-namespace //! field shapes). Re-typing one of these in a second place is now either a //! compile error (Rust callers share these types) or a fixture mismatch (the -//! harness gate diffs bash bodies against [`crate::fixtures`]). +//! harness gate diffs bash bodies against `agentkeys-backend-client`'s +//! `fixtures` module). //! //! Naming follows arch.md's canonical-names rule: the field names here MUST //! match what `agentkeys_broker_server::handlers::cap` and the //! `agentkeys_worker_*` handlers deserialize. We mirror by hand (not a shared //! struct dep) because the broker/worker are heavy binaries — but the mirror //! is now in ONE place, exercised end-to-end in the MCP server's -//! `tests/three_acts.rs` and pinned by [`crate::fixtures`]. +//! `tests/three_acts.rs` and pinned by the backend-client fixtures. use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -98,20 +111,35 @@ pub struct CapMintRequest { /// Broker cap-mint request body — the exact JSON /// `agentkeys_broker_server::handlers::cap` deserializes for all `/v1/cap/*` -/// endpoints. Carries the K10 cap-mint **proof-of-possession** (issue #76): +/// endpoints, AND the on-the-wire shape the browser host +/// (`agentkeys-web-core`) serializes directly (it has no separate caller-side +/// type — it aliases this as `CapRequest`). +/// +/// Carries the K10 cap-mint **proof-of-possession** (issue #76): /// `client_sig` is an EIP-191 signature by the caller's K10 device key over /// `device_crypto::cap_pop_payload(operator, actor, service, op, data_class, /// client_nonce, client_ts)`. The broker validates it and the WORKER re-verifies /// it independently — so a compromised broker (which lacks the K10 private key) -/// cannot mint a usable cap. Built by [`BackendClient::cap_mint`] from an -/// injected `DeviceKey`, NOT hand-set by callers. +/// cannot mint a usable cap. Built by `BackendClient::cap_mint` (in +/// `agentkeys-backend-client`) from an injected `DeviceKey`, NOT hand-set by +/// callers. +/// +/// `ttl_seconds` is `Option` + `skip_serializing_if` to mirror the broker's +/// `#[serde(default = "default_ttl_seconds")]`: `None` omits the field so the +/// broker applies its default (300s, clamped 60..1800); native callers coming +/// from [`CapMintRequest`] always send `Some(..)` (wire-identical to before). +/// This is the SINGLE on-wire definition, so the browser and the native client +/// can no longer drift on it — previously each crate had its own copy and they +/// diverged on this very field (the bug class #203 closed for the chain, now +/// extended to the browser). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BrokerCapRequest { pub operator_omni: String, pub actor_omni: String, pub service: String, pub device_key_hash: String, - pub ttl_seconds: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub ttl_seconds: Option, // The K10 cap-PoP is OPTIONAL on the wire (issue #76 staged rollout): a caller // that holds the actor's K10 signs (the broker validates + the worker // re-verifies); a caller without one (e.g. a master before its K10 is @@ -559,4 +587,33 @@ mod tests { assert_eq!(normalize_omni_0x("0xabcd"), "0xabcd"); assert_eq!(normalize_omni_0x("0Xabcd"), "0Xabcd"); } + + #[test] + fn broker_cap_request_ttl_is_optional_on_the_wire() { + // Mirrors the broker's `#[serde(default)]`: `None` omits ttl_seconds (the + // broker then applies its default), `Some` emits a bare number. This is + // why the single on-wire type uses `Option` + skip rather than a required + // `u64` — the divergence web-core and backend-client used to carry. + let base = BrokerCapRequest { + operator_omni: "0xop".into(), + actor_omni: "0xactor".into(), + service: "memory:travel".into(), + device_key_hash: "0xdkh".into(), + ttl_seconds: None, + client_sig: None, + client_nonce: None, + client_ts: None, + }; + let omitted = serde_json::to_value(&base).unwrap(); + assert!( + omitted.get("ttl_seconds").is_none(), + "None must omit ttl_seconds so the broker applies its default" + ); + let present = serde_json::to_value(BrokerCapRequest { + ttl_seconds: Some(900), + ..base + }) + .unwrap(); + assert_eq!(present["ttl_seconds"], 900); + } } diff --git a/crates/agentkeys-web-core/Cargo.toml b/crates/agentkeys-web-core/Cargo.toml index 75fff083..ce69bac9 100644 --- a/crates/agentkeys-web-core/Cargo.toml +++ b/crates/agentkeys-web-core/Cargo.toml @@ -10,6 +10,12 @@ description = "Host-agnostic master-plane core (broker client + ceremony logic) crate-type = ["cdylib", "rlib"] [dependencies] +# The shared, wasm-safe broker/worker wire shapes (the cap-mint body). Pure +# serde, no transport — so the browser shares the cap-mint request type with the +# native client (agentkeys-backend-client) instead of re-declaring it, which is +# how the two used to drift on `ttl_seconds` (and the #76 K10 PoP fields). +# #203 / parity-ladder rung 3. +agentkeys-protocol = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/agentkeys-web-core/src/broker.rs b/crates/agentkeys-web-core/src/broker.rs index 219df51a..8c03c722 100644 --- a/crates/agentkeys-web-core/src/broker.rs +++ b/crates/agentkeys-web-core/src/broker.rs @@ -184,23 +184,22 @@ impl BrokerClient { } } -// ─── 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, - /// Signed capability service. For memory it is **namespace-qualified** — - /// `memory:` (e.g. `memory:travel`), arch.md §896 — because the broker - /// hashes it (`keccak(service)`) for `isServiceInScope` and the worker keys - /// storage off it (`bots//memory/memory:.enc`). A bare `memory` - /// never matches a `memory:` grant → `service_not_in_scope`; the web - /// client builds it with `memoryService(ns)`, never a bare `memory`. - pub service: String, - pub device_key_hash: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub ttl_seconds: Option, -} +// ─── Cap-mint types ────────────────────────────────────────────────────────── +// +// The cap-mint request body is the SHARED on-wire type owned by +// `agentkeys-protocol`, aliased here as `CapRequest` so this crate's call sites +// and the `wasm.rs` bindings stay unchanged. Sharing it means the browser host +// and the native client (agentkeys-backend-client) can no longer drift on this +// body — previously each had its own copy and they diverged on `ttl_seconds` +// (`Option` here vs a required `u64` there) AND this copy was missing the +// #76 K10 cap-PoP fields (`client_sig`/`client_nonce`/`client_ts`; browser +// callers send `None` — verified-when-present until the worker enforce flag +// flips). `service` is still the namespace-qualified signed service +// `memory:` (arch.md §896) — build it with `memoryService(ns)`, never a +// bare `memory` (→ `service_not_in_scope`). The cap-token *response* shape +// stays local: a typed convenience view over the same wire bytes the native +// client keeps opaque (the deliberate B3 non-unification). +pub use agentkeys_protocol::BrokerCapRequest as CapRequest; /// 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 @@ -339,6 +338,9 @@ mod tests { service: "memory".into(), device_key_hash: "0xdkh".into(), ttl_seconds: Some(900), + client_sig: None, + client_nonce: None, + client_ts: None, } } diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 9d071ae0..b45bcb78 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -11,3 +11,8 @@ [toolchain] channel = "1.96.0" components = ["clippy", "rustfmt"] +# wasm32: agentkeys-web-core is the browser host (wasm-pack / the harness-ci +# "web-core compiles to wasm32" gate). Pinning the target here keeps the pin +# file the single source of truth — local dev, CI, and the broker host all get +# it from `rustup toolchain install` instead of a per-machine `rustup target add`. +targets = ["wasm32-unknown-unknown"] From 3eeea857938be2161f284bc58c8cea58eed9ad83 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Sat, 13 Jun 2026 01:28:16 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20#275=20B2=20re-land=20extended=20?= =?UTF-8?q?=E2=80=94=20ts-rs=20generates=20the=20frontend=20wire=20types;?= =?UTF-8?q?=20CI=20typechecks=20against=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-lands #215's B2 against current main, extended to everything that grew while that branch rotted. ts-rs derives on the daemon's 12 ui_bridge Api* structs + MemoryCategory now generate apps/parent-control/lib/generated/*.ts; daemon.ts imports them and all 8 hand-declared wire interfaces are deleted. A Rust-side field rename is a frontend compile error (rung 2 → rung 3). u64 fields pin #[ts(type=number)]; skip-serialize Options pin #[ts(optional)]. Extended past #215's scope (its own flagged follow-up): - ProposedScope + the catalog Sensitivity generate too; gating becomes a real ScopeGating enum (TS union "auto" | "k11" instead of a bare &'static str). - The protocol crate's WireUserOp / BuildAcceptUserOpResponse / SubmitAcceptUserOpResponse generate too — daemon.ts's three inline UserOp build-response types and ApiSubmitResult are replaced by the shared shapes. Two latent drifts the codegen forced into the open, fixed here: - SubmitAcceptUserOpResponse had drifted from what the broker actually returns (#97 user_op_hash + audit_envelope_hashes, #230 pending) — realigned to the broker's real serialization; the daemon proxies relay it verbatim. - ApiActor's account_address/account_type were ad-hoc serde_json inserts in enrich_actor_account, invisible to any type contract — folded into the struct as proper optional fields. CI (harness-ci rust-checks): git diff --exit-code on the generated dir after cargo test regenerates, plus a NEW frontend gate — wasm-pack build + npm ci + tsc --noEmit — so the bindings AND their consumer typecheck on every PR (apps/parent-control/** added to the PR path filter; npm run typecheck is now fully green in CI for the first time, the core.ts wasm-artifact failures are gone because CI builds the wasm). ts-rs rides the no-serde-warnings feature. Verified: cargo fmt/test/clippy -D warnings (workspace), generated dir diff-clean after regeneration, check-web-api-drift.sh, fixture --check, npm run typecheck green. --- .github/workflows/harness-ci.yml | 28 +++ AGENTS.md | 1 + Cargo.lock | 44 +++++ apps/parent-control/lib/client/daemon.ts | 133 +++----------- apps/parent-control/lib/generated/ApiActor.ts | 32 ++++ .../lib/generated/ApiAnchorBatch.ts | 3 + .../lib/generated/ApiAnchorStatus.ts | 4 + .../lib/generated/ApiAuditEvent.ts | 14 ++ .../lib/generated/ApiCapToken.ts | 3 + .../lib/generated/ApiMemoryEntry.ts | 8 + .../lib/generated/ApiPaymentCap.ts | 3 + .../lib/generated/ApiScopeBits.ts | 3 + .../lib/generated/ApiTimeWindow.ts | 3 + .../parent-control/lib/generated/ApiWorker.ts | 4 + .../lib/generated/ApiWorkerActorShare.ts | 3 + .../generated/BuildAcceptUserOpResponse.ts | 8 + .../lib/generated/MemoryCategory.ts | 3 + .../lib/generated/ProposedScope.ts | 10 ++ .../lib/generated/ScopeGating.ts | 8 + .../lib/generated/Sensitivity.ts | 8 + .../generated/SubmitAcceptUserOpResponse.ts | 25 +++ .../lib/generated/WireUserOp.ts | 9 + crates/agentkeys-catalog/Cargo.toml | 5 + crates/agentkeys-catalog/src/lib.rs | 3 +- crates/agentkeys-daemon/Cargo.toml | 6 + crates/agentkeys-daemon/src/ui_bridge.rs | 165 +++++++++++++----- crates/agentkeys-protocol/Cargo.toml | 5 + crates/agentkeys-protocol/src/lib.rs | 29 ++- .../frontend-testability-cli-web-parity.md | 37 ++++ 29 files changed, 453 insertions(+), 154 deletions(-) create mode 100644 apps/parent-control/lib/generated/ApiActor.ts create mode 100644 apps/parent-control/lib/generated/ApiAnchorBatch.ts create mode 100644 apps/parent-control/lib/generated/ApiAnchorStatus.ts create mode 100644 apps/parent-control/lib/generated/ApiAuditEvent.ts create mode 100644 apps/parent-control/lib/generated/ApiCapToken.ts create mode 100644 apps/parent-control/lib/generated/ApiMemoryEntry.ts create mode 100644 apps/parent-control/lib/generated/ApiPaymentCap.ts create mode 100644 apps/parent-control/lib/generated/ApiScopeBits.ts create mode 100644 apps/parent-control/lib/generated/ApiTimeWindow.ts create mode 100644 apps/parent-control/lib/generated/ApiWorker.ts create mode 100644 apps/parent-control/lib/generated/ApiWorkerActorShare.ts create mode 100644 apps/parent-control/lib/generated/BuildAcceptUserOpResponse.ts create mode 100644 apps/parent-control/lib/generated/MemoryCategory.ts create mode 100644 apps/parent-control/lib/generated/ProposedScope.ts create mode 100644 apps/parent-control/lib/generated/ScopeGating.ts create mode 100644 apps/parent-control/lib/generated/Sensitivity.ts create mode 100644 apps/parent-control/lib/generated/SubmitAcceptUserOpResponse.ts create mode 100644 apps/parent-control/lib/generated/WireUserOp.ts create mode 100644 docs/plan/frontend-testability-cli-web-parity.md diff --git a/.github/workflows/harness-ci.yml b/.github/workflows/harness-ci.yml index a8ea037a..2d2cbc8e 100644 --- a/.github/workflows/harness-ci.yml +++ b/.github/workflows/harness-ci.yml @@ -124,6 +124,10 @@ on: - ".github/workflows/harness-ci.yml" - "Cargo.toml" - "Cargo.lock" + # B2 (#215 re-land): the frontend imports ts-rs-generated wire types and + # is typechecked in rust-checks — a frontend-only PR must run that gate + # too, or a drifted daemon.ts import goes green by skipping the job. + - "apps/parent-control/**" workflow_dispatch: inputs: stage: @@ -244,6 +248,30 @@ jobs: cargo check --target wasm32-unknown-unknown -p agentkeys-web-core cargo check --target wasm32-unknown-unknown -p agentkeys-web-core --features wasm + # B2 (#215 re-land, #203 parity ladder rung 3): the frontend's Api* wire + # types are GENERATED from the Rust structs via ts-rs into + # apps/parent-control/lib/generated/. `cargo test --workspace` above + # re-ran the #[ts(export)] tests, rewriting those .ts in place; if a + # Rust-side struct changed without committing the regenerated bindings, + # the working tree now differs from HEAD → fail (same discipline as the + # backend-protocol fixtures). + - name: ts-rs frontend bindings are up to date (#215 re-land B2) + run: git diff --exit-code -- apps/parent-control/lib/generated/ + + # The enforcement half of B2: the React frontend actually COMPILES against + # the generated bindings (a daemon-side rename is a tsc error here, not a + # silent runtime break). Builds the web-core wasm pkg first — core.ts (and + # from #275, daemon.ts's plant path) import its .d.ts, so typecheck needs + # it; this also makes `npm run typecheck` fully green in CI for the first + # time (it used to fail on the missing wasm artifacts in fresh checkouts). + - name: Frontend typechecks against the generated bindings (B2 enforcement) + run: | + curl -sSf https://rustwasm.github.io/wasm-pack/installer/init.sh | sh + wasm-pack build crates/agentkeys-web-core --dev --target web \ + --out-dir "$PWD/apps/parent-control/lib/wasm/agentkeys-web-core" -- --features wasm + npm ci --prefix apps/parent-control --no-audit --no-fund + npm run typecheck --prefix apps/parent-control + detect-changes: # Issue #101: path-conditional triggers for auto-deploy of the test broker. # Computes `broker_changed` so deploy-test-broker can skip when a PR only diff --git a/AGENTS.md b/AGENTS.md index 5a27ca1b..3c0518f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -224,6 +224,7 @@ The broker/worker client protocol — the six `/v1/cap/*` mint endpoints, the ST **Rules when you touch this surface:** - Wire-field change → edit the serde type in [`agentkeys-protocol`](crates/agentkeys-protocol/) (the single definition; re-exported as `agentkeys_backend_client::protocol`), regenerate the committed fixtures (`cargo run -p agentkeys-backend-client --bin dump-protocol-fixtures`), update the frozen key-set test in `fixtures.rs`. The native callers AND the browser host recompile against the new shape automatically; the `wasm32` gate proves the browser still builds. - Harness steps drive the `agentkeys` CLI, not hand-rolled curls. Raw curls only for negative / HTTP-status tests; a body that mirrors a canonical shape carries `# @backend-fixture: ` ([`scripts/check-backend-fixture-drift.sh`](scripts/check-backend-fixture-drift.sh) diffs it against [`harness/fixtures/backend-protocol/`](harness/fixtures/backend-protocol/) in CI); deliberately-malformed negative payloads are NOT annotated. +- **The frontend's wire types are GENERATED, never hand-mirrored (#215 re-land B2, rung 3).** `ts-rs` derives on the `ui_bridge.rs` `Api*` structs, the catalog `Sensitivity`, and the protocol UserOp build/submit responses emit [`apps/parent-control/lib/generated/*.ts`](apps/parent-control/lib/generated/); [`daemon.ts`](apps/parent-control/lib/client/daemon.ts) imports them instead of re-declaring interfaces, so a Rust-side rename is a frontend **compile error**. After changing one of those structs: `cargo test export_bindings` (any `cargo test` triggers it), commit the regenerated `.ts`. CI (`harness-ci.yml` rust-checks) `git diff --exit-code`s the generated dir AND runs `npm run typecheck` against a fresh wasm-pack build, so both the bindings and their consumers are gated. `u64` fields carry `#[ts(type = "number")]`; skip-serialize `Option`s carry `#[ts(optional)]`. Never edit `lib/generated/` by hand, and never add a hand-declared wire interface next to a generated one. - The daemon's web-API plant contract is pinned the same way: route + `ApiMemoryEntry` body SoT in [`ui_bridge.rs`](crates/agentkeys-daemon/src/ui_bridge.rs), fixture [`harness/fixtures/web-api/master_memory_plant.json`](harness/fixtures/web-api/master_memory_plant.json), both non-Rust consumers annotated `@web-fixture: master_memory_plant` and gated by [`scripts/check-web-api-drift.sh`](scripts/check-web-api-drift.sh). - Field names are the arch.md canonical spellings — never invent a synonym in a new body. diff --git a/Cargo.lock b/Cargo.lock index d4f20e44..96c12f11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,6 +125,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "thiserror 2.0.18", + "ts-rs", ] [[package]] @@ -239,6 +240,7 @@ dependencies = [ "tower-service", "tracing", "tracing-subscriber", + "ts-rs", "url", "webauthn-rs", ] @@ -347,6 +349,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "ts-rs", ] [[package]] @@ -4606,6 +4609,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "termtree" version = "0.5.1" @@ -4991,6 +5003,29 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ts-rs" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e640d9b0964e9d39df633548591090ab92f7a4567bc31d3891af23471a3365c6" +dependencies = [ + "lazy_static", + "thiserror 2.0.18", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9d8656589772eeec2cf7a8264d9cda40fb28b9bc53118ceb9e8c07f8f38730" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "termcolor", +] + [[package]] name = "tungstenite" version = "0.23.0" @@ -5388,6 +5423,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/apps/parent-control/lib/client/daemon.ts b/apps/parent-control/lib/client/daemon.ts index 63b5cb7c..8488cf04 100644 --- a/apps/parent-control/lib/client/daemon.ts +++ b/apps/parent-control/lib/client/daemon.ts @@ -41,6 +41,21 @@ import type { StatusKind, Worker, } from '@/app/_components/types'; +// Wire types GENERATED from the Rust structs via ts-rs (#203 B2 / #215 re-land): +// the daemon's ui_bridge Api* structs, the catalog Sensitivity union, and the +// broker UserOp build/submit response shapes (agentkeys-protocol). Do not +// hand-edit @/lib/generated or re-declare these here — a Rust-side field rename +// regenerates the .ts and the mappers below stop compiling (rung-3 drift gate; +// CI also git-diffs the generated dir after `cargo test` regenerates it). +import type { ApiActor } from '@/lib/generated/ApiActor'; +import type { ApiAnchorStatus } from '@/lib/generated/ApiAnchorStatus'; +import type { ApiAuditEvent } from '@/lib/generated/ApiAuditEvent'; +import type { ApiMemoryEntry } from '@/lib/generated/ApiMemoryEntry'; +import type { ApiWorker } from '@/lib/generated/ApiWorker'; +import type { BuildAcceptUserOpResponse } from '@/lib/generated/BuildAcceptUserOpResponse'; +import type { MemoryCategory as ApiMemoryCategory } from '@/lib/generated/MemoryCategory'; +import type { ProposedScope as ApiProposedScope } from '@/lib/generated/ProposedScope'; +import type { SubmitAcceptUserOpResponse as ApiSubmitResult } from '@/lib/generated/SubmitAcceptUserOpResponse'; /** * DaemonBackend — talks to a running agentkeys-daemon over HTTP. @@ -198,11 +213,7 @@ export class DaemonBackend implements AgentKeysClient { } async getAnchorStatus(): Promise> { - const r = await this.getJson<{ - last_anchor_at: number; - next_anchor_in: number; - recent: { ts: string; root: string; count: number; txn: string; conf: number }[]; - }>('/v1/anchor/status'); + const r = await this.getJson('/v1/anchor/status'); if (!r.ok) return r; return { ok: true, @@ -256,9 +267,7 @@ export class DaemonBackend implements AgentKeysClient { // the per-agent unpair; every paired agent = the pre-reset fleet teardown // (ONE Touch ID). The broker skips already-revoked hashes; all-skipped // returns 409 "nothing to revoke". - async revokeBuild(input: { deviceKeyHashes: string[] }): Promise< - Result<{ user_op: Record; user_op_hash: string; entry_point: string; chain_id: number }> - > { + async revokeBuild(input: { deviceKeyHashes: string[] }): Promise> { return this.postJson('/v1/revoke/build', { device_key_hashes: input.deviceKeyHashes }); } @@ -430,7 +439,7 @@ export class DaemonBackend implements AgentKeysClient { { // @web-fixture: master_memory_plant — entry shape gated by scripts/check-web-api-drift.sh // (must match the daemon's ApiMemoryEntry + web-parity-demo.sh; issue #203 / the #206 parity ladder). - entries: entries.map((m) => ({ + entries: entries.map((m): ApiMemoryEntry => ({ ns: m.ns, key: m.key, title: m.title, bytes: m.bytes, version: m.version, updated: m.updated, preview: m.preview, body: m.body, content_hash: m.contentHash ?? '', @@ -587,9 +596,7 @@ export class DaemonBackend implements AgentKeysClient { maxPerPeriod: string; maxTotal: string; periodSeconds: number; - }): Promise< - Result<{ user_op: Record; user_op_hash: string; entry_point: string; chain_id: number }> - > { + }): Promise> { return this.postJson('/v1/accept/build', { request_id: input.requestId, services: input.services, @@ -614,9 +621,7 @@ export class DaemonBackend implements AgentKeysClient { services: string[]; preserveServiceIds?: string[]; readOnly: boolean; - }): Promise< - Result<{ user_op: Record; user_op_hash: string; entry_point: string; chain_id: number }> - > { + }): Promise> { return this.postJson('/v1/scope/build', { actor_omni: input.actorOmni, services: input.services, @@ -655,71 +660,10 @@ export class DaemonBackend implements AgentKeysClient { } } -interface ApiProposedScope { - data_class: string; - entity: string; - service: string; - category: string; - sensitivity: 'safe' | 'sensitive'; - gating: 'auto' | 'k11'; - confidence: number; -} - -// ─── API wire types (snake_case, mirror ui_bridge.rs ApiActor etc.) ──── - -interface ApiActor { - id: string; - omni: string; - omni_hex: string; - label: string; - role: string; - parent: string | null; - derivation: string; - device: string; - device_pubkey: string; - last_active: string; - status: string; - vendor: string; - k11: boolean; - scope?: Record; - // #248: on-chain scope service ids (keccak hex) that aren't a known memory: - // (e.g. cred:); echoed back on commit so set-replace can't wipe them. - scope_unknown_service_ids?: string[]; - // On-chain SidecarRegistry device key hash — the Touch-ID unpair's target. - device_key_hash?: string; - payment_cap?: { per_tx: number; daily: number; currency: string }; - time_window?: { start: string; end: string; tz: string }; - services?: string[]; - // #225 E7: on-chain account (master → P256Account address; agent → device omni). - account_address?: string | null; - account_type?: string; // "p256account" | "device" | "none" -} - -interface ApiAuditEvent { - id: string; - ts: string; - actor_id: string; - actor: string; - kind: string; - detail: string; - chip: string; - sev: string; - tx_hash?: string; - audit_envelope_hashes?: string[]; -} - -interface ApiWorker { - id: string; - title: string; - host: string; - desc: string; - calls_today: number; - calls_hour: number; - p50: number; - p95: number; - cap: string; - by_actor: { actor: string; count: number; share: number }[]; -} +// ─── Wire types are imported from @/lib/generated (ts-rs, generated from the +// Rust structs — #203 B2). The mappers below convert the snake_case wire +// types to the camelCase UI domain types; a Rust-side field rename +// regenerates the .ts and breaks these mappers (the drift gate). ───────── function apiToActor(a: ApiActor): Actor { return { @@ -749,19 +693,9 @@ function apiToActor(a: ApiActor): Actor { }; } -/** #97: broker submit response shape (relayed verbatim by the daemon proxies). */ -interface ApiSubmitResult { - ok?: boolean; - tx_hash?: string; - block_number?: string; - user_op_hash?: string; - pending?: boolean; - audit_envelope_hashes?: string[]; -} - function apiToSubmitResult(r: ApiSubmitResult): SubmitResult { return { - ok: r.ok ?? true, + ok: r.ok, txHash: r.tx_hash || undefined, blockNumber: r.block_number || undefined, userOpHash: r.user_op_hash || undefined, @@ -825,23 +759,6 @@ function normalizeChip(c: string): ChipKind { return (allowed as string[]).includes(c) ? (c as ChipKind) : 'default'; } -interface ApiMemoryEntry { - ns: string; - key: string; - title: string; - bytes: number; - version: string; - updated: string; - preview: string; - body: string; - content_hash?: string; -} - -interface ApiMemoryCategory { - ns: string; - label: string; -} - function apiToMemoryEntry(m: ApiMemoryEntry): MasterMemoryEntry { return { ns: m.ns, key: m.key, title: m.title, bytes: m.bytes, diff --git a/apps/parent-control/lib/generated/ApiActor.ts b/apps/parent-control/lib/generated/ApiActor.ts new file mode 100644 index 00000000..c76631b3 --- /dev/null +++ b/apps/parent-control/lib/generated/ApiActor.ts @@ -0,0 +1,32 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ApiPaymentCap } from "./ApiPaymentCap"; +import type { ApiScopeBits } from "./ApiScopeBits"; +import type { ApiTimeWindow } from "./ApiTimeWindow"; + +export type ApiActor = { id: string, omni: string, omni_hex: string, label: string, role: string, parent: string | null, derivation: string, device: string, device_pubkey: string, last_active: string, status: string, vendor: string, k11: boolean, +/** + * #233/#243: the on-chain `SidecarRegistry` device key hash (`0x` + 64 hex) + * when known — set for chain-reconstructed actors and fresh pairings. Lets + * the master-reset fleet teardown revoke by hash even when the per-label + * `~/.agentkeys/agents/