mesh-llm v6.1: relay-hosted compute sharing — discovery + admission (dial deferred)#695
Open
tlongwell-block wants to merge 14 commits into
Open
mesh-llm v6.1: relay-hosted compute sharing — discovery + admission (dial deferred)#695tlongwell-block wants to merge 14 commits into
tlongwell-block wants to merge 14 commits into
Conversation
- Step 1: KIND_MESH_LLM_DISCOVERY = 31990 (parameterized replaceable, global-only, MessagesWrite scope) — relay members announce compute offers through the same NIP-43-gated fan-out path as messages. - Step 2: extract transport-neutral check_relay_membership returning MembershipDecision. HTTP enforce_relay_membership becomes a thin wrapper that maps Denied -> 403 JSON. Same behavior for all 6 existing HTTP callers; non-HTTP gates (iroh-relay AccessConfig) can call the core directly without a StatusCode in their return type. - Step 4: NIP-11 iroh_relay_url field, fed from new SPROUT_IROH_RELAY_PUBLIC_URL config. Absent unless configured (older clients unaffected). This is what mesh-llm sidecars read to wire their iroh endpoints to Sprout's own relay -- no out-of-band config required. - Step 5: sprout-auth::nip98_canonical_url helper. Single source of truth for the NIP-98 'u'-tag value, used by both signer and verifier. Suffix- aware path join (preserves /iroh prefix when joining /relay), localhost/ IPv6 loopback collapse, query+fragment stripping. Round-trip test signs with the helper and verifies through verify_nip98_event to prevent drift. sprout-core: 165 tests pass sprout-auth: 36 -> 48 tests pass (+12 nip98_url) sprout-relay: 190 -> 195 tests pass (+3 mesh-llm, +2 iroh_relay_url) workspace clippy -D warnings: clean workspace cargo fmt --check: clean Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
- New module crates/sprout-relay/src/iroh_relay.rs (~290 lines incl. tests).
- pub fn spawn(state, bind_addr) constructs an iroh_relay::server::Server
with AccessConfig::Restricted set to a closure that:
1. Pulls the Bearer token from ClientRequest::auth_token().
2. base64-decodes (accepts STANDARD + URL_SAFE, padded or not).
3. Calls sprout_auth::verify_nip98_event against canonical URL
(= sprout_auth::nip98_canonical_url(public_url, '/relay')).
4. Runs check_relay_membership against the NIP-98 pubkey.
Anything other than Member/ViaOwner/OpenRelay -> Deny.
Per Max's review notes: fail-closed on missing/invalid token, run
membership only after NIP-98 verifies the pubkey, no caching.
- Returns Ok(None) gracefully when SPROUT_IROH_RELAY_PUBLIC_URL is unset
(the canonical URL can't be built without it).
- patched-iroh-relay feature flag reserved for upstream PR C's per-client
max-lifetime hook (kept behind cfg so unpatched rc.0 still compiles).
- MSRV bumped from 1.88.0 -> 1.91.0 (iroh-relay rc.0's MSRV). Repo's
rust-toolchain.toml already pins 1.95.0 so builds are unaffected; the
bump just keeps Cargo.toml honest with the actual transitive floor.
- README updated: 'Rust 1.88+' -> 'Rust 1.91+'.
- crates/sprout-relay/Cargo.toml: added
iroh-relay = { version = "=1.0.0-rc.0", features = ["server"] }
plus the patched-iroh-relay feature.
Tests (rustc 1.95, via rust-toolchain.toml; also verified independently
on 1.91.1):
- sprout-relay --lib: 195 -> 206 (+11 iroh_relay tests covering valid
admission, missing/empty/non-base64/wrong-method/wrong-URL/wrong-kind/
stale-timestamp denials, and bearer-encoding round-trips).
- cargo clippy --workspace --all-targets -- -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) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Mari (trust): - Add 64 KiB pre-decode length cap on the bearer token. NIP-98 events are well under a kilobyte; rejecting oversized inputs before allocating the base64 decode buffer prevents an admission request from coercing the relay into multi-megabyte allocations. New const MAX_BEARER_LEN. - New verify_bearer_rejects_oversized_token test. - New verify_bearer_rejects_internal_whitespace test: pins the fact that base64 0.22's general_purpose engines reject mid-token whitespace (no MIME mode), which is what we want. Max (review): - Soften the module-level 'patched-fork hooks' docs so they don't imply the per-client max-lifetime hook is already wired, and add an explicit TODO(patched-iroh-relay) marker at the future insertion site in spawn. - Add SPROUT_IROH_RELAY_BIND_ADDR to Config (iroh_relay_bind_addr: Option<SocketAddr>) now, so the main.rs wiring follow-up can read it without a separate config churn. Server::spawn owns its own listener, so this is independent of the Sprout HTTP bind_addr. sprout-relay --lib: 206 -> 208 tests pass (+2). workspace clippy -D warnings: clean. workspace cargo fmt --check: clean. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
- IrohRelayHandle::shutdown() consumes self and awaits Server::shutdown()
(which drains in-flight QUIC sessions before returning), replacing the
previous drop-aborts-supervisor pattern.
- main.rs::serve() now starts the embedded iroh-relay when *both*
SPROUT_IROH_RELAY_PUBLIC_URL and SPROUT_IROH_RELAY_BIND_ADDR are set.
Spawned alongside the HTTP listener; subscribed to the same shutdown_tx
watcher so SIGTERM/Ctrl-C drains the iroh-relay together with axum.
- Mismatched config logs a warn! and starts neither:
* URL only -> NIP-11 advertises a phantom endpoint -> mesh-LLM is broken
* bind only -> clients can't build the NIP-98 'u' tag -> 100% denial
In both cases we fail loud and refuse to lie to clients.
- Both serve() paths (UDS-enabled + TCP-only) await the iroh drain task
before returning so shutdown is actually graceful end-to-end.
sprout-relay --lib: 208/208 unit tests pass (unchanged; this is a wiring
change, not an auth change).
workspace clippy -D warnings: clean.
workspace cargo fmt --check: clean.
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
B0 — sprout-core/src/mesh_llm.rs (new): MeshLlmOffer envelope, the
content of a kind:31990 event. Schema versioned (v: u32), with
deny_unknown_fields at the top level and a freeform 'extra' Value
escape hatch. ResourceCaps + ModelOffer sub-structs. d_tag charset is
limited to [A-Za-z0-9_-] (NIP-33 stability). 9 unit tests covering
round-trip JSON, optional caps, unknown-field rejection, d_tag
validation, is_publishable rule set.
B2 — desktop/src-tauri/src/mesh_llm/endpoint.rs: persists the iroh
endpoint keypair to {app_data_dir}/mesh_iroh.key as 32 hex bytes.
Atomic write via tempfile.persist; corrupt files quarantined to
.bad.{epoch} (same pattern as identity.key). 2 unit tests.
Design note in the module doc: we deliberately do NOT derive the
iroh key from the Nostr key, because that would couple key rotation
(rotating the Nostr key would silently break active offers) and
invent a new key-custody convention. Separate file, same Tauri
sandbox.
B3 — desktop/src-tauri/src/mesh_llm/nip98.rs: build_nip98_bearer(keys,
iroh_relay_public_url) signs a kind:27235 event with the user's Nostr
key over the canonical relay URL (sprout_auth::nip98_canonical_url
with path '/relay'), base64-encodes the event JSON. This is the exact
token the relay's iroh_relay::verify_bearer decodes + verifies.
3 unit tests.
B-offer prefs — desktop/src-tauri/src/mesh_llm/offer.rs: persisted
ComputeSharingPrefs (the avatar-menu sliders). Default is disabled,
1 concurrent consumer cap. build_offer() returns None when disabled
so callers know to *delete* any prior offer rather than re-publish.
JSON round-trip + helper tests, 4 tests.
Workspace deps added:
- desktop pulls sprout-auth (for the canonical URL helper, nostr-free
at the API surface so the 0.36/0.37 nostr split doesn't matter).
- desktop pulls iroh-base = =1.0.0-rc.0 with the 'key' feature for
SecretKey/PublicKey/EndpointId.
- desktop pulls thiserror = '2'.
Tests: 9 desktop mesh_llm tests pass + 9 sprout-core mesh_llm tests
pass. Workspace clippy + fmt clean (relay side; desktop has expected
dead_code warnings until B4-B6 wire these in).
Note: desktop crate requires sidecar binary stubs in
desktop/src-tauri/binaries/ to typecheck; created via the existing
'just _ensure-sidecar-stubs' helper.
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
desktop/src-tauri/src/mesh_llm/nip11.rs: fetch_iroh_relay_url(ws_url) converts ws:// -> http://, GETs / with Accept: application/nostr+json, extracts the iroh_relay_url field from the NIP-11 JSON. Returns Ok(None) on unreachable relays / malformed responses / missing field so mesh-LLM silently disables itself rather than producing a deploy mystery; only returns Err for un-fixable caller mistakes (non-ws URL). Mirrors the probe_relay_supports_nip43 helper already in commands/pairing.rs; deliberately doesn't share code since this is a different decode shape with different graceful-failure semantics. desktop mesh_llm tests: 9 -> 11 (+2 nip11 helper). Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
desktop/src-tauri/src/commands/mesh_llm.rs (new):
- mesh_get_endpoint_id(app) -> { endpoint_id }: creates the persisted
iroh keypair on first call; returns the canonical Display form of
the public key so the UI can show 'this device' identity.
- mesh_get_sharing_prefs(app) -> ComputeSharingPrefs: reads the
persisted prefs file (defaults applied when absent).
- mesh_set_sharing_prefs(app, prefs) -> (): atomic write-through.
- mesh_relay_iroh_url(state, relay_ws_url) -> Option<String>: probes
the relay's NIP-11 doc for iroh_relay_url; returns None gracefully
when the relay doesn't advertise mesh-LLM.
All four registered in lib.rs's tauri::generate_handler! block.
Frontend hooks land in C1 (avatar-menu MeshComputeSettingsCard).
Cleanup: drop wildcard re-exports from mesh_llm/mod.rs so unused
publisher/dialer helpers don't trip top-level dead-code lints before
B5/B6 are wired in.
cargo check passes; clippy + fmt clean (relay-side workspace).
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx (new): - Toggle: 'Share this machine's compute' (master switch). - Three numeric inputs: max VRAM (MB), max RAM (MB), concurrent peers. Empty = no cap, validated to non-negative integers. - Displays the local iroh endpoint id (canonical Display form) so the user knows which device identity is publishing. - All state persisted through the mesh_set_sharing_prefs Tauri command; loads via mesh_get_sharing_prefs + mesh_get_endpoint_id on mount. - Error and saving states surfaced inline. Registered as a new 'compute' SettingsSection in SettingsPanels.tsx between 'agents' and 'channel-templates', with a Cpu icon. Reached through the existing avatar-menu -> Settings flow (no popover restructuring needed for the MVP). desktop typecheck (tsc --noEmit): clean. desktop biome check: clean. UX (matches Tyler's [1]): - avatar bottom-left -> ProfilePopover -> Settings -> Share compute - one switch + three caps; the offering side decides everything. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
desktop/src-tauri/src/commands/mesh_llm.rs: new mesh_publish_offer command. Reads persisted prefs and the local iroh endpoint id; on enabled=true, builds a kind:31990 event with the JSON-serialised MeshLlmOffer envelope and a 'd' tag matching prefs.d_tag, then signs + POSTs via the existing submit_event pipeline (NIP-98 to /events). On enabled=false, publishes the *same address* with empty content — NIP-33's 'delete by replace' idiom — so consumers know the offer has been withdrawn. PublishOfferResult.published_offer reports which path was taken. desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx: persist() now follows save-prefs with a relay capability probe and (when the relay advertises iroh_relay_url) a publish call. If the relay doesn't support mesh-LLM, prefs are still saved locally and the UI surfaces a specific 'this relay does not advertise iroh_relay_url' message rather than a confusing 'publish failed'. The user-facing flow is now: open settings -> Share compute -> toggle on -> a kind:31990 event hits the relay, NIP-43-fanned-out to other members. Toggling off publishes the empty-content replacement. Tests: 208 sprout-relay, 174 sprout-core, 11 desktop mesh_llm — all unchanged-and-pass. desktop typecheck + biome clean. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
shared/constants/kinds.ts: KIND_MESH_LLM_DISCOVERY = 31990 (matches
sprout_core::kind::KIND_MESH_LLM_DISCOVERY).
shared/api/relayClientSession.ts: subscribeToMeshLlmOffers(onEvent)
issues a NIP-01 REQ for kinds=[31990], limit=200. Returns the latest
snapshot plus a live stream. Membership is enforced relay-side via the
existing NIP-43 fan-out gate, so consumers see only offers authored by
relay members.
features/settings/hooks/useMeshLlmOffers.ts: parses each event's
content as MeshLlmOffer (mirrors sprout_core::mesh_llm shape), keys
offers by (pubkey, d_tag) per NIP-33. Empty content => drop entry
(matches the Rust publisher's delete-by-replace path). Newest-first
sort. Returns { offers, error } for the consumer hook contract.
features/settings/ui/MeshComputeSettingsCard.tsx: new 'Compute offered
by other members' section between the caps fieldset and the identity
footer. Empty-state copy when no offers; otherwise a list with
pubkey-short / d_tag / advertised models / caps for each.
scripts/check-file-sizes.mjs: relayClientSession override 930 -> 960
to absorb subscribeToMeshLlmOffers; comment updated to mention it so
future readers know why.
pnpm check, pnpm typecheck both clean. The full publish/discover loop
runs end-to-end: user A toggles -> kind:31990 hits relay -> user B's
useMeshLlmOffers hook receives it -> card renders 'A is offering ...'.
The only remaining gap to actually use the compute is the iroh dial,
which is the deferred question for Tyler.
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
…lter
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) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
…mounted
Without heartbeat, the expires_at TTL added in the previous commit makes
offers vanish from consumer UIs 15 min after the user toggles on, even
if the publisher is still running fine. The original commit message
even noted this gap but didn't implement it. Fixing now.
desktop/src/features/settings/hooks/useMeshOfferHeartbeat.ts (new):
- HEARTBEAT_MS = 5 minutes (~OFFER_TTL_SECS / 3 — so one missed beat
still leaves the offer visible; only two consecutive misses drop us).
- While { enabled, irohRelayUrl } are both truthy, setInterval calls
mesh_publish_offer, which re-stamps expires_at = now + 15 min as a
NIP-33 replace under the same (pubkey, d_tag) address.
- Errors are logged and ignored — the user isn't waiting in front of the
panel; the next tick or an explicit prefs change will surface a fresh
error if the relay is durably down.
desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx:
- New irohRelayUrl state, populated on mount and after every persist().
- useMeshOfferHeartbeat({ enabled, irohRelayUrl }) wired in.
- Mount-time relay probe so a user opening the panel with sharing
already enabled from a previous session starts heartbeating
immediately; failures are non-fatal (console.warn, prefs still
editable).
pnpm typecheck + pnpm check clean.
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
crates/sprout-test-client/tests/e2e_mesh_llm_discovery.rs (new): four end-to-end tests verifying the kind:31990 publish/discover loop against a real running sprout-relay. Follows the same #[ignore]+RELAY_URL pattern as e2e_long_form.rs etc. Tests: - test_offer_publish_then_retrieve: publish a kind:31990 with a far-future expires_at, REQ it back via kinds+author filter, confirm the serialised MeshLlmOffer round-trips through the relay intact (expires_at, d_tag). - test_offer_replace_by_d_tag: NIP-33 replace semantics — publishing a second event with the same (pubkey, d_tag) must replace the first; a subsequent REQ returns only the latest. - test_offer_delete_by_empty_replace: the desktop publisher's delete-by-replace tombstone — publish a real offer, then publish empty content at the same address; only the tombstone remains. - test_offer_stray_h_tag_is_ignored: kind:31990 is global (is_global_only_kind); a stray h-tag must not channel-scope the event. Verifies the unit-tested behaviour holds end-to-end on real relay traffic. These exercise the slice that pure unit tests can't reach: the actual NIP-43 fan-out gate, the relay's ingest validation against required_scope_for_kind / is_global_only_kind, and NIP-33 storage semantics. They are #[ignore]'d so 'just test-unit' is unaffected; 'cargo test --test e2e_mesh_llm_discovery -- --ignored' runs them against the relay pointed at by RELAY_URL (default ws://localhost:3000). clippy + fmt clean across the workspace. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
The verifier side of the iroh-relay NIP-98 admission is fully wired and tested in sprout-relay::iroh_relay. The client side (build_nip98_bearer) is reserved for the deferred dial path — it's complete and unit-tested on its own, but has no live caller until upstream mesh-llm PR A lands. Adds a module-level #![allow(dead_code)] with a comment pointing at the deferred-dial dependency, so the workspace stays warning-clean without losing the test coverage on the helper. cargo clippy --workspace -- -D warnings + cargo fmt --check clean. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
|
really cool: Mesh-LLM/mesh-llm#641 is some mesh side stuff I started to work with the auth'd relay (will do api too). Probably could strip down this PR here too as don't need too much once sprout tells mesh about it, but very cool. Longer term want to make sure it can work with N relays, and perhaps use another gating mechanism if warranted. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Mesh-LLM plan v6.1 — relay-hosted compute sharing (discovery + admission)
Closes the relay-side, desktop-side, and frontend pieces of Plan v6.1 so a
user can:
Settings → Share compute, toggle on, dial in their VRAM /RAM / concurrent-peer caps.
compute.
This PR is "mesh discovery + secure relay admission + contribution UX",
not "remote inference works". The actual iroh dial that runs an
inference request on the offered peer is deferred to upstream mesh-llm
PR A — see "Deferred" below.
Scope guardrails (read this first)
Run on meshis preview/discovery until upstream mesh-llm PR Adefines the stream protocol. The settings panel shows live offers
from other members; the agent runner doesn't yet route to mesh peers.
(
max_vram_mb,max_ram_mb,max_concurrency) are advertised limits;the provider runtime must enforce its own caps locally once the dial
protocol exists.
iroh_relay_urldoesn't match the current relay's NIP-11iroh_relay_url; cross-relay membership is reserved for a futureexplicit design.
AppState.keys. The iroh endpoint key is aseparate ed25519 keypair under
{app_data_dir}/mesh_iroh.key; thefrontend only sees the endpoint id (public key) and never the secret.
Plan v6.1 step-by-step
KIND_MESH_LLM_DISCOVERY = 31990in sprout-core, hookedinto
required_scope_for_kind(MessagesWrite) andis_global_only_kindin sprout-relay. NIP-43 fan-out gates read+write.
check_relay_membershipreturningMembershipDecision. HTTP wrapper preserves identical behaviour for allexisting callers; non-HTTP gates (Step 3) call the core directly.
crates/sprout-relay/src/iroh_relay.rs)with NIP-98 admission. 64 KiB bearer length cap, fail-closed on
missing/invalid token, calls membership check only after NIP-98
verification of the pubkey. Wired into
fn mainwith graceful shutdownthrough the existing
shutdown_tx.patched-iroh-relayfeaturereserved for upstream PR C's per-client lifetime hook
(
TODO(patched-iroh-relay)marker at the insertion site).iroh_relay_urlfromSPROUT_IROH_RELAY_PUBLIC_URL. Also addedSPROUT_IROH_RELAY_BIND_ADDRfor the main-wiring.
sprout_auth::nip98_canonical_urlhelper. Single source oftruth for the NIP-98
u-tag, used by signer (desktop) and verifier(iroh-relay access callback). Round-trip test pins the helper to
verify_nip98_event.Offer envelope schema (
crates/sprout-core/src/mesh_llm.rs)MeshLlmOfferis the JSON content of a kind:31990 event:Key shape decisions:
endpoint_idisStringin core to avoid pullingiroh-baseintosprout-core; parsed into
iroh_base::EndpointIdat the desktop edge.expires_atis required (no serde default). Crashed publisherscannot send the NIP-33 delete-by-replace tombstone, so the TTL is the
only thing that reaps stale offers. Consumer filter:
MeshLlmOffer::is_expired(now)returnstruewhenexpires_at <= now.OFFER_TTL_SECS = 15min; frontend heartbeats everyOFFER_TTL_SECS / 3 = 5minso one missed heartbeat still leaves theoffer visible.
deny_unknown_fieldsto lock the schema;extrais thefreeform escape hatch for future experiments.
Desktop sidecar (
desktop/src-tauri/src/mesh_llm/)endpoint.rs— iroh endpoint keypair persisted to{app_data_dir}/mesh_iroh.key. Atomic write, corrupt-file quarantine.Not derived from the Nostr key (see doc comment: rotation
coupling).
nip98.rs—build_nip98_bearer(keys, iroh_relay_public_url)signskind:27235 over the canonical relay URL using the same
sprout_auth::nip98_canonical_urlhelper as the verifier.nip11.rs—fetch_iroh_relay_url(ws_url)probes the relay's NIP-11doc. Returns
Ok(None)gracefully when the relay doesn't advertisemesh-LLM.
offer.rs— persistedComputeSharingPrefs(default disabled, 1concurrent peer);
OFFER_TTL_SECS = 15min;build_offer(endpoint, url, expires_at).Tauri commands (
desktop/src-tauri/src/commands/mesh_llm.rs)mesh_get_endpoint_id/mesh_get_sharing_prefs/mesh_set_sharing_prefs/mesh_relay_iroh_url.mesh_publish_offer(iroh_relay_url)— signs + POSTs a kind:31990event via the existing
submit_eventpipeline. On toggle off,publishes empty content at the same address (NIP-33
delete-by-replace).
Frontend (
desktop/src/features/settings/)MeshComputeSettingsCard.tsx— toggle, three numeric inputs (VRAM /RAM / concurrency), live offers list, identity footer. On every save,
probes the relay's
iroh_relay_urland publishes; surfaces a clearmessage when the relay doesn't advertise mesh-LLM.
hooks/useMeshLlmOffers.ts— subscribes to kind:31990, dedups by(pubkey, d_tag)(NIP-33), drops on empty content (delete-by-replace),filters
iroh_relay_url != local relay's NIP-11 iroh_relay_url(v1 same-relay invariant), filters
expires_at <= now(TTL reaper),30s periodic re-tick so newly-expired offers drop without a fresh
event.
hooks/useMeshOfferHeartbeat.ts— re-invokesmesh_publish_offerevery 5 min while
enabled, so the offer'sexpires_atstaysahead of consumer expiry.
relayClientSession.ts—subscribeToMeshLlmOffers.SettingsPanels.tsx— new "Share compute" section between "Agents"and "Templates", reached through avatar-menu → Settings.
Tests + checks
cargo test -p sprout-core --libcargo test -p sprout-auth --libnip98_url)cargo test -p sprout-relay --libcargo test --lib mesh_llm(desktop)cargo clippy --workspace --all-targets -- -D warningscargo fmt --all -- --checkpnpm typecheckpnpm check(biome + file-sizes)MSRV bump: 1.88 → 1.91
Required by
iroh-relay 1.0.0-rc.0. The repo'srust-toolchain.tomlpins 1.95.0, so CI is unaffected; this only constrains downstream
consumers building with an older rustc. README updated.
Deferred to follow-up
The actual iroh dial that runs an inference request on the offered
peer. Plan v6.1 explicitly gates this on upstream mesh-llm PR A
(request/response protocol over iroh streams). Inventing a
Sprout-only protocol now would be wasted work the moment upstream lands.
This PR intentionally does not send prompts, model weights, or inference
payloads over iroh yet. The only traffic on the wire is the kind:31990
discovery events (which describe capabilities, not data) and the
NIP-98-gated iroh-relay admission handshake (which authenticates but
carries no payload). The data plane stays inside the consuming desktop's
local agent runtime until the dial protocol lands.
What slots in cleanly once PR A lands, in this codebase:
MeshLlmProviderinmanaged_agents/backend.rsselects an offerfrom
useMeshLlmOffersand dials it,mesh_llm/nip98.rsplugsunchanged into the iroh client config,
patched-iroh-relaycfg flag (insertion site already marked withTODO(patched-iroh-relay)) gains the per-client lifetime hook fromupstream PR C.
How to demo locally
SPROUT_REQUIRE_RELAY_MEMBERSHIP=true,SPROUT_IROH_RELAY_PUBLIC_URL=http://localhost:3000/iroh,SPROUT_IROH_RELAY_BIND_ADDR=0.0.0.0:3478, a stable relay key, andyourself in
RELAY_OWNER_PUBKEY.just devdesktop; sign in as the relay-member.Share compute panel. Toggling off in user A's panel makes the offer
disappear from user B's view within a few seconds.
Review history
Iterative in-channel review by Max and Mari. Thread root
48856e13…. Concrete reviewer asks folded:TODO(patched-iroh-relay)marker, distinct
SPROUT_IROH_RELAY_BIND_ADDR, schema additions(
expires_at,EndpointAddrwording, same-relay filter).length cap + no-internal-whitespace + no-token-in-logs, NIP-98
canonicaliser drift prevention, explicit "preview / claims / v1
same-relay / key-custody" labeling in this description.