Skip to content

feat: run mesh as a library, and join gated relays - #641

Closed
michaelneale wants to merge 12 commits into
mainfrom
micn/relay-auth-fix
Closed

feat: run mesh as a library, and join gated relays#641
michaelneale wants to merge 12 commits into
mainfrom
micn/relay-auth-fix

Conversation

@michaelneale

@michaelneale michaelneale commented May 22, 2026

Copy link
Copy Markdown
Collaborator

Lets you run a full mesh node from the SDK, and also join a gated relay if you need to.

Adds a per-relay bearer token to the iroh relay map so mesh-llm can register with a gated iroh-relay (one running AccessConfig::Restricted) while public relays in the same map continue to register without auth.

Copilot AI review requested due to automatic review settings May 22, 2026 09:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds support for per-relay bearer authentication so the runtime can register with restricted (gated) iroh relays while continuing to use unauthenticated public relays.

Changes:

  • Introduces --relay-auth URL=TOKEN CLI parsing and wiring through runtime startup.
  • Extends mesh endpoint construction to attach auth tokens to specific relay configs when building an iroh::RelayMap.
  • Updates mesh tests and adds focused unit tests for relay-auth parsing and relay-map token attachment behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
crates/mesh-llm-host-runtime/src/runtime/mod.rs Builds a relay-auth map from CLI args and passes it into mesh::Node::start.
crates/mesh-llm-host-runtime/src/mesh/mod.rs Adds per-relay auth token support in relay map construction and threads it into endpoint/control listener setup; adds unit tests.
crates/mesh-llm-host-runtime/src/mesh/tests.rs Updates control-listener test call sites for the new relay_auths parameter.
crates/mesh-llm-host-runtime/src/cli/mod.rs Adds --relay-auth flag with a URL=TOKEN parser plus parser unit tests.
Comments suppressed due to low confidence (1)

crates/mesh-llm-host-runtime/src/mesh/mod.rs:277

  • Rustdoc link [RelayMap] likely won't resolve here because RelayMap isn't in scope (the function returns iroh::RelayMap via a fully-qualified path). Consider changing the link target to [iroh::RelayMap] (or plain backticks) to avoid broken intra-doc links/warnings.
