diff --git a/.github/workflows/harness-ci.yml b/.github/workflows/harness-ci.yml index 1555d4c5..2547e105 100644 --- a/.github/workflows/harness-ci.yml +++ b/.github/workflows/harness-ci.yml @@ -152,6 +152,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: components: clippy, rustfmt + targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 with: @@ -187,6 +188,28 @@ jobs: - name: Frontend↔harness plant contract has not drifted (issue #203 / #206) run: bash scripts/check-web-api-drift.sh + # B1 (#203 follow-up): 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. + - 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 + + # B2 (#203 parity ladder, rung 3): the frontend imports the Api* wire types + # GENERATED from ui_bridge.rs via ts-rs (apps/parent-control/lib/generated/). + # `cargo test --workspace` above re-ran the #[ts(export)] tests, rewriting + # those .ts in place; if a daemon-side struct changed without committing the + # regenerated bindings, the working tree now differs from HEAD → fail (same + # discipline as the backend-protocol fixtures). Keeps the generated types and + # the daemon structs from drifting silently. + - name: ts-rs frontend bindings are up to date (issue #203 B2) + run: git diff --exit-code -- apps/parent-control/lib/generated/ + 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/CLAUDE.md b/CLAUDE.md index 4500a426..ec46328a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -245,16 +245,17 @@ Verified live: ## Broker/worker request shapes have ONE owner (issue #203) -**The broker/worker client protocol — cap-mint (the six `/v1/cap/*` endpoints: cred/memory/config × store/fetch, #201), the STS relay, worker `/v1/memory/{put,get}` + `/v1/config/{put,get}` body types, audit append, the `memory:` service builder, and the `0x`-omni normalizer — is owned by ONE crate, [`agentkeys-backend-client`](crates/agentkeys-backend-client/) (request/response 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 broker/worker client protocol — cap-mint (the six `/v1/cap/*` endpoints: cred/memory/config × store/fetch, #201), the STS relay, worker `/v1/memory/{put,get}` + `/v1/config/{put,get}` body types, audit append, the `memory:` service builder, and the `0x`-omni normalizer — has ONE definition, split across two crates by transport-safety: the wire **types** live in [`agentkeys-protocol`](crates/agentkeys-protocol/) — a pure-serde, transport-free crate that 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`. The browser client [`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` was a required `u64` in backend-client but `Option` in web-core's own copy). web-core must NOT depend on `agentkeys-backend-client` directly: that crate pulls `aws-sdk-sts` + `tokio` + native `reqwest` (via `agentkeys-provisioner`) and would break 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.** This is the structural fix for the drift bug class #200 closed (`evm_address` vs `{address,chain_id}`, bare-vs-`0x` omni, per-namespace field shapes): the same JSON used to be hand-coded in the MCP `HttpBackend`, the daemon `ui_bridge`, and bash `jq -n` bodies. Now: -- **Rust callers share the crate.** The MCP server's [`HttpBackend`](crates/agentkeys-mcp-server/src/backend/http_backend.rs) is a thin delegate over `BackendClient`; the daemon's [`ui_bridge`](crates/agentkeys-daemon/src/ui_bridge.rs) mints every master-self cap (memory AND #201 config) via `BackendClient::cap_mint` (`mint_master_cap`) and builds its worker put/get bodies from the crate's `MemoryPutBody`/`MemoryGetBody`/`ConfigPutBody`/`ConfigGetBody` types. A drifted shape is a **compile error**. (The raw worker POST stays in the daemon to reuse the once-minted STS creds across namespaces; only the body shape is crate-owned.) +- **Rust callers share the crate.** The MCP server's tools call `BackendClient` directly — the `Backend` trait is `impl`'d on `BackendClient` and the old `HttpBackend` delegate was removed in #213 (the trait is kept only as the `MockBackend` test seam). The daemon's [`ui_bridge`](crates/agentkeys-daemon/src/ui_bridge.rs) mints every master-self cap (memory AND #201 config) via `BackendClient::cap_mint` (`mint_master_cap`) and builds its worker put/get bodies from the crate's `MemoryPutBody`/`MemoryGetBody`/`ConfigPutBody`/`ConfigGetBody` types (re-exported from `agentkeys-protocol`). A drifted shape is a **compile error**. (The raw worker POST stays in the daemon to reuse the once-minted STS creds across namespaces; only the body shape is crate-owned.) - **Bash is gated.** Any hand-rolled cap/worker body in `harness/**` that is meant to mirror the wire shape carries a `# @backend-fixture: ` comment above it. [`scripts/check-backend-fixture-drift.sh`](scripts/check-backend-fixture-drift.sh) diffs each annotated body's key-set against the crate-emitted fixtures in [`harness/fixtures/backend-protocol/`](harness/fixtures/backend-protocol/) (regenerated by `cargo run -p agentkeys-backend-client --bin dump-protocol-fixtures`). A drifted bash body is a **CI failure** (the [`harness-ci.yml`](.github/workflows/harness-ci.yml) `rust-checks` job runs both the fixture `--check` and the bash gate on every PR touching `crates/**`, `harness/**`, or `scripts/**`). - **The daemon's web-API plant contract is gated too** (the adjacent frontend↔daemon↔harness surface — the #206 parity ladder, rung 2). The route `/v1/master/memory/plant` + the `ApiMemoryEntry` body have ONE source of truth (the daemon's `MASTER_MEMORY_PLANT_ROUTE` const + struct in [`ui_bridge.rs`](crates/agentkeys-daemon/src/ui_bridge.rs)), pinned to [`harness/fixtures/web-api/master_memory_plant.json`](harness/fixtures/web-api/master_memory_plant.json) by a `ui_bridge` unit test, and BOTH non-Rust consumers — the React frontend [`daemon.ts`](apps/parent-control/lib/client/daemon.ts) and [`web-parity-demo.sh`](harness/web-parity-demo.sh) — carry a `@web-fixture: master_memory_plant` annotation and are diffed against it by [`scripts/check-web-api-drift.sh`](scripts/check-web-api-drift.sh) in CI. This closed phase 6's frontend false-green (a `daemon.ts` route/shape change is now CI-red, not a stale green). +- **The frontend's `Api*` wire types are GENERATED, not hand-mirrored (#203 B2, rung 3).** `ts-rs` derives on the `ui_bridge.rs` `Api*` structs emit [`apps/parent-control/lib/generated/*.ts`](apps/parent-control/lib/generated/); `daemon.ts` imports them instead of re-declaring interfaces, so a daemon-side field rename is a frontend **compile error** (and the [`harness-ci.yml`](.github/workflows/harness-ci.yml) `rust-checks` job `git diff --exit-code`s the generated dir after `cargo test` regenerates it). `u64` fields are pinned to `number` (`#[ts(type = "number")]`) and skip-serialize `Option`s to `?:` (`#[ts(optional)]`) to match the wire. Regenerate after a struct change: `cargo test -p agentkeys-daemon export_bindings` (any `cargo test` triggers it), then commit the `.ts`. (Still hand-declared, next codegen batch: the #207 `ApiProposedScope` + classify/credentials response types.) **Rules when you touch this surface:** -- Adding/changing a wire field → change the serde type in `agentkeys-backend-client::protocol` (the single definition), then `cargo run -p agentkeys-backend-client --bin dump-protocol-fixtures` to regenerate the committed fixtures, and update the frozen key-set test in `fixtures.rs`. The four Rust callers recompile against the new shape automatically. +- Adding/changing a wire field → change the serde type in [`agentkeys-protocol`](crates/agentkeys-protocol/) (the single definition; `agentkeys-backend-client` re-exports it as `::protocol`), then `cargo run -p agentkeys-backend-client --bin dump-protocol-fixtures` to regenerate the committed fixtures, and update the frozen key-set test in `fixtures.rs`. All Rust callers — the MCP server, the daemon, AND the browser host `agentkeys-web-core` (wasm) — recompile against the new shape automatically; the `wasm32` CI gate proves the browser still builds. - Real-path harness steps should drive the `agentkeys` CLI (which routes through the shared client), NOT hand-roll curls. Keep raw curls only for negative / HTTP-status-assertion tests — and annotate any such body that mirrors a canonical shape with `# @backend-fixture: ` so the gate keeps it honest. Deliberately-malformed negative-test payloads are NOT annotated (they're supposed to be wrong). - Per the terminology-source-of-truth rule, the field names are the arch.md canonical spellings — don't invent a synonym in a new body. diff --git a/Cargo.lock b/Cargo.lock index e56249e8..7450cee0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,7 @@ dependencies = [ name = "agentkeys-backend-client" version = "0.1.0" dependencies = [ + "agentkeys-protocol", "agentkeys-provisioner", "async-trait", "reqwest", @@ -215,6 +216,7 @@ dependencies = [ "tower-service", "tracing", "tracing-subscriber", + "ts-rs", "url", "webauthn-rs", ] @@ -316,6 +318,14 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "agentkeys-protocol" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "agentkeys-provisioner" version = "0.1.0" @@ -349,6 +359,7 @@ dependencies = [ name = "agentkeys-web-core" version = "0.1.0" dependencies = [ + "agentkeys-protocol", "axum", "reqwest", "serde", @@ -4568,6 +4579,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" @@ -4953,6 +4973,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" @@ -5350,6 +5393,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/Cargo.toml b/Cargo.toml index d4cb7b34..94b88492 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", @@ -30,6 +31,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/apps/parent-control/lib/client/daemon.ts b/apps/parent-control/lib/client/daemon.ts index 8a6a6bf5..c85eca31 100644 --- a/apps/parent-control/lib/client/daemon.ts +++ b/apps/parent-control/lib/client/daemon.ts @@ -32,6 +32,15 @@ import type { StatusKind, Worker, } from '@/app/_components/types'; +// Wire types GENERATED from the Rust ui_bridge Api* structs via ts-rs (#203 B2). +// Do not hand-edit @/lib/generated or re-declare these here — a daemon-side field +// rename regenerates the .ts and the mappers below stop compiling (rung-3 drift +// gate; CI also git-diffs the generated dir). +import type { ApiActor } from '@/lib/generated/ApiActor'; +import type { ApiAuditEvent } from '@/lib/generated/ApiAuditEvent'; +import type { ApiWorker } from '@/lib/generated/ApiWorker'; +import type { ApiMemoryEntry } from '@/lib/generated/ApiMemoryEntry'; +import type { MemoryCategory as ApiMemoryCategory } from '@/lib/generated/MemoryCategory'; /** * DaemonBackend — talks to a running agentkeys-daemon over HTTP. @@ -486,51 +495,10 @@ interface ApiProposedScope { 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; - payment_cap?: { per_tx: number; daily: number; currency: string }; - time_window?: { start: string; end: string; tz: string }; - services?: string[]; -} - -interface ApiAuditEvent { - id: string; - ts: string; - actor_id: string; - actor: string; - kind: string; - detail: string; - chip: string; - sev: 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 +// ui_bridge.rs Api* structs — #203 B2). The mappers below convert the +// snake_case wire types to the camelCase UI domain types; a daemon-side +// field rename regenerates the .ts and breaks these mappers (drift gate). ── function apiToActor(a: ApiActor): Actor { return { @@ -606,23 +574,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..095e66a1 --- /dev/null +++ b/apps/parent-control/lib/generated/ApiActor.ts @@ -0,0 +1,6 @@ +// 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, scope?: { [key in string]?: ApiScopeBits }, payment_cap?: ApiPaymentCap, time_window?: ApiTimeWindow, services?: Array, }; diff --git a/apps/parent-control/lib/generated/ApiAnchorBatch.ts b/apps/parent-control/lib/generated/ApiAnchorBatch.ts new file mode 100644 index 00000000..e7480d35 --- /dev/null +++ b/apps/parent-control/lib/generated/ApiAnchorBatch.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ApiAnchorBatch = { ts: string, root: string, count: number, txn: string, conf: number, }; diff --git a/apps/parent-control/lib/generated/ApiAnchorStatus.ts b/apps/parent-control/lib/generated/ApiAnchorStatus.ts new file mode 100644 index 00000000..c8623132 --- /dev/null +++ b/apps/parent-control/lib/generated/ApiAnchorStatus.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ApiAnchorBatch } from "./ApiAnchorBatch"; + +export type ApiAnchorStatus = { last_anchor_at: number, next_anchor_in: number, recent: Array, }; diff --git a/apps/parent-control/lib/generated/ApiAuditEvent.ts b/apps/parent-control/lib/generated/ApiAuditEvent.ts new file mode 100644 index 00000000..f10c6bcf --- /dev/null +++ b/apps/parent-control/lib/generated/ApiAuditEvent.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ApiAuditEvent = { id: string, ts: string, actor_id: string, actor: string, kind: string, detail: string, chip: string, sev: string, }; diff --git a/apps/parent-control/lib/generated/ApiCapToken.ts b/apps/parent-control/lib/generated/ApiCapToken.ts new file mode 100644 index 00000000..f4025cf7 --- /dev/null +++ b/apps/parent-control/lib/generated/ApiCapToken.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ApiCapToken = { id: string, cap: string, scope: string, ttl: string, minted: string, danger?: boolean, }; diff --git a/apps/parent-control/lib/generated/ApiMemoryEntry.ts b/apps/parent-control/lib/generated/ApiMemoryEntry.ts new file mode 100644 index 00000000..19df2671 --- /dev/null +++ b/apps/parent-control/lib/generated/ApiMemoryEntry.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A master-actor memory entry. `content_hash` is the dedup key — + * keccak-free sha256 over (ns || key || body) so a re-plant of the same + * content is detected and skipped (the "prevent duplicate plant" gate). + */ +export type ApiMemoryEntry = { ns: string, key: string, title: string, bytes: number, version: string, updated: string, preview: string, body: string, content_hash: string, }; diff --git a/apps/parent-control/lib/generated/ApiPaymentCap.ts b/apps/parent-control/lib/generated/ApiPaymentCap.ts new file mode 100644 index 00000000..1ce8ccac --- /dev/null +++ b/apps/parent-control/lib/generated/ApiPaymentCap.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ApiPaymentCap = { per_tx: number, daily: number, currency: string, }; diff --git a/apps/parent-control/lib/generated/ApiScopeBits.ts b/apps/parent-control/lib/generated/ApiScopeBits.ts new file mode 100644 index 00000000..df61148d --- /dev/null +++ b/apps/parent-control/lib/generated/ApiScopeBits.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ApiScopeBits = { read: boolean, write: boolean, }; diff --git a/apps/parent-control/lib/generated/ApiTimeWindow.ts b/apps/parent-control/lib/generated/ApiTimeWindow.ts new file mode 100644 index 00000000..de803975 --- /dev/null +++ b/apps/parent-control/lib/generated/ApiTimeWindow.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ApiTimeWindow = { start: string, end: string, tz: string, }; diff --git a/apps/parent-control/lib/generated/ApiWorker.ts b/apps/parent-control/lib/generated/ApiWorker.ts new file mode 100644 index 00000000..7dfe2c1f --- /dev/null +++ b/apps/parent-control/lib/generated/ApiWorker.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ApiWorkerActorShare } from "./ApiWorkerActorShare"; + +export type ApiWorker = { id: string, title: string, host: string, desc: string, calls_today: number, calls_hour: number, p50: number, p95: number, cap: string, by_actor: Array, }; diff --git a/apps/parent-control/lib/generated/ApiWorkerActorShare.ts b/apps/parent-control/lib/generated/ApiWorkerActorShare.ts new file mode 100644 index 00000000..afc3f615 --- /dev/null +++ b/apps/parent-control/lib/generated/ApiWorkerActorShare.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ApiWorkerActorShare = { actor: string, count: number, share: number, }; diff --git a/apps/parent-control/lib/generated/MemoryCategory.ts b/apps/parent-control/lib/generated/MemoryCategory.ts new file mode 100644 index 00000000..ba7c57dc --- /dev/null +++ b/apps/parent-control/lib/generated/MemoryCategory.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MemoryCategory = { ns: string, label: string, }; diff --git a/crates/agentkeys-backend-client/Cargo.toml b/crates/agentkeys-backend-client/Cargo.toml index 4958397d..436cf9ec 100644 --- a/crates/agentkeys-backend-client/Cargo.toml +++ b/crates/agentkeys-backend-client/Cargo.toml @@ -21,6 +21,12 @@ path = "src/bin/dump_protocol_fixtures.rs" # shared piece. The client crate wraps it so cap-mint + STS + worker put/get # live behind ONE owner (issue #203). agentkeys-provisioner = { path = "../agentkeys-provisioner" } +# 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/fixtures.rs b/crates/agentkeys-backend-client/src/fixtures.rs index 18ea0044..94a42118 100644 --- a/crates/agentkeys-backend-client/src/fixtures.rs +++ b/crates/agentkeys-backend-client/src/fixtures.rs @@ -37,7 +37,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), }; let memory_put = MemoryPutBody { cap: json!(""), diff --git a/crates/agentkeys-backend-client/src/lib.rs b/crates/agentkeys-backend-client/src/lib.rs index fcd983c2..3f650dad 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/Cargo.toml b/crates/agentkeys-daemon/Cargo.toml index 9c1656be..955e2d9d 100644 --- a/crates/agentkeys-daemon/Cargo.toml +++ b/crates/agentkeys-daemon/Cargo.toml @@ -27,6 +27,12 @@ sha2 = "0.10" # ui-bridge master-memory content-hash dedup (§2 plant) tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +# B2 (#203 parity ladder, rung 3): generate the Api* wire types as TypeScript so +# the parent-control frontend imports them instead of hand-mirroring (a drift then +# becomes a compile error, not a silent runtime break). The `#[ts(export)]` tests +# write apps/parent-control/lib/generated/ under `cargo test`; a CI git-diff gate +# fails on drift. +ts-rs = "10" anyhow = { workspace = true } clap = { version = "4", features = ["derive", "env"] } tracing = "0.1" diff --git a/crates/agentkeys-daemon/src/ui_bridge.rs b/crates/agentkeys-daemon/src/ui_bridge.rs index de066a9c..b126d559 100644 --- a/crates/agentkeys-daemon/src/ui_bridge.rs +++ b/crates/agentkeys-daemon/src/ui_bridge.rs @@ -39,6 +39,7 @@ use axum::{ }; use futures_util::stream::Stream; use serde::{Deserialize, Serialize}; +use ts_rs::TS; use tokio::sync::{broadcast, RwLock}; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::StreamExt; @@ -172,11 +173,13 @@ pub struct RegisteredMaster { /// A master-actor memory entry. `content_hash` is the dedup key — /// keccak-free sha256 over (ns || key || body) so a re-plant of the same /// content is detected and skipped (the "prevent duplicate plant" gate). -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../apps/parent-control/lib/generated/")] pub struct ApiMemoryEntry { pub ns: String, pub key: String, pub title: String, + #[ts(type = "number")] pub bytes: u64, pub version: String, pub updated: String, @@ -260,7 +263,8 @@ pub struct MemoryTaxonomy { categories: Vec, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../apps/parent-control/lib/generated/")] pub struct MemoryCategory { pub ns: String, pub label: String, @@ -366,27 +370,31 @@ pub type SharedUiBridgeState = Arc; const AUDIT_BUFFER_CAP: usize = 200; -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../apps/parent-control/lib/generated/")] pub struct ApiScopeBits { pub read: bool, pub write: bool, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../apps/parent-control/lib/generated/")] pub struct ApiPaymentCap { pub per_tx: f64, pub daily: f64, pub currency: String, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../apps/parent-control/lib/generated/")] pub struct ApiTimeWindow { pub start: String, pub end: String, pub tz: String, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../apps/parent-control/lib/generated/")] pub struct ApiActor { pub id: String, pub omni: String, @@ -402,16 +410,21 @@ pub struct ApiActor { pub vendor: String, pub k11: bool, #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub scope: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub payment_cap: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub time_window: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub services: Option>, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../apps/parent-control/lib/generated/")] pub struct ApiCapToken { pub id: String, pub cap: String, @@ -419,10 +432,12 @@ pub struct ApiCapToken { pub ttl: String, pub minted: String, #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub danger: Option, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../apps/parent-control/lib/generated/")] pub struct ApiAuditEvent { pub id: String, pub ts: String, @@ -434,39 +449,52 @@ pub struct ApiAuditEvent { pub sev: String, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../apps/parent-control/lib/generated/")] pub struct ApiWorkerActorShare { pub actor: String, + #[ts(type = "number")] pub count: u64, pub share: f64, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../apps/parent-control/lib/generated/")] pub struct ApiWorker { pub id: String, pub title: String, pub host: String, pub desc: String, + #[ts(type = "number")] pub calls_today: u64, + #[ts(type = "number")] pub calls_hour: u64, + #[ts(type = "number")] pub p50: u64, + #[ts(type = "number")] pub p95: u64, pub cap: String, pub by_actor: Vec, } -#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../apps/parent-control/lib/generated/")] pub struct ApiAnchorBatch { pub ts: String, pub root: String, + #[ts(type = "number")] pub count: u64, pub txn: String, + #[ts(type = "number")] pub conf: u64, } -#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../apps/parent-control/lib/generated/")] pub struct ApiAnchorStatus { + #[ts(type = "number")] pub last_anchor_at: u64, + #[ts(type = "number")] pub next_anchor_in: u64, pub recent: Vec, } 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 82% rename from crates/agentkeys-backend-client/src/protocol.rs rename to crates/agentkeys-protocol/src/lib.rs index cdeafb06..e3c327b1 100644 --- a/crates/agentkeys-backend-client/src/protocol.rs +++ b/crates/agentkeys-protocol/src/lib.rs @@ -85,15 +85,27 @@ pub struct CapMintRequest { } /// Broker cap-mint request body — the exact JSON -/// `agentkeys_broker_server::handlers::cap` deserializes for all four -/// `/v1/cap/*` endpoints. +/// `agentkeys_broker_server::handlers::cap` deserializes for all six +/// `/v1/cap/*` 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`). +/// +/// `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, } impl From for BrokerCapRequest { @@ -103,7 +115,11 @@ impl From for BrokerCapRequest { actor_omni: r.actor_omni, service: r.service, device_key_hash: r.device_key_hash, - ttl_seconds: r.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) can choose `None` to take the broker default. + ttl_seconds: Some(r.ttl_seconds), } } } @@ -317,4 +333,30 @@ 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, + }; + 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..e67446c4 100644 --- a/crates/agentkeys-web-core/Cargo.toml +++ b/crates/agentkeys-web-core/Cargo.toml @@ -10,6 +10,11 @@ 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`. #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 624e0d45..a6e36fb1 100644 --- a/crates/agentkeys-web-core/src/broker.rs +++ b/crates/agentkeys-web-core/src/broker.rs @@ -184,23 +184,19 @@ 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). `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 for now (a follow-up unifies it +// with the native client's opaque token). +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 diff --git a/docs/plan/frontend-testability-cli-web-parity.md b/docs/plan/frontend-testability-cli-web-parity.md new file mode 100644 index 00000000..7d5e34d6 --- /dev/null +++ b/docs/plan/frontend-testability-cli-web-parity.md @@ -0,0 +1,57 @@ +# Plan — frontend testability + CLI↔web parity + +**Status:** amended after Codex adversarial review (2026-06-06). **Landed on this branch + green: B0 (via #213, merged), B1 (`agentkeys-protocol` crate + wasm32 CI gate), B2 (ts-rs `Api*` codegen + drift gate). B3 + B5 assessed as no-code (documented below); B4 blocked on Part A.** Verified: cargo build/test/clippy `-D warnings` (native + wasm32) + `npm run typecheck` (clean except pre-existing `core.ts` wasm-artifact errors). Remaining: Part A (frontend Vitest) — and with it B4 — plus the #207 wire-type codegen follow-up. + +## Goal +Two asks, argued to share one root cause: make the parent-control web app testable, and shrink the parity gap between the CLI and the web app. Both are won at the **data-layer boundary** (the `AgentKeysClient` TS interface, the Rust↔TS type seam, the WASM `agentkeys-web-core`), which is framework-agnostic. + +## Current state (verified in-repo) +- Frontend: Next.js 14 App Router, React 18, TS, plain CSS. Used as a **client-only SPA** — no SSR/RSC/API-routes/file-routing (routing is a `useState` machine). No test runner at all. +- Data layer: `AgentKeysClient` interface (~15 methods) with `EmptyBackend` / `DaemonBackend` / `CoreBackend`, selected via `ClientProvider`/`useClient` (DI seam already exists). `Result` discriminated union. +- `daemon.ts` hand-declares ~10 `Api*` TS interfaces mirroring Rust `ui_bridge.rs` structs. **Only 1** (`ApiMemoryEntry`/plant) is gated by `check-web-api-drift.sh`. No Rust→TS codegen. +- **Three** Rust broker/worker clients: `agentkeys-backend-client` (daemon + mcp-server, #203), `agentkeys-web-core::broker::BrokerClient` (browser WASM), the CLI's own `reqwest`. The first two already drift: web-core `CapRequest.ttl_seconds: Option` vs backend-client `CapMintRequest.ttl_seconds: u64`. +- MCP server: tools take `Arc`; `HttpBackend` is a 74-line verbatim pass-through over `BackendClient`; `backend/mod.rs` re-exports backend-client's I/O types. `InMemoryBackend` (305 lines) is the **runtime** `--backend in-memory` mode (referenced only in `main.rs`). + +## Parity ladder (project's own framing, harness/CLAUDE.md) +1. Runtime behavioral assertion (run both, compare) — rots silently. +2. Shared fixture/golden contract — CI reddens on shape drift. +3. Shared implementation — one code path; drift is a compile error. +Operating rule: push every parity check *down* the ladder. + +## #213 direction (amended after adversarial review) +Remove the runtime `InMemoryBackend` and have MCP tools effectively call `agentkeys-backend-client`. **Keep the `Backend` trait** — it is the test seam, not dead polymorphism. + +### Verified (adversarial review, 2026-06-06) +- **The MCP test seam is `MockBackend`** in `crates/agentkeys-mcp-server/tests/common/mod.rs` (a test-only `impl Backend`, from #132), used by `http_auth.rs` / `three_acts.rs` / `schema_only_stubs.rs`. It is **independent of** the runtime `InMemoryBackend` (referenced only in `main.rs`). Deleting `InMemoryBackend` leaves test coverage untouched — **no removal gap** (refutes review finding 1). +- Because `MockBackend impl Backend`, the trait is **not** single-impl once tests count (HttpBackend prod + MockBackend test). Keep it; that mock is its reason to exist. +- The only genuine loss is the `--backend in-memory` manual dev loop — already banned from demo assertions by `harness/CLAUDE.md`, so acceptable. +- **`agentkeys-backend-client` is NOT wasm-safe** (confirms review finding 2): via `agentkeys-provisioner` it pulls `aws-config` + `aws-sdk-sts` + `tokio` + native `reqwest`. `protocol.rs`, however, is **pure serde** (imports only `serde`/`serde_json`) → cleanly splittable. + +### Two end states for the MCP backend +- **Option 1 (chosen — minimal, safe):** delete `InMemoryBackend` + the redundant `HttpBackend` wrapper by making `BackendClient` itself `impl Backend` (local trait + foreign type = allowed). Trait + `MockBackend` stay; tools get `Arc` = `BackendClient` (prod) / `MockBackend` (test). Zero removal gap. +- **Option 2 (optional, heavier, deferred):** fully collapse the trait to `Arc` and migrate tool tests to real `BackendClient` + mock HTTP for wire-contract coverage. Only this path requires a mock-HTTP harness to land first. + +### Principle +**Fake the transport, not the behavior.** Orchestration/UI → fake the interface (cheap; `MockBackend` / `FakeBackend`). Wire contract → fake only the transport (mock HTTP / MSW), run the real client against the shared golden fixture (Option 2 / the `daemon.ts` mapper tests). + +## Part A — testability +1. Add Vitest. +2. Fixture-driven mapper tests: run the **real** `DaemonBackend` (`apiToActor` etc.) with mocked `fetch` against `harness/fixtures/web-api/*.json`. (Highest ROI; doubles as rung-2 parity.) +3. `FakeBackend implements AgentKeysClient` + React Testing Library for component tests via the existing `ClientProvider`. +4. Playwright happy-path E2E on `dev:stack`. + +## Part B — parity (push down the ladder) +- **B0 — ✅ DONE (landed via #213, merged):** delete the runtime `InMemoryBackend`; remove the `HttpBackend` wrapper by making `BackendClient` `impl Backend`; **keep the trait** as the `MockBackend` test seam; drop the `--backend in-memory` flag value. (Option 2's full collapse + mock-HTTP is optional, deferred.) +- **B1 — ✅ DONE (this change):** extract `agentkeys-backend-client::protocol` (pure serde) into a standalone `agentkeys-protocol` crate. Both `backend-client` (native: + client + STS via `agentkeys-provisioner`) and `agentkeys-web-core` (wasm: + browser-fetch client) depend on **that**, never the native client. Kills the `ttl_seconds` drift with one shared type. **Do NOT make web-core depend on backend-client** — it drags in `aws-sdk-sts` + `tokio` + native `reqwest` and breaks the wasm build. Add a CI gate: `cargo check --target wasm32-unknown-unknown -p agentkeys-web-core`. +- **B2 — ✅ DONE (this change).** `ts-rs` derives on the 12 `ui_bridge.rs` `Api*` structs generate `apps/parent-control/lib/generated/*.ts`; `daemon.ts` imports them and the 5 hand-declared interfaces are deleted (−49 lines net). `u64`→`number` via `#[ts(type = "number")]`, skip-serialize `Option`s → `#[ts(optional)]`. CI gate: `cargo test --workspace` regenerates + `git diff --exit-code` on the generated dir (rung 2→3: a daemon struct rename is now a frontend compile error). Verified: `cargo test export_bindings` green + `npm run typecheck` clean (only pre-existing `core.ts` wasm-artifact errors, unrelated). Follow-up: the #207 `ApiProposedScope` + classify/credentials wire types are still hand-declared — next codegen batch. +- **B3 — ✅ assessed, no code needed.** The cap-mint *request* is unified (B1). The remaining candidates are non-drifts: (a) `CapToken` is opaque-by-design in backend-client (`type CapToken = Value`, forwarded transparently to workers — every site reads `cap.get("payload")`) vs a typed convenience view in web-core (`{payload, broker_sig}`) over **identical** wire bytes; web-core never re-serializes a cap and backend-client never introspects the typed shape, so forcing one type is ripple (MockBackend + client + daemon + 4 fixtures) for no real benefit. (b) Pairing is single-owned, not duplicated: web-core owns master-side (`claim`/`pending`/`ack`), the daemon owns agent-side (`request`/`poll`) — different endpoint sets. (c) A full client-impl merge (one `BrokerClient`) is unwarranted — native does STS + reqwest, browser does fetch-only. So the achievable, valuable type-sharing IS B1; the rest is documented as deliberately not-done. +- **B4 — ⛔ blocked on Part A.** The backend-protocol + web-api golden fixtures already exist and are gated Rust-side (#203). The net-new B4 work is having the *frontend tests* consume the same fixtures — which needs the Vitest harness (Part A). B2's generated types are the shared *type* contract; fixture-sharing for tests lands with Part A. +- **B5 — ✅ audited, no code needed.** The `agentkeys` CLI does NOT re-type the cap/worker wire protocol. Its `json!` bodies are MCP tool-call args (`call_tool("agentkeys.memory.get", …)`), hook stdin/stdout, and the SIWE/device-session auth surface (separate from the §203 cap/worker chain). Cap/worker bodies are owned by the MCP server via `BackendClient` (B1). Confirmed by grep of `crates/agentkeys-cli/src`. + +## Framework verdict +Keep Next.js. Framework choice is orthogonal to both goals (Vitest/RTL/ts-rs/web-core all framework-agnostic; the app uses zero Next server features). The one condition that would flip to Vite + TanStack Router: the "fold the UI into the daemon's `agentkeys web` subcommand as a static bundle, phone-first via the WASM CoreBackend" endgame becoming firm. Optionally adopt TanStack Query *on top of* Next.js for the data layer. + +## Sequencing (ROI order) +B0 (delete `InMemoryBackend`, `BackendClient impl Backend`, keep trait) → B1 extract `agentkeys-protocol` crate (+ wasm32 CI gate) → Vitest + `daemon.ts` mapper tests vs fixtures → ts-rs codegen → FakeBackend + RTL → web-core + daemon reuse shared core → Playwright. (Option 2 mock-HTTP tool tests optional, anytime after B0.) + +End state: one shared wire-protocol crate behind every consumer (MCP tools, daemon, web-core, CLI), with a native client (`backend-client`, + STS) and a wasm client (`web-core`, fetch-only) that cannot drift on shapes; every test fakes transport or interface, never re-implements behavior.