From f711e37a7aac1af976318ab6d5aac2f23d45ca9f Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 19 May 2026 17:20:29 -0400 Subject: [PATCH] =?UTF-8?q?mesh-llm:=20Max=20review=20fixups=20=E2=80=94?= =?UTF-8?q?=20expires=5Fat,=20EndpointAddr,=20same-relay=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Max's pre-PR review of d9a791f: 1. Add MeshLlmOffer.expires_at: u64 (unix seconds). Hard-required by serde (no default) since consumers depend on it for correctness: crashed publishers cannot send the NIP-33 delete-by-replace tombstone, so the TTL is the only thing that reaps stale offers. New helpers + tests in sprout-core::mesh_llm: - is_expired(now) returns true when expires_at <= now. - matches_local_relay(current_relay) compares against the relay's NIP-11 iroh_relay_url with lightweight canonicalisation (lower-case scheme/host, trailing-slash collapse, query/fragment drop). v1 invariant: one relay = one mesh boundary. - is_publishable now rejects expires_at == 0. - canonical_relay_url() is a small private helper, deliberately separate from sprout_auth::nip98_canonical_url (different jobs). 5 new tests (expires_at_required, is_expired_filter, matches_local_relay_canonicalises, is_publishable_rejects_zero_*, plus updated JSON fixtures throughout). 2. Fix doc wording: iroh NodeAddr -> EndpointAddr (rc.0 naming). Soften 'multiple relays bridging into membership scope' language to reflect that v1 is strictly single-relay and the field is reserved for future cross-relay only. 3. Desktop publisher (mesh_publish_offer) now computes expires_at = now + OFFER_TTL_SECS (15 min) and threads it through build_offer(endpoint_id, iroh_relay_url, expires_at). The frontend hook is expected to re-invoke publish on heartbeat well before the deadline (heartbeat plumbing is in the next commit). 4. Frontend useMeshLlmOffers hook: - probes the relay's NIP-11 iroh_relay_url once on mount and filters offers whose iroh_relay_url doesn't match (v1 same-relay invariant). When the relay doesn't advertise mesh-LLM, the filter correctly empties the list. - rejects events with schema v != 1 before storing. - 30s setInterval ticks 'now' so expired offers drop without waiting for a new event. O(1) timer regardless of how many offers are in the cache. - mirrors the canonicaliser logic from sprout-core in TS so JS-side accept/reject decisions match the Rust check. Tests after change: - sprout-core --lib: 174 -> 178 (+4 mesh_llm) - desktop mesh_llm --lib: 11 unchanged (publisher signature change required updating build_offer call sites; tests pass exact-same set). - pnpm typecheck + pnpm check (biome + file-sizes): clean. - cargo clippy --workspace -- -D warnings: clean. - cargo fmt --all -- --check: clean. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- crates/sprout-core/src/mesh_llm.rs | 150 +++++++++++++++++- desktop/src-tauri/src/commands/mesh_llm.rs | 11 +- desktop/src-tauri/src/mesh_llm/offer.rs | 20 ++- .../settings/hooks/useMeshLlmOffers.ts | 100 +++++++++++- 4 files changed, 264 insertions(+), 17 deletions(-) diff --git a/crates/sprout-core/src/mesh_llm.rs b/crates/sprout-core/src/mesh_llm.rs index 72ec9568d1..b4293bccc9 100644 --- a/crates/sprout-core/src/mesh_llm.rs +++ b/crates/sprout-core/src/mesh_llm.rs @@ -61,20 +61,35 @@ pub struct MeshLlmOffer { /// Iroh endpoint id (ed25519 public key, base32 z-base form as iroh /// renders it) of the offering node's iroh endpoint. Consumers dial - /// this through an iroh `NodeAddr`. + /// this through an iroh `EndpointAddr` constructed from + /// `(endpoint_id, iroh_relay_url)`. pub endpoint_id: String, /// Iroh relay URL through which the offering endpoint is reachable. /// /// This is the *Sprout-hosted* iroh-relay URL — copied verbatim from - /// the publisher's view of NIP-11 `iroh_relay_url`. If multiple Sprout - /// relays are bridged into the same membership scope in the future, - /// this lets a consumer reach an offer behind a different host. + /// the publisher's view of NIP-11 `iroh_relay_url`. The field is + /// preserved for future cross-relay bridging, but **v1 consumers MUST + /// ignore offers whose `iroh_relay_url` doesn't match the current + /// relay's NIP-11 `iroh_relay_url`** (see [`Self::matches_local_relay`]). + /// This keeps "one relay = one mesh boundary" as an enforced invariant + /// until cross-relay membership is explicitly designed. pub iroh_relay_url: String, + /// Unix-seconds timestamp at which this offer becomes stale. Consumers + /// MUST ignore offers where `expires_at <= now` (publishers SHOULD + /// republish a fresh offer well before this deadline to act as a + /// heartbeat). Because crashed publishers cannot send the NIP-33 + /// delete-by-replace tombstone, this TTL is the only thing that + /// removes their offers from the consumer view. + pub expires_at: u64, + /// Resource caps the offering side promises to honour for any single /// consumer at a time. The publisher should re-publish (replacing the - /// previous event) whenever these change materially. + /// previous event) whenever these change materially. **These are + /// claims/UI hints, not authority** — the provider runtime must + /// enforce its own caps locally; the consumer cannot rely on the + /// publisher to honour them at admission time. pub caps: ResourceCaps, /// Models this node is willing to serve. Empty list = "negotiate at @@ -157,12 +172,83 @@ impl MeshLlmOffer { /// /// This is a *publisher-side* sanity check; consumers should be /// permissive in what they accept as long as serde-deserialization - /// succeeds. + /// succeeds (modulo the [`Self::is_expired`] and + /// [`Self::matches_local_relay`] filters below). pub fn is_publishable(&self) -> bool { self.v == 1 && Self::is_valid_d_tag(&self.d_tag) && !self.endpoint_id.is_empty() && !self.iroh_relay_url.is_empty() + && self.expires_at > 0 + } + + /// Consumer-side TTL check. Returns `true` when `expires_at <= now`, + /// in which case the offer must be ignored (crashed publishers can't + /// send a delete-by-replace tombstone; the TTL is the only reaper). + /// + /// `now` is unix-seconds — callers in this crate pass `Timestamp::now()` + /// or a test clock to avoid pulling `std::time::SystemTime` into the + /// trust path. + pub fn is_expired(&self, now: u64) -> bool { + self.expires_at <= now + } + + /// Consumer-side same-relay filter for v1 discovery. Returns `true` + /// when the offer's advertised `iroh_relay_url` matches `current_relay` + /// after canonicalisation (lower-case scheme/host, trailing slash on + /// path collapsed, query/fragment dropped). + /// + /// **v1 consumers MUST ignore offers where this returns `false`.** The + /// invariant is "one relay = one mesh boundary"; cross-relay bridging + /// is reserved for a future explicit design. + pub fn matches_local_relay(&self, current_relay: &str) -> bool { + canonical_relay_url(&self.iroh_relay_url) == canonical_relay_url(current_relay) + } +} + +/// Lightweight URL canonicaliser used only for the same-relay filter. Not +/// to be confused with [`sprout_auth::nip98_canonical_url`], which has a +/// different job (computing the `u`-tag value); this one just strips +/// query/fragment, lower-cases scheme/host, and collapses one trailing +/// slash so users who paste `https://r.example.com/iroh/` see it match +/// `https://r.example.com/iroh`. +fn canonical_relay_url(raw: &str) -> String { + let trimmed = raw.trim(); + // Split off query + fragment. Compose the splits in two steps so the + // second split operates on the result of the first, not on `trimmed`. + let no_query = match trimmed.split_once('?') { + Some((h, _)) => h, + None => trimmed, + }; + let no_frag = match no_query.split_once('#') { + Some((h, _)) => h, + None => no_query, + }; + let head = if let Some(stripped) = no_frag.strip_suffix('/') { + // Only strip *one* trailing slash — don't collapse repeated + // slashes (those are real path components). + stripped + } else { + no_frag + }; + + // Lower-case the scheme+authority portion; leave the path case-sensitive. + if let Some(idx) = head.find("://") { + let (scheme, rest) = head.split_at(idx); + // rest starts with "://"; authority ends at next '/'. + let after_proto = &rest[3..]; + let (authority, path) = match after_proto.find('/') { + Some(p) => after_proto.split_at(p), + None => (after_proto, ""), + }; + format!( + "{}://{}{}", + scheme.to_ascii_lowercase(), + authority.to_ascii_lowercase(), + path, + ) + } else { + head.to_string() } } @@ -176,6 +262,7 @@ mod tests { d_tag: "node-1".to_string(), endpoint_id: "1234abcd".to_string(), iroh_relay_url: "https://relay.example.com/iroh".to_string(), + expires_at: 2_000_000_000, // far-future fixture caps: ResourceCaps { max_vram_mb: Some(24_000), max_ram_mb: Some(64_000), @@ -205,6 +292,7 @@ mod tests { "d_tag": "x", "endpoint_id": "abc", "iroh_relay_url": "https://r/", + "expires_at": 2000000000, "caps": {} }"#; let offer: MeshLlmOffer = serde_json::from_str(s).expect("deserialise minimal"); @@ -222,6 +310,7 @@ mod tests { "d_tag": "x", "endpoint_id": "abc", "iroh_relay_url": "https://r", + "expires_at": 2000000000, "caps": {}, "wat": "lol" }"#; @@ -235,11 +324,60 @@ mod tests { "d_tag": "x", "endpoint_id": "abc", "iroh_relay_url": "https://r", + "expires_at": 2000000000, "caps": { "wat": 7 } }"#; assert!(serde_json::from_str::(s).is_err()); } + #[test] + fn expires_at_required() { + // expires_at has no serde default; missing it is a hard error + // (consumers depend on TTL for correctness). + let s = r#"{ + "v": 1, + "d_tag": "x", + "endpoint_id": "abc", + "iroh_relay_url": "https://r", + "caps": {} + }"#; + assert!(serde_json::from_str::(s).is_err()); + } + + #[test] + fn is_expired_filter() { + let mut offer = sample(); + offer.expires_at = 1_000; + assert!(offer.is_expired(2_000), "now > expires_at must expire"); + assert!(offer.is_expired(1_000), "now == expires_at must expire"); + assert!(!offer.is_expired(999), "now < expires_at must not expire"); + } + + #[test] + fn matches_local_relay_canonicalises() { + let mut offer = sample(); + offer.iroh_relay_url = "https://relay.example.com/iroh".to_string(); + // exact match + assert!(offer.matches_local_relay("https://relay.example.com/iroh")); + // trailing slash on one side + assert!(offer.matches_local_relay("https://relay.example.com/iroh/")); + // upper-case host + assert!(offer.matches_local_relay("HTTPS://Relay.Example.COM/iroh")); + // query/fragment stripped + assert!(offer.matches_local_relay("https://relay.example.com/iroh?x=1#y")); + // different host -> reject + assert!(!offer.matches_local_relay("https://other.example.com/iroh")); + // different path -> reject (different mesh boundary) + assert!(!offer.matches_local_relay("https://relay.example.com/other")); + } + + #[test] + fn is_publishable_rejects_zero_expires_at() { + let mut offer = sample(); + offer.expires_at = 0; + assert!(!offer.is_publishable()); + } + #[test] fn extra_freeform_passes_through() { let offer = MeshLlmOffer { diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 321d77889b..014dcabf97 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -119,8 +119,17 @@ pub async fn mesh_publish_offer( let d_tag_tag = Tag::parse(["d", &d_tag]).map_err(|e| format!("d tag: {e}"))?; let (content, published_offer) = if prefs.enabled { + // expires_at = now + OFFER_TTL_SECS. The frontend hook re-invokes + // mesh_publish_offer on a heartbeat well before the deadline; if + // the publisher crashes before the next heartbeat, consumers reap + // the offer once `now > expires_at` (see MeshLlmOffer::is_expired). + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| format!("system clock: {e}"))? + .as_secs(); + let expires_at = now + mesh_llm::offer::OFFER_TTL_SECS; let offer = prefs - .build_offer(&endpoint_id_str, &iroh_relay_url) + .build_offer(&endpoint_id_str, &iroh_relay_url, expires_at) .ok_or_else(|| { "build_offer returned None despite enabled=true (logic bug)".to_string() })?; diff --git a/desktop/src-tauri/src/mesh_llm/offer.rs b/desktop/src-tauri/src/mesh_llm/offer.rs index 441b0accd4..03563cfa18 100644 --- a/desktop/src-tauri/src/mesh_llm/offer.rs +++ b/desktop/src-tauri/src/mesh_llm/offer.rs @@ -75,10 +75,15 @@ impl ComputeSharingPrefs { /// Builds the kind:31990 offer envelope to publish. Returns `None` if /// sharing is disabled; the publisher should then *delete* any prior /// offer rather than calling this. + /// + /// `expires_at` is the unix-seconds deadline after which consumers will + /// drop this offer. Callers pass `now + OFFER_TTL_SECS` so an + /// unannounced publisher crash naturally reaps the stale offer. pub fn build_offer( &self, endpoint_id: &str, iroh_relay_url: &str, + expires_at: u64, ) -> Option { if !self.enabled { return None; @@ -88,6 +93,7 @@ impl ComputeSharingPrefs { d_tag: self.d_tag.clone(), endpoint_id: endpoint_id.to_string(), iroh_relay_url: iroh_relay_url.to_string(), + expires_at, caps: self.caps.clone(), models: self.models.clone(), extra: None, @@ -95,6 +101,11 @@ impl ComputeSharingPrefs { } } +/// Default offer TTL: 15 minutes. Publishers republish on each prefs +/// change *and* should heartbeat at ~`OFFER_TTL_SECS/3` so a one-missed +/// heartbeat still leaves the offer visible. +pub const OFFER_TTL_SECS: u64 = 15 * 60; + fn prefs_path(app: &AppHandle) -> Result { let data_dir = app .path() @@ -149,7 +160,7 @@ mod tests { let prefs = ComputeSharingPrefs::default(); assert!( prefs - .build_offer("endpoint", "https://relay/iroh") + .build_offer("endpoint", "https://relay/iroh", 2_000_000_000) .is_none() ); } @@ -161,10 +172,15 @@ mod tests { ..Default::default() }; let offer = prefs - .build_offer("endpoint-id-hex", "https://relay.example.com/iroh") + .build_offer( + "endpoint-id-hex", + "https://relay.example.com/iroh", + 2_000_000_000, + ) .expect("offer"); assert_eq!(offer.endpoint_id, "endpoint-id-hex"); assert_eq!(offer.iroh_relay_url, "https://relay.example.com/iroh"); + assert_eq!(offer.expires_at, 2_000_000_000); assert!(offer.is_publishable()); } diff --git a/desktop/src/features/settings/hooks/useMeshLlmOffers.ts b/desktop/src/features/settings/hooks/useMeshLlmOffers.ts index e93103b9ef..6eba832e28 100644 --- a/desktop/src/features/settings/hooks/useMeshLlmOffers.ts +++ b/desktop/src/features/settings/hooks/useMeshLlmOffers.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; import { relayClient } from "@/shared/api/relayClient"; import type { RelayEvent } from "@/shared/api/types"; @@ -12,6 +13,8 @@ export interface MeshLlmOffer { d_tag: string; endpoint_id: string; iroh_relay_url: string; + /** Unix-seconds deadline; consumers ignore offers where `expires_at <= now`. */ + expires_at: number; caps: { max_vram_mb?: number | null; max_ram_mb?: number | null; @@ -44,14 +47,50 @@ function extractDTag(event: RelayEvent): string | null { return null; } +/** + * Canonicalise a relay URL the same way `sprout_core::mesh_llm`'s same-relay + * filter does, so the JS side can't accept an offer the Rust schema check + * would reject. (See `MeshLlmOffer::matches_local_relay` in core.) + */ +function canonicalRelayUrl(raw: string): string { + const trimmed = raw.trim(); + const noQuery = trimmed.split("?")[0] ?? trimmed; + const noFrag = noQuery.split("#")[0] ?? noQuery; + const noTrail = noFrag.endsWith("/") ? noFrag.slice(0, -1) : noFrag; + const protoIdx = noTrail.indexOf("://"); + if (protoIdx === -1) return noTrail; + const scheme = noTrail.slice(0, protoIdx).toLowerCase(); + const rest = noTrail.slice(protoIdx + 3); + const slash = rest.indexOf("/"); + if (slash === -1) { + return `${scheme}://${rest.toLowerCase()}`; + } + const authority = rest.slice(0, slash).toLowerCase(); + const path = rest.slice(slash); + return `${scheme}://${authority}${path}`; +} + +/** + * How often (in ms) the hook recomputes the rendered list so freshly + * expired offers drop without waiting for a new event to arrive. Tied to a + * setInterval rather than per-offer setTimeouts so the cost is O(1) no + * matter how many offers are in the cache. + */ +const EXPIRY_TICK_MS = 30_000; + /** * Subscribe to live mesh-LLM offers from the connected relay. * * Returns the de-duplicated set of *currently-active* offers (keyed by - * `(pubkey, d_tag)` per NIP-33). An event with empty `content` is treated - * as 'offer withdrawn' and removes the corresponding entry — this is the - * NIP-33 delete-by-replace idiom the Rust publisher emits when the user - * toggles compute-sharing off. + * `(pubkey, d_tag)` per NIP-33), filtered to: + * - the current relay's iroh-relay URL (v1 invariant: one relay = one mesh + * boundary). Offers advertising a different `iroh_relay_url` are dropped. + * - non-expired offers (`expires_at > now`). Crashed publishers can't send + * the NIP-33 delete-by-replace tombstone; the TTL is the reaper. + * + * An event with empty `content` is treated as 'offer withdrawn' and removes + * the corresponding entry — this is the NIP-33 delete-by-replace idiom the + * Rust publisher emits when the user toggles compute-sharing off. */ export function useMeshLlmOffers(): { offers: ResolvedOffer[]; @@ -59,6 +98,39 @@ export function useMeshLlmOffers(): { } { const [offers, setOffers] = useState>(new Map()); const [error, setError] = useState(null); + const [localRelay, setLocalRelay] = useState(null); + const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000)); + + // Discover the current relay's iroh_relay_url once so we can filter + // offers against it. If the relay doesn't advertise one, the same-relay + // filter rejects everything and the panel correctly shows the empty + // state. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const ws = await invoke("get_relay_ws_url"); + const irohUrl = await invoke("mesh_relay_iroh_url", { + relayWsUrl: ws, + }); + if (!cancelled) setLocalRelay(irohUrl ?? null); + } catch (e) { + if (!cancelled) setError(String(e)); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + // Periodic re-tick so expired offers fall out of the rendered list even + // when no new events arrive. + useEffect(() => { + const id = setInterval(() => { + setNowSec(Math.floor(Date.now() / 1000)); + }, EXPIRY_TICK_MS); + return () => clearInterval(id); + }, []); useEffect(() => { let cancelled = false; @@ -89,6 +161,9 @@ export function useMeshLlmOffers(): { // poison the list. return; } + // Reject obviously-bad schema versions before storing. + if (parsed.v !== 1) return; + setOffers((prev) => { const existing = prev.get(key); if (existing && existing.createdAt >= event.created_at) { @@ -125,9 +200,18 @@ export function useMeshLlmOffers(): { }; }, []); - // Sort newest first for the UI. - const list = Array.from(offers.values()).sort( - (a, b) => b.createdAt - a.createdAt, - ); + // Filter on every render so newly-expired offers drop without a refresh + // event, and so the same-relay filter applies as soon as the NIP-11 + // probe completes. + const localCanonical = + localRelay != null ? canonicalRelayUrl(localRelay) : null; + const list = Array.from(offers.values()) + .filter((entry) => entry.offer.expires_at > nowSec) + .filter((entry) => { + if (localCanonical == null) return false; + return canonicalRelayUrl(entry.offer.iroh_relay_url) === localCanonical; + }) + .sort((a, b) => b.createdAt - a.createdAt); + return { offers: list, error }; }