fn effective_relay_urls(relay_urls: &[String]) -> Vec<String> {
    if relay_urls.is_empty() {
        vec![
            "https://usw1-2.relay.michaelneale.mesh-llm.iroh.link./".into(),
            "https://aps1-1.relay.michaelneale.mesh-llm.iroh.link./".into(),

Comment thread crates/mesh-llm-host-runtime/src/cli/mod.rs Outdated
@ndizazzo

Copy link
Copy Markdown
Collaborator

Found one more thing:

Medium: --relay-auth fails before serve / client in split-arg form
In crates/mesh-llm-host-runtime/src/cli/mod.rs, normalize_runtime_surface_args() skips known value-taking flags while searching for the legacy pseudo-subcommand, but the new --relay-auth flag is not included.

Example: mesh-llm --relay-auth https://gated.example/=token serve --relay https://gated.example/ --auto stops scanning at the token value, never normalizes serve, and Clap can reject/misparse the command.

Suggest: Add "--relay-auth" to value_taking_flags and add parser tests for --relay-auth URL=TOKEN serve ... and --relay-auth URL=TOKEN client ...

michaelneale added a commit that referenced this pull request May 23, 2026
… --relay-auth before serve/client

PR feedback fixes for #641:

- ndizazzo: add --relay-auth to normalize_runtime_surface_args' value-taking
  flag list so 'mesh-llm --relay-auth URL=TOKEN serve/client …' no longer
  stops scanning at the token. Adds two regression tests covering both
  surfaces and a base64-padded NIP-98-style token.

- Copilot: drop the broken intra-doc link [`RelayMap`] (RelayMap isn't in
  scope at the call site) for [`iroh::RelayMap`].

Plus a real defence for the feature itself: spin up an in-process
iroh-relay with AccessConfig::Restricted, build an iroh::Endpoint from
relay_map_from_urls' output, and assert:

  1. Matching token → endpoint.online() resolves.
  2. Wrong token → home_relay_status reports 'not authorized' and
     online() never resolves.
  3. Missing token → online() never resolves.
  4. Mixed map (gated + public) authenticates only the gated relay and
     still comes online.

This is the missing end-to-end check: if iroh changes how
with_auth_token is sent on the WebSocket upgrade, or if a future refactor
drops relay_auths from the call chain, these tests fail.
@michaelneale

Copy link
Copy Markdown
Collaborator Author

Thanks both — pushed f76042cc:

@ndizazzo — fixed. Added --relay-auth to normalize_runtime_surface_args' value-taking list, plus two new parser tests:

  • normalize_runtime_surface_args_treats_relay_auth_as_value_taking_before_serve — verifies mesh-llm --relay-auth URL=TOKEN serve --relay … --auto discovers the serve surface and cli.relay_auth gets populated.
  • normalize_runtime_surface_args_relay_auth_before_client_invocation — same for client, with a base64-padded NIP-98-style token (eyJ…payload==) so we exercise the =-in-token edge.

@copilot — dropped the broken [RelayMap] intra-doc link for [iroh::RelayMap].

While here, added a real defence for the feature too — an in-process iroh-relay with AccessConfig::Restricted and 4 e2e tests:

  • matching token admits the endpoint (online() resolves),
  • wrong token surfaces not authorized on home_relay_status and never reaches online(),
  • missing token also rejected,
  • mixed gated + public relay map authenticates only the gated relay.

These exercise the actual wire path through relay_map_from_urlsiroh::RelayConfig::with_auth_token → relay WebSocket upgrade. Build cost: iroh-relay with server,test-utils features in [dev-dependencies] only — shipped binary tree unchanged.

@michaelneale

Copy link
Copy Markdown
Collaborator Author

Pushed 20d6cb82: the Rust SDK that motivated this PR in the first place.

mesh_llm::sdk::RuntimeBuilder mirrors the binary's CLI 1:1 — every mesh-llm flag has a builder method, including --relay-auth:

mesh_llm::sdk::RuntimeBuilder::new()
    .client(true)
    .auto(true)
    .relay("https://gated.example/")
    .relay_auth("https://gated.example/", "<nip98-bearer>")
    .run()
    .await?;

How it works

The builder collects fields, serialises them to argv, and feeds that argv to the same runtime::run entry point the binary uses. To enable this, runtime::run() is split into a thin entry that reads std::env::args_os() plus a new run_with_args(args) that takes a caller-supplied argv. Binary behaviour is unchanged.

One code path, two surfaces. The CLI parser is the same parser the SDK targets.

What it covers

The realistic CLI surface: client/serve, auto, publish, mesh_name, region, join, discover, model/gguf/mmproj, port, console_port, headless, blackboard, name, max_vram_gb, no_enumerate_host, relay, relay_auth, nostr_relay, bind_port/bind_ip, listen_all, config, owner_key, owner_required, node_label, trust_owner. Plus a .arg(...) escape hatch.

Known limitations (documented in the module)

  • No spawn() method: the runtime future is not currently Send, so tokio::spawn would be unsound. Embedders use .run().await or wrap in tokio::task::LocalSet themselves. We can revisit once the runtime is Send-clean.
  • Introspecting a running runtime (invite token, peers, served models) goes through the management API on --console (default :3131). That's what the CLI's TUI and web console do too. A rich in-process handle would need real refactoring of run() to return early with a handle; out of scope here.

Tests

7 unit tests pin each setter's argv. The decisive one is argv_is_parseable_by_the_real_cli_parser: it round-trips builder argv through normalize_runtime_surface_args + Cli::try_parse_from and asserts the fields land. If a future refactor renames a flag, this test fails immediately and points at the drifted builder method.

Stability statement

mesh_llm::sdk::* is documented as the only stable Rust surface in the mesh-llm crate. Everything else still reachable via the existing wildcard re-export of mesh-llm-host-runtime is implementation detail and may change in any release. The docstring on mesh-llm's lib.rs says so explicitly.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 9 changed files in this pull request and generated 5 comments.

Comment thread crates/mesh-llm-host-runtime/src/sdk.rs Outdated
Comment thread crates/mesh-llm-host-runtime/src/sdk.rs Outdated
Comment thread crates/mesh-llm-host-runtime/src/sdk.rs Outdated
Comment thread crates/mesh-llm-host-runtime/src/runtime/mod.rs Outdated
Comment thread crates/mesh-llm-host-runtime/src/cli/mod.rs
@michaelneale michaelneale changed the title feat(cli): --relay-auth URL=TOKEN for gated iroh-relays WIP feat: add library and cli for --relay-auth URL=TOKEN for gated iroh-relays May 23, 2026
@michaelneale
michaelneale marked this pull request as draft May 23, 2026 07:03
@michaelneale

michaelneale commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up work to plan

PR is in draft. Notes here on the next steps that this PR clears the runway for. Whether any of these land in this PR or a follow-up is open \u2014 captured here so we don't lose context either way.

1. Make cargo build of mesh-llm work without just

Today cargo build -p mesh-llm from a clean tree fails: skippy-ffi/build.rs only links pre-built llama.cpp static libs, and those libs are produced by scripts/prepare-llama.sh + scripts/build-llama.sh orchestrated by just build before cargo runs.

This is the core blocker for any external consumer (mesh-llm = { git = "...", rev = "..." }) and for any future crates.io publish.

Feasibility: confirmed locally. Adding ~74 lines to skippy-ffi/build.rs (a thin auto-prepare/auto-build path that invokes the existing scripts when the canary static lib is missing) gets cargo build -p mesh-llm working end-to-end from a clean checkout, ~55s on an M-series Mac. Subsequent builds are fast (canary present, scripts skipped). just build still works unchanged (canary present from cargo's previous run, scripts skipped).

Critical caveat to get right (the real bug we hit during the feasibility test):

build-llama.sh defaults to LLAMA_BACKEND=cpu. skippy-ffi/build.rs (the link path) defaults to metal on apple-darwin and cpu elsewhere via default_build_dir. These two must agree, always, or the build silently produces the wrong artefacts and then fails to link.

The autobuild path therefore MUST:

  • Compute the backend exactly once, in one place.
  • Honour overrides in priority order: LLAMA_STAGE_BACKEND \u2192 SKIPPY_LLAMA_BACKEND \u2192 LLAMA_BACKEND \u2192 target-based default (metal on apple-darwin, else cpu). This is the same order the scripts already use.
  • Pass that backend into the script invocation via LLAMA_BACKEND, LLAMA_STAGE_BACKEND, and SKIPPY_LLAMA_BACKEND env vars (the scripts inspect all three).
  • Use the same backend value when computing the link path's build_dir so the autobuild and the link agree by construction.

It should also:

  • Be opt-out: SKIPPY_FFI_NO_AUTOBUILD=1 skips the autobuild (for CI lanes that pre-stage a different llama tree).
  • No-op when the scripts aren't present (i.e. the crate is being consumed outside the workspace) \u2014 fall through to the regular link path so the existing linker error tells the user what to do.
  • Surface a cargo:warning=\u2026 line before the first autobuild so a developer knows why their first cargo build is suddenly compiling C++ for ~30-50s.

GPU backends (CUDA / ROCm / Vulkan) add more required env (LLAMA_STAGE_CUDA_ARCHITECTURES, LLAMA_STAGE_AMDGPU_TARGETS, etc.). The autobuild should pass-through anything the user has set, and only override what's missing.

Pre-commit on that change: just build && cargo build -p mesh-llm from .deps cleared, on at least macOS arm64 and Linux CPU.

2. Publishing mesh-llm to crates.io

The autobuild above unlocks git-dep consumption (mesh-llm = { git = "...", rev = "..." }). It does not unlock cargo add mesh-llm because:

  • scripts/prepare-llama.sh / scripts/build-llama.sh live in the workspace, not the published crate.
  • third_party/llama.cpp/patches/ and upstream.txt similarly aren't shipped with a cargo publish of mesh-llm/skippy-ffi.

The standard pattern (see llama-cpp-sys-2 on crates.io for the existence proof) is to vendor the post-patch llama.cpp source inside the published crate via include = [...] in Cargo.toml. Concretely:

  • A pre-publish step materialises the patched llama.cpp source into crates/skippy-ffi/llama.cpp/ (the same source prepare-llama.sh produces in .deps/ today).
  • Cargo.toml include = [...] lists the source files to ship.
  • The autobuild path from (1) detects "vendored source present" \u2192 skips the prepare step and goes straight to cmake-against-vendored-source.

Workspace dev flow is unchanged: scripts still produce .deps/llama.cpp/ for fast iteration; the vendored copy is a publish-time artefact only.

Also requires publishing the unpublished workspace deps mesh-llm pulls in (mesh-llm-host-runtime, skippy-runtime, skippy-server, mesh-llm-ui, mesh-llm-system, mesh-llm-plugin, mesh-mixture-of-agents, openai-frontend, model-resolver, model-package, skippy-protocol, skippy-coordinator, skippy-topology). The existing scripts/publish-crates.sh chain (model-ref \u2192 \u2026 \u2192 mesh-api) is the template \u2014 extend it.

3. spawn() on RuntimeBuilder

Currently absent because the runtime future is not Send. To add it cleanly:

  • Audit non-Send types held across .await points in runtime::run_with_args. Known offenders include Box<dyn SearchFormatter> and Box<dyn ModelsFormatter> in the models CLI commands (the build error we hit when I first tried to tokio::spawn the future surfaced dyn SearchFormatter cannot be shared between threads safely).
  • Add + Send + Sync to the trait objects (and any others surfaced by clippy's -D warnings once enabled on the spawn path).
  • Add a RuntimeBuilder::spawn(self) -> tokio::task::JoinHandle<Result<()>> method once the future compiles as Send.

Until then run().await is the only entry. Documented in the module-level doc comment.

4. Rich in-process introspection

The runtime API on --console (default :3131) is what the CLI's TUI and web console use, and it's how SDK consumers should pull invite-token / peers / served-models from a running runtime today. That's deliberate in this PR \u2014 runtime::run_with_args blocks until shutdown, so there's no "started, here's a handle, your turn" point.

If we want a real RuntimeHandle with invite_token(), peers(), shutdown() exposed in-process (no HTTP roundtrip), runtime::run_with_args needs to be split into a startup phase that returns a handle plus a background driver task. Non-trivial \u2014 touches the full startup sequence (auto-discovery, model load, proxy bind, console bind). Worth doing once a real consumer asks for it.

5. Embed-only feature flag

For consumers who want mesh-llm's mesh fabric but route inference elsewhere (no in-process llama.cpp), a --no-default-features build of mesh-llm that excludes skippy-ffi entirely would be valuable. Removes the llama.cpp build dependency end-to-end. Requires feature-gating mesh-llm-host-runtime's inference module and a few other points where the runtime currently assumes local serving is available.


These are the bookmarks. Any of them may land in this PR or a follow-up.

@michaelneale michaelneale changed the title WIP feat: add library and cli for --relay-auth URL=TOKEN for gated iroh-relays WIP feat: acli for --relay-auth URL=TOKEN for gated iroh-relays May 23, 2026
@michaelneale
michaelneale requested review from i386 and ndizazzo May 24, 2026 03:14
@michaelneale

Copy link
Copy Markdown
Collaborator Author

Copilot review pass — addressed

Reviewers added: @ndizazzo, @i386.

Walked Copilot's two review passes:

Live finding (fixed in 89c4900a): Token leakage in parse_relay_auth_pair error messages. The original format!("... got {s:?}") echoed the full URL=TOKEN input back, which would land bearer tokens in terminal output, logs, and bug reports if a user mistyped --relay-auth. Now:

  • Missing = separator → redact whole input (can't tell URL from token).
  • Empty URL (=token) → redact (the value after = is the secret).
  • Empty token (URL=) → URL is safe to name; no token to leak.

Added parser_errors_never_leak_token_portion test that injects a known token, drives each error path, and asserts it never appears in the error message. Good catch from Copilot.

Stale findings (ignore): Three comments in Copilot's second pass refer to crates/mesh-llm-host-runtime/src/sdk.rs and rustdoc links into it. That file was reverted (4cfae45a) once we saw #634 had already shipped the proper SDK story (MeshNode / MeshNodeBuilder in mesh-llm-api-server + Swift/Kotlin/Node bindings + native runtime artifact crates). Re-doing it here would have been duplication.

First-pass intra-doc link [RelayMap][iroh::RelayMap] was already fixed in f76042cc.

@michaelneale
michaelneale marked this pull request as ready for review May 24, 2026 10:13
Copilot AI review requested due to automatic review settings May 24, 2026 10:13
@michaelneale michaelneale changed the title WIP feat: acli for --relay-auth URL=TOKEN for gated iroh-relays feat: --relay-auth URL=TOKEN for gated iroh-relays May 24, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 6 changed files in this pull request and generated 7 comments.

Comment thread crates/mesh-llm-host-runtime/src/mesh/tests.rs Outdated
Comment thread crates/mesh-llm-host-runtime/src/mesh/tests.rs Outdated
Comment thread crates/mesh-llm-host-runtime/src/mesh/tests.rs
Comment thread crates/mesh-llm-host-runtime/src/mesh/tests.rs Outdated
Comment thread crates/mesh-llm-host-runtime/src/mesh/tests.rs Outdated
Comment thread crates/mesh-llm-host-runtime/src/mesh/tests.rs Outdated
Comment thread crates/mesh-llm-host-runtime/src/mesh/tests.rs Outdated
@michaelneale

Copy link
Copy Markdown
Collaborator Author

Copilot review pass 3 \u2014 addressed

Seven comments, all the same shape: &HashMap::new() passed into maybe_start_control_listener(...).await, flagged as "temporary borrowed across await".

Verdict: harmless today (compiles, tests pass on every CI lane), because the callee only borrows the reference synchronously before its internal .await points. Rust's temporary-lifetime-extension rules cover the actual call boundary.

But the pattern is fragile \u2014 if the callee's signature ever shifts the borrow across an await, every test would break at once. Cheap future-proofing, applied in 6fd108b5: introduced an empty_relay_auths() helper and bound each call's map to a local before passing &auths. 7 sites, no behaviour change, all 119 mesh tests still pass.

Net diff is slightly smaller (+31 -50) because the helper consolidates what was 7 inline temporary-construction sites.

Copilot AI review requested due to automatic review settings May 24, 2026 16:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated 4 comments.

Comment thread crates/mesh-llm-api-server/src/node.rs
Comment thread crates/mesh-llm-host-runtime/src/host_node.rs
Comment thread crates/mesh-llm-host-runtime/src/host_node.rs Outdated
Comment thread crates/mesh-llm-host-runtime/src/mesh/mod.rs Outdated

@i386 i386 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review pass: two host-runtime SDK lifecycle issues to address before merge.

Comment thread crates/mesh-llm-api-server/src/node.rs
Comment thread crates/mesh-llm-host-runtime/src/host_node.rs Outdated
michaelneale and others added 7 commits May 25, 2026 18:48
Adds a per-relay bearer token to the iroh relay map so mesh-llm can
register with a gated iroh-relay (one running AccessConfig::Restricted)
while public relays in the same map continue to register without auth.

The token is sent as 'Authorization: Bearer <TOKEN>' on the WebSocket
upgrade to the matching --relay URL, via iroh::RelayConfig::with_auth_token.

Repeatable. Splits on the first '=' only so tokens may contain '='
(base64 padding, JWTs, etc.).

Why: enables embedders (e.g. Sprout) to launch mesh-llm against an
operator-hosted iroh-relay that authenticates members via a bearer
scheme (NIP-98, JWT, opaque API key, ...) without forking mesh-llm.
Admission policy stays at the relay; mesh-llm just carries the token.

Threaded through Node::start -> bind_mesh_endpoint and
maybe_start_control_listener -> configure_control_relay so both the
data-plane and owner-control endpoints honour per-relay tokens.

Tests: parser handles trailing '=' and rejects malformed input;
relay-map builder leaves untokened relays unauthenticated and attaches
tokens only to matching URLs.
… --relay-auth before serve/client

PR feedback fixes for #641:

- ndizazzo: add --relay-auth to normalize_runtime_surface_args' value-taking
  flag list so 'mesh-llm --relay-auth URL=TOKEN serve/client …' no longer
  stops scanning at the token. Adds two regression tests covering both
  surfaces and a base64-padded NIP-98-style token.

- Copilot: drop the broken intra-doc link [`RelayMap`] (RelayMap isn't in
  scope at the call site) for [`iroh::RelayMap`].

Plus a real defence for the feature itself: spin up an in-process
iroh-relay with AccessConfig::Restricted, build an iroh::Endpoint from
relay_map_from_urls' output, and assert:

  1. Matching token → endpoint.online() resolves.
  2. Wrong token → home_relay_status reports 'not authorized' and
     online() never resolves.
  3. Missing token → online() never resolves.
  4. Mixed map (gated + public) authenticates only the gated relay and
     still comes online.

This is the missing end-to-end check: if iroh changes how
with_auth_token is sent on the WebSocket upgrade, or if a future refactor
drops relay_auths from the call chain, these tests fail.
Copilot review surfaced a real leak: parse_relay_auth_pair includes the
full URL=TOKEN input in error strings via {s:?}. If a user mistypes the
flag, the bearer token ends up in terminal output, logs, and bug
reports.

Redaction rules:

- Missing '=' separator: redact whole input (we cannot tell URL from
  token).
- Empty URL ('=token'): redact (the value after '=' is the secret).
- Empty token ('URL='): URL is safe to name; no token to leak.

New test parser_errors_never_leak_token_portion pins the property:
inject a known token string, drive each error path, assert the string
never appears in the error message.
Copilot review pass flagged 7 sites where the test threads
`&std::collections::HashMap::new()` straight into
`maybe_start_control_listener(...).await`. Compiles fine today because
the callee only borrows the reference synchronously before any internal
`.await`, but the pattern is fragile: if the signature ever shifts the
borrow across an await point, every test breaks at once.

Centralise on an `empty_relay_auths()` helper bound to a local before
each call. Cheap future-proofing, removes 7 lookalikes from review
chatter on future PRs touching this file, no behaviour change.

All 119 mesh tests still pass.
…ime feature

Lets a Rust app drive a real mesh-llm node from the published SDK \u2014 the
same iroh-backed peer the binary runs, not the HTTP-shim client that
MeshNode::start() used previously.

```rust
let node = MeshNode::builder()
    .identity(OwnerKeypair::generate())
    .join(invite)
    .role(MeshRole::Client)
    .relay("https://gated.example/")
    .relay_auth("https://gated.example/", "<bearer-token>")
    .max_vram_gb(0.0)
    .build()?;

node.start().await?;
let invite = node.invite_token().await;
```

Architecture:

- New `mesh_llm_host_runtime::host_node` module exposes `HostNodeSpec`
  + `HostNode` + `start_host_node` as the curated entry point into the
  internal `mesh::Node`. SDK consumers don't see internal types directly.

- `mesh-llm-api-server` gains a `host-runtime` Cargo feature
  (off by default). With it on, depends on `mesh-llm-host-runtime` and
  `MeshNode::start()` calls `host_node::start_host_node` with the
  builder's relay / relay_auth / role / quic_bind / max_vram fields.

- Builder API extended with .role(), .relay(), .relay_auth(),
  .quic_bind(), .max_vram_gb(), .no_enumerate_host(). New types
  `MeshRole` and `MeshQuicBind` mirror the CLI surface; SDK consumers
  never have to import host-runtime-internal types.

- Without the feature, the new builder methods are still callable
  (forward-compat: an SDK consumer can configure relay-auth without
  caring whether the runtime is wired) \u2014 fields are stored but
  ignored, and start() falls back to the existing HTTP-shim behaviour.

- New invite_token() / set_display_name() accessors on MeshNode
  (host-runtime-only) so consumers can introspect / advertise their
  running node.

Cycle fix: `mesh-llm-host-runtime` had an unused declared dep on
`mesh-llm-api-server` (no `use` sites in src/). Dropped to let the
inverse dep land cleanly.

Test: crates/mesh-llm-api-server/tests/host_node_gated_relay.rs
(gated on host-runtime feature) brings up an in-process iroh-relay with
AccessConfig::Restricted, builds a MeshNode with .relay_auth(...) for
the matching token, and asserts node.start() reaches the gated relay
end-to-end. A second test pins that the wrong token is denied with
'not authorized' at the iroh wire layer.
@ndizazzo
ndizazzo force-pushed the micn/relay-auth-fix branch from 19f3ecf to 7c42372 Compare May 25, 2026 22:56
* origin/main:
  task(ci): optimize PR builds further (#674)
  Fix Metal benchmark cross-arch build (#679)
Copilot AI review requested due to automatic review settings May 26, 2026 00:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

crates/mesh-llm-api-server/Cargo.toml:43

  • base64 and serde_json are added as dev-dependencies here, but there are no references to either in crates/mesh-llm-api-server (including the new host-runtime tests). Consider removing them to avoid unnecessary dependency bloat/compile time.
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "time"] }
# In-process iroh-relay for testing the host-runtime feature: real
# AccessConfig::Restricted relay, real endpoint bind, real WebSocket
# upgrade. Same dev-dep iroh feature combo `mesh-llm-host-runtime`
# already uses for its own gated-relay tests.
iroh = { version = "1.0.0-rc.0", features = ["test-utils"] }
iroh-relay = { version = "1.0.0-rc.0", features = ["server", "test-utils"] }
futures-util = "0.3"
base64 = "0.22"
serde_json = "1"

Comment thread crates/mesh-llm-host-runtime/src/host_node.rs Outdated
Comment thread crates/mesh-llm-host-runtime/src/mesh/mod.rs
Comment thread crates/mesh-llm-api-server/tests/host_node_gated_relay.rs Outdated
SDK consumers can now launch the full mesh node *including* the OpenAI
HTTP API surface from Rust code, without going through the binary CLI:

```rust
let node = MeshNode::builder()
    .identity(OwnerKeypair::generate())
    .join(invite)
    .role(MeshRole::Client)
    .relay("https://gated.example/")
    .relay_auth("https://gated.example/", "<bearer>")
    .openai_port(0) // 0 = OS-assigned ephemeral port
    .build()?;

node.start().await?;
let base = node.openai_base_url().await.unwrap();
// e.g. http://127.0.0.1:54321
// Hit /v1/chat/completions, /v1/models, /v1/responses there as usual.
```

Mechanism:

- New `mesh_llm_host_runtime::host_node::start_openai_proxy(node, port,
  listen_all)` wraps the internal `network::openai::ingress::api_proxy`
  with default empty target_rx (routing pulls remote peers dynamically
  from `node.hosts_for_model()` at request time) and a background drain
  for runtime-control messages (SDK consumers without local serving
  have no one to handle Load/Unload control requests).

- `MeshNodeBuilder` gains `.openai_port(port)` and
  `.openai_listen_all(bool)` setters. With the `host-runtime` feature on
  and `openai_port` set, `MeshNode::start()` binds and spawns the proxy
  alongside the mesh node; `MeshNode::stop()` aborts it.

- New `MeshNode::openai_base_url()` accessor returns the bound URL
  after start (Some when the proxy is running, None otherwise).

End-to-end test `openai_proxy_binds_and_serves_v1_models_over_http`:
constructs a MeshNode via the SDK with .openai_port(0), GETs /v1/models
over real TCP, asserts 200 OK with a JSON body containing the `data`
field (OpenAI shape), then stops the node and asserts the port no
longer answers.

Scope clarification in host_node module docs: the "this does not do"
list shrinks; OpenAI proxy is no longer in it. Local model serving
still requires plugging an EmbeddedServingController, which is a
separate concern (client-only embedders don't need it because the
proxy routes to remote mesh peers).
Adds the missing piece: SDK consumers can now run *exactly* what the
mesh-llm binary runs \u2014 not a degraded subset. `run_serve(spec)`
constructs argv from a typed `MeshServeSpec` and feeds it to the same
`runtime::run_with_args` entry point the binary calls.

```rust
use mesh_llm_api_server::{run_serve, MeshServeSpec};

run_serve(MeshServeSpec {
    client: true,
    auto: true,
    relays: vec!["https://gated.example/".into()],
    relay_auths: [(
        "https://gated.example/".to_string(),
        "<bearer>".to_string(),
    )].into_iter().collect(),
    port: Some(9337),
    console_port: Some(3131),
    max_vram_gb: Some(0.0),
    ..Default::default()
}).await?;
```

That gets the full thing: auto-discovery, election, tunnel manager,
OpenAI proxy, management console, local model serving (when configured),
plugin host. Same code path as `mesh-llm serve` / `mesh-llm client`.

Mechanism:

- `runtime::run()` split into a thin env-driven entry and a new
  `run_with_args(argv)` that takes a caller-supplied argv. Binary
  unchanged \u2014 main() still calls run() which forwards std::env::args_os.

- `mesh_llm_host_runtime::run_with_args(argv)` re-exports it at the
  crate root.

- `host_node::MeshServeSpec` covers the realistic CLI surface: client,
  auto, publish, mesh_name, region, display_name, join, discover,
  models, ggufs, mmproj, port, console_port, headless, blackboard,
  relays, relay_auths, nostr_relays, bind_port, bind_ip, listen_all,
  max_vram_gb, no_enumerate_host, config, owner_key, owner_required,
  node_label, trust_owners, debug. Plus an extra_args escape hatch.

- `host_node::run_serve(spec)` serialises the spec to argv via
  `MeshServeSpec::into_argv()` and calls `run_with_args`.

- `mesh-llm-api-server::{run_serve, MeshServeSpec}` re-exports them
  through the published SDK crate (gated on the host-runtime feature).

Regression-catcher test `mesh_serve_spec_argv_parses_via_the_real_cli_parser`:
constructs a fully-populated MeshServeSpec, calls into_argv(), runs it
through `normalize_runtime_surface_args` + `Cli::try_parse_from` (the
real parser the binary uses), asserts every field round-trips. If a
future refactor renames a CLI flag, this fails immediately and points
at the drifted MeshServeSpec field.

This is what sprout (or any Rust app) actually needs to run a full
mesh-llm node from inside its own process. The earlier
`MeshNodeBuilder` + `start_openai_proxy` work remains useful for
finer-grained client-only embedders that don't want the whole runtime
machinery, but `run_serve` is the answer to 'I want my Rust app to do
exactly what `mesh-llm serve` does.'
Copilot AI review requested due to automatic review settings May 26, 2026 01:48
Adds the missing 'how do I run mesh-llm from Rust?' answer in three
places so it's discoverable however a consumer arrives:

- `docs/SDK.md` gains a 'Run the full mesh-llm runtime from Rust
  (host-runtime feature)' section under Rust Usage. Explains the
  feature flag, contrasts with MeshNodeBuilder (fine-grained vs
  full-runtime), gives a complete relay-auth + OpenAI + console
  example, lists every MeshServeSpec field.

- `crates/mesh-llm-api-server/README.md` gets a parallel section so
  consumers landing on the crate page (e.g. via docs.rs or crates.io)
  see the run_serve story without leaving the crate docs.

- The `pub use` re-export of `run_serve` / `MeshServeSpec` in
  `mesh-llm-api-server/src/lib.rs` now carries a full rustdoc example
  with the same MeshServeSpec, so `cargo doc` surfaces it
  prominently.

No code changes; documentation only.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 15 changed files in this pull request and generated 3 comments.

Comment thread crates/mesh-llm-host-runtime/src/cli/mod.rs Outdated
Comment on lines 458 to +507
pub async fn start(&self) -> Result<(), MeshApiError> {
self.inner.client.lock().await.join().await
#[cfg(feature = "host-runtime")]
{
let spec = HostNodeSpec {
role: match self.inner.host_node_spec.role {
MeshRole::Client => HostNodeRole::Client,
MeshRole::Serve => HostNodeRole::default(),
},
relays: self.inner.host_node_spec.relays.clone(),
relay_auths: self.inner.host_node_spec.relay_auths.clone(),
quic_bind: HostQuicBindSelection {
ip: self.inner.host_node_spec.quic_bind.ip,
port: self.inner.host_node_spec.quic_bind.port,
},
max_vram_gb: self.inner.host_node_spec.max_vram_gb,
enumerate_host: self.inner.host_node_spec.enumerate_host,
};
let node =
host_node::start_host_node(spec)
.await
.map_err(|err| MeshApiError::Serving {
message: format!("host node start failed: {err}"),
})?;
if let Err(err) = node.join(self.inner.config.invite_token.as_str()).await {
node.shutdown().await;
return Err(MeshApiError::Serving {
message: format!("host node join failed: {err}"),
});
}
node.start_accepting();

// Spin up the OpenAI HTTP proxy if the builder asked for one.
// Equivalent to `mesh-llm … --port <port>`. Routes inference
// requests to mesh peers serving the requested model.
if let Some(port) = self.inner.host_node_spec.openai_port {
let listen_all = self.inner.host_node_spec.openai_listen_all;
let handle =
mesh_llm_host_runtime::host_node::start_openai_proxy(&node, port, listen_all)
.await
.map_err(|err| MeshApiError::Serving {
message: format!("openai proxy bind failed: {err}"),
})?;
*self.inner.openai_proxy.lock().await = Some(handle);
}

*self.inner.host_node.lock().await = Some(node);
// Also flip the legacy HTTP-shim client's connected flag so
// status()/events() callers see a connected node. Harmless.
self.inner.client.lock().await.join().await
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mine found the same:


Medium — host node leaks when OpenAI proxy bind fails
crates/mesh-llm-api-server/src/node.rs:502-513
If .openai_port(...) is set to a port that is already in use or cannot be bound, start_host_node(), join(), and node.start_accepting() have already succeeded before start_openai_proxy(...) returns an error. Because self.inner.host_node is not stored yet, a later stop() has no handle to shut the node down, leaving QUIC/background tasks alive. Add cleanup on this error path.

Comment thread crates/mesh-llm-api-server/tests/openai_proxy.rs Outdated
…nicalisation

Six findings from Copilot's latest pass on the SDK surface. All real.

1. HostNodeSpec / MeshServeSpec leaked bearer tokens via Debug.
   Both structs derived Debug, so any {:?} (panic, tracing, etc.)
   would expose relay_auths token values. Now Debug is implemented
   manually with a RedactedAuthMap helper that prints relay URLs
   (public) but replaces token values with '<redacted N bytes>'.

2. relay_map_from_urls did raw-string auth lookup.
   Logically equivalent URLs ('https://x.example' vs 'https://x.example/')
   would silently miss the auth map and the gated relay would reject
   the registration with no clear signal. Now both sides canonicalise
   via iroh::RelayUrl::parse before the lookup. Misconfigured auth
   keys surface as Err naming the offending key. New tests
   auth_token_matches_canonicalised_url_with_or_without_trailing_slash
   and malformed_relay_auth_key_surfaces_as_error pin the contract.

3. SDK gated-relay test anchor used bundled public relays.
   Made tests internet-dependent and slow. anchor_on_relay() now
   takes the relay URL explicitly (and optional auth token for
   gated). Both SDK test files spin up an in-process relay and point
   the anchor at it so the suite stays offline and deterministic.

4. Clap's value_parser leaked tokens in 'invalid value' errors.
   --relay-auth used #[arg(value_parser = parse_relay_auth_pair)],
   so Clap's default 'invalid value '...' for '--relay-auth''
   message would echo the full URL=TOKEN input on parse failure.
   Now Cli::relay_auth is an opaque Vec<String>; validation
   happens post-parse via Cli::parse_relay_auths() which we
   control and which already redacts properly. Production call
   sites in runtime/mod.rs updated. Test fixtures updated to
   match the new shape (still pin the URL=TOKEN string going in
   and the (url, token) pair coming out).

5. MeshNode::start() not idempotent under host-runtime feature.
   A second start() spawned a second iroh endpoint and OpenAI
   proxy and orphaned the first (stop() only knew about the most
   recent). Now early-returns if host_node is already Some. New
   test start_is_idempotent_on_repeat_calls in
   openai_proxy.rs pins the contract: second start() returns
   without rebinding, base URL unchanged.

6. openai_proxy test leaked the anchor HostNode.
   Test bound the anchor to '_anchor' and never shut it down,
   leaking the iroh endpoint + accept loop into subsequent tests.
   Now keeps the anchor named and explicitly calls
   anchor.shutdown().await before returning.
@michaelneale michaelneale changed the title feat: --relay-auth URL=TOKEN for gated iroh-relays feat: run mesh as a library, and join gated relays May 26, 2026
@michaelneale

Copy link
Copy Markdown
Collaborator Author

not sure if I like this approach yet, need to think about how rust crate is pre-built and shared

michaelneale added a commit that referenced this pull request May 26, 2026
Follow-on to docs/design/RUST_NATIVE_SDK.md. Investigates what it would
take for the mesh-llm shipped binary to consume mesh-llm-api-server
the same way an external Rust app does, instead of reaching into
host-runtime internals.

Findings:

- The user-facing CLI subcommands (discover, download, models,
  blackboard) already have direct SDK equivalents. The duplication
  is roughly 400 lines of host-runtime-internal access in
  crates/mesh-llm-host-runtime/src/cli/commands/*.
- Three change groups: (1) domain commands route through SDK, (2)
  serve/client route through run_serve(MeshServeSpec) from PR #641,
  (3) auth either moves into the SDK or is explicitly marked
  binary-only.
- Concrete cost: 400-1000 lines re-pointed at SDK calls, plus the
  auth decision. Not a 7K-line rewrite. CLI shell (clap parsing,
  output, TUI) stays as the binary's job.
- Several CLI surfaces explicitly stay bespoke: update, model-prepare,
  benchmark, gpu enumerate, stop, http-to-management-api commands.

Sequencing depends on landing #690, the gated-relay split, and #691
first. Without mesh-llm-api-server actually on crates.io, the
'binary uses the SDK' story is internal-only.
@michaelneale

Copy link
Copy Markdown
Collaborator Author

Superseded by two unrelated PRs split out of this one:

Closing this one in favour of the cleaner split.

michaelneale added a commit that referenced this pull request May 26, 2026
cargo fmt under edition 2024 sorts uppercase types alongside lowercase
modules. Reorders imports in the SDK files cherry-picked from #641.
No logic change.
michaelneale added a commit that referenced this pull request May 26, 2026
Three small cleanups to make this branch green on a workspace that
doesn't (yet) have the rest of #641's gated-relay polish:

1. skippy-ffi/build.rs: collapse the nested ifs in the tarball-URL
   fetch path into a single let-chain so clippy's collapsible-if
   doesn't fire.

2. crates/mesh-llm-host-runtime/src/host_node.rs: remove three tests
   that depend on helpers only present on the gated-relay PR
   (id_returns_bare_hex_endpoint_id needs the bare-hex HostNode::id()
   refactor; shutdown_closes_the_mesh_endpoint needs
   Node::endpoint_is_closed_for_tests; shutdown_releases_fixed_quic_bind
   depends on the shutdown polish that releases the QUIC bind cleanly).
   They come back once that work is on main. Also remove the
   helpers (free_local_udp_port, probe_quic_port_released) those
   tests pulled in.

3. The mesh_serve_spec_argv_parses_via_the_real_cli_parser test no
   longer asserts on cli.relay_auth (that field doesn't exist on this
   branch). Updated to use 'https://public.example/' instead of
   'https://gated.example/' since gated-relay support isn't here yet.
michaelneale added a commit that referenced this pull request May 27, 2026
* feat(cli): --relay-auth URL=TOKEN for gated iroh-relays

Adds a per-relay bearer token to the iroh relay map so mesh-llm can
register with a gated iroh-relay (one running AccessConfig::Restricted)
while public relays in the same map continue to register without auth.

The token is sent as 'Authorization: Bearer <TOKEN>' on the WebSocket
upgrade to the matching --relay URL, via iroh::RelayConfig::with_auth_token.

Repeatable. Splits on the first '=' only so tokens may contain '='
(base64 padding, JWTs, etc.).

Why: enables embedders (e.g. Sprout) to launch mesh-llm against an
operator-hosted iroh-relay that authenticates members via a bearer
scheme (NIP-98, JWT, opaque API key, ...) without forking mesh-llm.
Admission policy stays at the relay; mesh-llm just carries the token.

Threaded through Node::start -> bind_mesh_endpoint and
maybe_start_control_listener -> configure_control_relay so both the
data-plane and owner-control endpoints honour per-relay tokens.

Tests: parser handles trailing '=' and rejects malformed input;
relay-map builder leaves untokened relays unauthenticated and attaches
tokens only to matching URLs.

* fix(clippy): group relay urls/auths into RelayConfig to keep Node::start under arg limit

* test(relay-auth): in-process gated-relay e2e + scanner regression for --relay-auth before serve/client

PR feedback fixes for #641:

- ndizazzo: add --relay-auth to normalize_runtime_surface_args' value-taking
  flag list so 'mesh-llm --relay-auth URL=TOKEN serve/client …' no longer
  stops scanning at the token. Adds two regression tests covering both
  surfaces and a base64-padded NIP-98-style token.

- Copilot: drop the broken intra-doc link [`RelayMap`] (RelayMap isn't in
  scope at the call site) for [`iroh::RelayMap`].

Plus a real defence for the feature itself: spin up an in-process
iroh-relay with AccessConfig::Restricted, build an iroh::Endpoint from
relay_map_from_urls' output, and assert:

  1. Matching token → endpoint.online() resolves.
  2. Wrong token → home_relay_status reports 'not authorized' and
     online() never resolves.
  3. Missing token → online() never resolves.
  4. Mixed map (gated + public) authenticates only the gated relay and
     still comes online.

This is the missing end-to-end check: if iroh changes how
with_auth_token is sent on the WebSocket upgrade, or if a future refactor
drops relay_auths from the call chain, these tests fail.

* fix(relay-auth): redact token portion from parser error messages

Copilot review surfaced a real leak: parse_relay_auth_pair includes the
full URL=TOKEN input in error strings via {s:?}. If a user mistypes the
flag, the bearer token ends up in terminal output, logs, and bug
reports.

Redaction rules:

- Missing '=' separator: redact whole input (we cannot tell URL from
  token).
- Empty URL ('=token'): redact (the value after '=' is the secret).
- Empty token ('URL='): URL is safe to name; no token to leak.

New test parser_errors_never_leak_token_portion pins the property:
inject a known token string, drive each error path, assert the string
never appears in the error message.

* test(mesh): bind empty relay-auth map locally instead of &HashMap::new()

Copilot review pass flagged 7 sites where the test threads
`&std::collections::HashMap::new()` straight into
`maybe_start_control_listener(...).await`. Compiles fine today because
the callee only borrows the reference synchronously before any internal
`.await`, but the pattern is fragile: if the signature ever shifts the
borrow across an await point, every test breaks at once.

Centralise on an `empty_relay_auths()` helper bound to a local before
each call. Cheap future-proofing, removes 7 lookalikes from review
chatter on future PRs touching this file, no behaviour change.

All 119 mesh tests still pass.

* style: rustfmt for Rust 2024 edition import ordering

cargo fmt under edition 2024 sorts uppercase types alongside lowercase
modules, which reorders the gated_relay_e2e_tests imports. Pure
formatting, no logic change.

Also adds dist/native-sdk*/ and dist/llama-stage-static/ to .gitignore
so locally-packaged release artifacts don't leak into commits.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants