9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
1066 lines
43 KiB
Rust
1066 lines
43 KiB
Rust
//! Relay-driven mesh lifecycle smoke — the full Buzz join story, CI-shaped.
|
|
//!
|
|
//! Unlike `mesh_serve_client_smoke` (Mdns + hand-carried invite token) and
|
|
//! `mesh_admission_smoke` (allowlist mechanics, token passed out-of-band),
|
|
//! this harness exercises the *relay as the control plane*, the way the
|
|
//! desktop app actually joins a mesh:
|
|
//!
|
|
//! 1. MEMBERSHIP — two Nostr identities are added to a membership-gated
|
|
//! buzz-relay (kind:13534 roster via buzz-admin); a third is not.
|
|
//! 2. ADVERTISE — each member process publishes a client-signed kind:30003
|
|
//! status note carrying its MeshLLM owner binding
|
|
//! (`ownerId`/`ownerVerifyingKey`/`ownerBindingSig`) and, for the serve
|
|
//! node, `serveTargets[].endpointAddr` covered by an endpoint binding
|
|
//! signature — the exact payload shape the desktop coordinator publishes.
|
|
//! 3. TRUST — the serve node derives its admission allowlist from the relay:
|
|
//! status notes ∩ membership roster, and requires the *exact* expected
|
|
//! owner set before starting with `TrustPolicy::Allowlist`.
|
|
//! 4. JOIN — the client node discovers the serve target from the relay,
|
|
//! verifies both bindings and membership, and dials the advertised
|
|
//! endpoint. No token is ever handed over out-of-band.
|
|
//! 5. INFER — a chat completion against the client's local OpenAI endpoint
|
|
//! routes over QUIC to the serve node's model.
|
|
//! 6. DENY — the stranger's NIP-42 auth must fail with the relay's
|
|
//! membership rejection, and even when handed the leaked endpoint
|
|
//! address directly it must not complete an inference — *while the
|
|
//! trusted client re-verifies inference immediately afterwards*, so a
|
|
//! sick serve node cannot masquerade as an admission denial.
|
|
//!
|
|
//! ## Scope: an independent protocol harness
|
|
//!
|
|
//! This harness speaks the same wire protocol as the desktop
|
|
//! (`desktop/src-tauri/src/mesh_llm/{identity,discovery,coordinator}.rs`) but
|
|
//! deliberately re-implements the binding/verification logic rather than
|
|
//! linking desktop code (the desktop crate is outside this workspace). The
|
|
//! payloads and canonical binding bytes are kept byte-identical — see the
|
|
//! keep-in-sync comments below. A regression inside the desktop's own
|
|
//! discovery filtering is covered by the desktop unit tests, not this smoke;
|
|
//! what this smoke proves is that the relay + mesh-llm SDK + admission stack
|
|
//! actually support the lifecycle end to end.
|
|
//!
|
|
//! One process per node is load-bearing: mesh-llm keeps process-global state
|
|
//! (node endpoint key, ownership attestation under `~/.mesh-llm`), so each
|
|
//! role runs with an isolated HOME — exactly how the desktop runs it (one
|
|
//! machine = one node).
|
|
//!
|
|
//! Run in CI via `scripts/ci-mesh-lifecycle-smoke.sh` (which provisions the
|
|
//! membership-gated relay), or locally:
|
|
//!
|
|
//! ```text
|
|
//! ./scripts/start-relay-for-tests.sh # with membership env set
|
|
//! cargo build --profile ci -p buzz-admin
|
|
//! BUZZ_ADMIN_BIN=target/ci/buzz-admin \
|
|
//! cargo run --profile ci -p buzz-relay --example mesh_relay_lifecycle_smoke
|
|
//! ```
|
|
use std::collections::BTreeSet;
|
|
use std::io::{BufRead, Write};
|
|
use std::process::{Child, ChildStdout, Command, ExitStatus, Stdio};
|
|
use std::sync::mpsc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use buzz_test_client::BuzzTestClient;
|
|
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
|
use mesh_llm_host_runtime::crypto::{load_keystore, save_keystore, OwnerKeypair};
|
|
use mesh_llm_sdk::{client, serve, MeshDiscoveryMode, TrustPolicy};
|
|
use nostr::{Alphabet, Event, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag};
|
|
use sha2::{Digest, Sha256};
|
|
|
|
/// NIP-51 bookmark set reused for client-owned mesh discovery notes
|
|
/// (`KIND_BUZZ_MESH_MEMBER_STATUS` in the desktop coordinator).
|
|
const KIND_MESH_STATUS: u16 = 30_003;
|
|
/// NIP-43 membership roster snapshot.
|
|
const KIND_MEMBERSHIP: u16 = 13_534;
|
|
const STATUS_D_TAG_PREFIX: &str = "buzz-mesh-member-status";
|
|
const STATUS_K_TAG: &str = "buzz-mesh-status";
|
|
|
|
/// Small, real instruct model; same ref the sibling mesh examples use.
|
|
const DEFAULT_MODEL: &str = "jc-builds/SmolLM2-135M-Instruct-Q4_K_M-GGUF:Q4_K_M";
|
|
|
|
const SERVE_API_PORT: u16 = 19_537;
|
|
const SERVE_CONSOLE_PORT: u16 = 13_331;
|
|
const CLIENT_API_PORT: u16 = 19_538;
|
|
const CLIENT_CONSOLE_PORT: u16 = 13_332;
|
|
const STRANGER_API_PORT: u16 = 19_539;
|
|
const STRANGER_CONSOLE_PORT: u16 = 13_333;
|
|
|
|
/// The trusted client sees the model within seconds on one box; this bounds
|
|
/// the stranger's chance to (fail to) see it. Both windows are overridable
|
|
/// via env (`MESH_CLIENT_WINDOW_SECS` / `MESH_STRANGER_WINDOW_SECS`) so CI
|
|
/// can pin longer windows on slow shared runners instead of re-running the
|
|
/// whole job.
|
|
const CLIENT_WINDOW_SECS: u64 = 180;
|
|
const STRANGER_WINDOW_SECS: u64 = 60;
|
|
|
|
fn window_secs(name: &str, default: u64) -> u64 {
|
|
std::env::var(name)
|
|
.ok()
|
|
.and_then(|value| value.trim().parse().ok())
|
|
.unwrap_or(default)
|
|
}
|
|
|
|
fn client_window() -> Duration {
|
|
Duration::from_secs(window_secs("MESH_CLIENT_WINDOW_SECS", CLIENT_WINDOW_SECS))
|
|
}
|
|
|
|
fn stranger_window() -> Duration {
|
|
Duration::from_secs(window_secs(
|
|
"MESH_STRANGER_WINDOW_SECS",
|
|
STRANGER_WINDOW_SECS,
|
|
))
|
|
}
|
|
|
|
/// Marker the orchestrator writes to the client child's stdin to request the
|
|
/// post-attack inference re-verification.
|
|
const VERIFY_AGAIN: &str = "VERIFY_AGAIN";
|
|
|
|
fn main() -> anyhow::Result<()> {
|
|
match std::env::var("MESH_ROLE").ok().as_deref() {
|
|
Some("serve") => run_role(role_serve()),
|
|
Some("client") => run_role(role_client()),
|
|
Some("stranger") => run_role(role_stranger()),
|
|
_ => orchestrate(),
|
|
}
|
|
}
|
|
|
|
/// Run a role future and exit without unwinding through C++ static
|
|
/// destructors: once the native runtime has initialized, normal process exit
|
|
/// aborts inside ggml's Metal/CPU device teardown, which would mask the real
|
|
/// error under a GGML_ASSERT backtrace.
|
|
fn run_role(role: impl std::future::Future<Output = anyhow::Result<()>>) -> anyhow::Result<()> {
|
|
match runtime()?.block_on(role) {
|
|
Ok(()) => std::process::exit(0),
|
|
Err(error) => {
|
|
eprintln!("[role] FAILED: {error:#}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// mesh-llm's async chains overflow tokio's default 2 MiB worker stacks; the
|
|
/// desktop and the mesh binary itself both run 8 MiB workers for this reason.
|
|
fn runtime() -> anyhow::Result<tokio::runtime::Runtime> {
|
|
Ok(tokio::runtime::Builder::new_multi_thread()
|
|
.enable_all()
|
|
.thread_stack_size(8 * 1024 * 1024)
|
|
.build()?)
|
|
}
|
|
|
|
fn env(name: &str) -> anyhow::Result<String> {
|
|
std::env::var(name).map_err(|_| anyhow::anyhow!("{name} is required for this role"))
|
|
}
|
|
|
|
fn relay_ws_url() -> String {
|
|
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
|
}
|
|
|
|
async fn init_native_runtime() -> anyhow::Result<()> {
|
|
// The dynamic host runtime installs the recommended signed native runtime
|
|
// on first use when none is cached — the same SDK-owned path the desktop
|
|
// relies on. CI caches the install dir across runs.
|
|
mesh_llm_host_runtime::initialize_host_runtime()
|
|
.await
|
|
.map_err(|error| anyhow::anyhow!("MeshLLM host runtime init failed: {error:#}"))
|
|
}
|
|
|
|
// ── Owner binding payloads ───────────────────────────────────────────────────
|
|
// Byte-for-byte the desktop's `identity::member_binding_bytes` /
|
|
// `member_endpoint_binding_bytes`; the client role verifies exactly what the
|
|
// desktop coordinator publishes. Keep in sync with
|
|
// `desktop/src-tauri/src/mesh_llm/identity.rs`.
|
|
|
|
fn member_binding_bytes(member_pubkey: &str) -> Vec<u8> {
|
|
format!(
|
|
"buzz-mesh-owner-binding-v1:{}",
|
|
member_pubkey.trim().to_ascii_lowercase()
|
|
)
|
|
.into_bytes()
|
|
}
|
|
|
|
fn member_endpoint_binding_bytes(member_pubkey: &str, endpoint_tokens: &[String]) -> Vec<u8> {
|
|
let mut endpoints = endpoint_tokens
|
|
.iter()
|
|
.map(|token| token.trim())
|
|
.filter(|token| !token.is_empty())
|
|
.collect::<Vec<_>>();
|
|
endpoints.sort_unstable();
|
|
endpoints.dedup();
|
|
|
|
let mut digest = Sha256::new();
|
|
for endpoint in endpoints {
|
|
digest.update((endpoint.len() as u64).to_be_bytes());
|
|
digest.update(endpoint.as_bytes());
|
|
}
|
|
format!(
|
|
"buzz-mesh-owner-endpoint-binding-v1:{}:{}",
|
|
member_pubkey.trim().to_ascii_lowercase(),
|
|
hex::encode(digest.finalize())
|
|
)
|
|
.into_bytes()
|
|
}
|
|
|
|
// ── Relay I/O ────────────────────────────────────────────────────────────────
|
|
|
|
fn status_filter() -> Filter {
|
|
Filter::new()
|
|
.kind(Kind::Custom(KIND_MESH_STATUS))
|
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::K), STATUS_K_TAG)
|
|
.limit(100)
|
|
}
|
|
|
|
fn membership_filter() -> Filter {
|
|
Filter::new().kind(Kind::Custom(KIND_MEMBERSHIP)).limit(1)
|
|
}
|
|
|
|
async fn query_events(
|
|
relay: &mut BuzzTestClient,
|
|
filters: Vec<Filter>,
|
|
) -> anyhow::Result<Vec<Event>> {
|
|
let sid = format!("mesh-lifecycle-{}", uuid::Uuid::new_v4().simple());
|
|
relay.subscribe(&sid, filters).await?;
|
|
let events = relay
|
|
.collect_until_eose(&sid, Duration::from_secs(10))
|
|
.await?;
|
|
relay.close_subscription(&sid).await?;
|
|
Ok(events)
|
|
}
|
|
|
|
/// Publish this member's client-signed kind:30003 discovery note — the same
|
|
/// payload the desktop coordinator's `bind_payload_to_member` +
|
|
/// `build_status_report_event` produce.
|
|
async fn publish_status(
|
|
relay: &mut BuzzTestClient,
|
|
keys: &Keys,
|
|
owner: &OwnerKeypair,
|
|
serve_targets: &[(String, String)],
|
|
) -> anyhow::Result<()> {
|
|
let member_pubkey = keys.public_key().to_hex();
|
|
let endpoint_tokens: Vec<String> = serve_targets
|
|
.iter()
|
|
.map(|(_, endpoint)| endpoint.clone())
|
|
.collect();
|
|
let targets_json: Vec<serde_json::Value> = serve_targets
|
|
.iter()
|
|
.map(|(model, endpoint)| serde_json::json!({ "modelId": model, "endpointAddr": endpoint }))
|
|
.collect();
|
|
let models_json: Vec<serde_json::Value> = serve_targets
|
|
.iter()
|
|
.map(|(model, _)| serde_json::json!({ "id": model }))
|
|
.collect();
|
|
let payload = serde_json::json!({
|
|
"ownerId": owner.owner_id(),
|
|
"ownerVerifyingKey": hex::encode(owner.verifying_key().as_bytes()),
|
|
"ownerBindingSig":
|
|
hex::encode(owner.sign_bytes(&member_binding_bytes(&member_pubkey))),
|
|
"ownerEndpointBindingSig": hex::encode(owner.sign_bytes(
|
|
&member_endpoint_binding_bytes(&member_pubkey, &endpoint_tokens),
|
|
)),
|
|
"serveTargets": targets_json,
|
|
"models": models_json,
|
|
});
|
|
let d_tag = format!("{STATUS_D_TAG_PREFIX}:{}", owner.owner_id());
|
|
let d = Tag::parse(["d", d_tag.as_str()]).map_err(|error| anyhow::anyhow!("{error}"))?;
|
|
let k = Tag::parse(["k", STATUS_K_TAG]).map_err(|error| anyhow::anyhow!("{error}"))?;
|
|
let event = EventBuilder::new(Kind::Custom(KIND_MESH_STATUS), payload.to_string())
|
|
.tags([d, k])
|
|
.sign_with_keys(keys)?;
|
|
let ok = relay.send_event(event).await?;
|
|
anyhow::ensure!(
|
|
ok.accepted,
|
|
"relay rejected mesh status note: {}",
|
|
ok.message
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
// ── Discovery verification (mirrors desktop `discovery.rs`) ─────────────────
|
|
|
|
fn membership_set(events: &[Event]) -> Option<BTreeSet<String>> {
|
|
events
|
|
.iter()
|
|
.filter(|event| event.kind.as_u16() == KIND_MEMBERSHIP)
|
|
.max_by_key(|event| event.created_at)
|
|
.map(|event| {
|
|
event
|
|
.tags
|
|
.iter()
|
|
.filter_map(|tag| {
|
|
let slice = tag.as_slice();
|
|
let name = slice.first()?;
|
|
if name != "member" && name != "p" {
|
|
return None;
|
|
}
|
|
slice
|
|
.get(1)
|
|
.map(|pubkey| pubkey.trim().to_ascii_lowercase())
|
|
})
|
|
.filter(|pubkey| !pubkey.is_empty())
|
|
.collect()
|
|
})
|
|
}
|
|
|
|
/// `ownerId` must equal sha256(ownerVerifyingKey) and `ownerBindingSig` must
|
|
/// verify against the note's Nostr author — a stored note cannot be re-pointed
|
|
/// at someone else's mesh identity.
|
|
fn verified_owner_id(event: &Event) -> Option<String> {
|
|
let content = serde_json::from_str::<serde_json::Value>(&event.content).ok()?;
|
|
let owner_id = content.get("ownerId")?.as_str()?.trim();
|
|
let verifying_key_bytes: [u8; 32] =
|
|
hex::decode(content.get("ownerVerifyingKey")?.as_str()?.trim())
|
|
.ok()?
|
|
.try_into()
|
|
.ok()?;
|
|
if owner_id != hex::encode(Sha256::digest(verifying_key_bytes)) {
|
|
return None;
|
|
}
|
|
let signature_bytes = hex::decode(content.get("ownerBindingSig")?.as_str()?.trim()).ok()?;
|
|
let signature = Signature::from_slice(&signature_bytes).ok()?;
|
|
let verifying_key = VerifyingKey::from_bytes(&verifying_key_bytes).ok()?;
|
|
verifying_key
|
|
.verify(&member_binding_bytes(&event.pubkey.to_hex()), &signature)
|
|
.ok()?;
|
|
Some(owner_id.to_string())
|
|
}
|
|
|
|
/// Extract `(model_id, endpoint_addr)` pairs from a status note, but only when
|
|
/// the endpoint binding signature covers exactly the advertised tokens.
|
|
fn verified_serve_targets(event: &Event) -> Vec<(String, String)> {
|
|
let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) else {
|
|
return Vec::new();
|
|
};
|
|
let targets: Vec<(String, String)> = content
|
|
.get("serveTargets")
|
|
.and_then(serde_json::Value::as_array)
|
|
.map(|targets| {
|
|
targets
|
|
.iter()
|
|
.filter_map(|target| {
|
|
let model = target.get("modelId")?.as_str()?.trim().to_string();
|
|
let endpoint = target.get("endpointAddr")?.as_str()?.trim().to_string();
|
|
(!endpoint.is_empty()).then_some((model, endpoint))
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
if targets.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
let endpoint_tokens: Vec<String> = targets
|
|
.iter()
|
|
.map(|(_, endpoint)| endpoint.clone())
|
|
.collect();
|
|
let Some(verifying_key) = content
|
|
.get("ownerVerifyingKey")
|
|
.and_then(serde_json::Value::as_str)
|
|
.and_then(|value| hex::decode(value.trim()).ok())
|
|
.and_then(|value| <[u8; 32]>::try_from(value).ok())
|
|
.and_then(|value| VerifyingKey::from_bytes(&value).ok())
|
|
else {
|
|
return Vec::new();
|
|
};
|
|
let Some(signature) = content
|
|
.get("ownerEndpointBindingSig")
|
|
.and_then(serde_json::Value::as_str)
|
|
.and_then(|value| hex::decode(value.trim()).ok())
|
|
.and_then(|value| Signature::from_slice(&value).ok())
|
|
else {
|
|
return Vec::new();
|
|
};
|
|
let bytes = member_endpoint_binding_bytes(&event.pubkey.to_hex(), &endpoint_tokens);
|
|
if verifying_key.verify(&bytes, &signature).is_err() {
|
|
return Vec::new();
|
|
}
|
|
targets
|
|
}
|
|
|
|
/// Owner ids of current members with valid owner bindings — the relay-derived
|
|
/// admission roster (`owner_ids_from_events` semantics).
|
|
fn member_owner_ids(events: &[Event]) -> BTreeSet<String> {
|
|
let Some(members) = membership_set(events) else {
|
|
return BTreeSet::new();
|
|
};
|
|
events
|
|
.iter()
|
|
.filter(|event| event.kind.as_u16() == KIND_MESH_STATUS)
|
|
.filter(|event| members.contains(&event.pubkey.to_hex().to_ascii_lowercase()))
|
|
.filter_map(verified_owner_id)
|
|
.collect()
|
|
}
|
|
|
|
// ── Roles ────────────────────────────────────────────────────────────────────
|
|
|
|
/// SERVE (member A): publish presence, derive the allowlist from the relay,
|
|
/// require the exact expected owner set, start an allowlist serve node,
|
|
/// publish the endpoint, park.
|
|
async fn role_serve() -> anyhow::Result<()> {
|
|
init_native_runtime().await?;
|
|
let model = std::env::var("MESH_SMOKE_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string());
|
|
let keys = Keys::parse(&env("BUZZ_MEMBER_NSEC")?)?;
|
|
let owner = load_keystore(std::path::Path::new(&env("MESH_OWNER_KEY")?), None)
|
|
.map_err(|error| anyhow::anyhow!("loading serve owner keystore: {error}"))?;
|
|
// The exact owner ids the orchestrator provisioned for members A and B.
|
|
// Waiting for this exact set (not a count) means the allowlist can only
|
|
// ever contain the intended identities.
|
|
let expected_owners: BTreeSet<String> = env("MESH_EXPECTED_OWNERS")?
|
|
.split(',')
|
|
.map(|id| id.trim().to_string())
|
|
.filter(|id| !id.is_empty())
|
|
.collect();
|
|
anyhow::ensure!(
|
|
expected_owners.contains(&owner.owner_id()),
|
|
"serve owner id is not in MESH_EXPECTED_OWNERS"
|
|
);
|
|
|
|
let mut relay = BuzzTestClient::connect(&relay_ws_url(), &keys)
|
|
.await
|
|
.map_err(|error| anyhow::anyhow!("serve member relay connect: {error}"))?;
|
|
publish_status(&mut relay, &keys, &owner, &[]).await?;
|
|
println!("STATUS_PUBLISHED");
|
|
|
|
// TRUST: wait until every expected member owner is visible via the relay
|
|
// (statuses ∩ roster), then admit exactly those owners.
|
|
let deadline = Instant::now() + Duration::from_secs(120);
|
|
loop {
|
|
let events = query_events(&mut relay, vec![status_filter(), membership_filter()]).await?;
|
|
let mut visible = member_owner_ids(&events);
|
|
visible.insert(owner.owner_id());
|
|
if visible.is_superset(&expected_owners) {
|
|
break;
|
|
}
|
|
anyhow::ensure!(
|
|
Instant::now() < deadline,
|
|
"timed out waiting for expected owners {expected_owners:?}; saw {visible:?}"
|
|
);
|
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
}
|
|
let allowlist: Vec<String> = expected_owners.iter().cloned().collect();
|
|
println!("ALLOWLIST:{}", allowlist.join(","));
|
|
// The upcoming serve::start() blocks through a possibly multi-minute model
|
|
// download; an idle relay socket gets closed under it. Reconnect after.
|
|
let _ = relay.disconnect().await;
|
|
|
|
let cfg = serve::EmbeddedServeConfig::builder()
|
|
.model(&model)
|
|
.api_port(SERVE_API_PORT)
|
|
.console_port(SERVE_CONSOLE_PORT)
|
|
// Desktop no-leak invariants: never publish mesh presence, never
|
|
// auto-discover. The Buzz relay is the only discovery surface.
|
|
.publish(false)
|
|
.auto_join(false)
|
|
.discovery_mode(MeshDiscoveryMode::Nostr)
|
|
.console_ui(true)
|
|
.startup_timeout(Duration::from_secs(600))
|
|
.owner_key(env("MESH_OWNER_KEY")?)
|
|
.owner_required(true)
|
|
.trust_policy(TrustPolicy::Allowlist)
|
|
.trust_owners(allowlist)
|
|
.build();
|
|
let node = serve::start(cfg).await?;
|
|
let endpoint = node
|
|
.invite_token()
|
|
.map(str::to_string)
|
|
.ok_or_else(|| anyhow::anyhow!("serve node produced no endpoint address"))?;
|
|
println!("ENDPOINT:{endpoint}");
|
|
|
|
let http = reqwest::Client::new();
|
|
let base = node.api_base_url().to_string();
|
|
let served = wait_for_model(&http, &base, Duration::from_secs(600))
|
|
.await?
|
|
.ok_or_else(|| anyhow::anyhow!("serve node never loaded the model"))?;
|
|
|
|
// ADVERTISE: refresh the status note with the live serve target, exactly
|
|
// what the desktop's 45s heartbeat publishes once serving. Fresh relay
|
|
// connection — the pre-download socket has long been idle-closed.
|
|
let mut relay = BuzzTestClient::connect(&relay_ws_url(), &keys)
|
|
.await
|
|
.map_err(|error| anyhow::anyhow!("serve member relay reconnect: {error}"))?;
|
|
publish_status(
|
|
&mut relay,
|
|
&keys,
|
|
&owner,
|
|
&[(served.clone(), endpoint.clone())],
|
|
)
|
|
.await?;
|
|
println!("READY:{served}");
|
|
|
|
// Park; the orchestrator kills this process when the run is over.
|
|
loop {
|
|
tokio::time::sleep(Duration::from_secs(3600)).await;
|
|
}
|
|
}
|
|
|
|
/// CLIENT (member B): publish presence, discover + verify the serve target
|
|
/// from the relay, dial it, prove inference routes over the mesh — then wait
|
|
/// for the orchestrator's `VERIFY_AGAIN` and re-prove inference after the
|
|
/// stranger's admission attack, so denial is differential, not absence.
|
|
async fn role_client() -> anyhow::Result<()> {
|
|
init_native_runtime().await?;
|
|
let keys = Keys::parse(&env("BUZZ_MEMBER_NSEC")?)?;
|
|
let owner = load_keystore(std::path::Path::new(&env("MESH_OWNER_KEY")?), None)
|
|
.map_err(|error| anyhow::anyhow!("loading client owner keystore: {error}"))?;
|
|
|
|
let mut relay = BuzzTestClient::connect(&relay_ws_url(), &keys)
|
|
.await
|
|
.map_err(|error| anyhow::anyhow!("client member relay connect: {error}"))?;
|
|
publish_status(&mut relay, &keys, &owner, &[]).await?;
|
|
println!("STATUS_PUBLISHED");
|
|
|
|
// JOIN: poll the relay until a *verified* serve target from another member
|
|
// appears — membership roster, owner binding, and endpoint binding all
|
|
// checked, mirroring `availability_from_events`.
|
|
let deadline = Instant::now() + Duration::from_secs(900);
|
|
let (endpoint, allowlist) = loop {
|
|
let events = query_events(&mut relay, vec![status_filter(), membership_filter()]).await?;
|
|
let members = membership_set(&events).unwrap_or_default();
|
|
let target = events
|
|
.iter()
|
|
.filter(|event| event.kind.as_u16() == KIND_MESH_STATUS)
|
|
.filter(|event| members.contains(&event.pubkey.to_hex().to_ascii_lowercase()))
|
|
.filter(|event| verified_owner_id(event).is_some_and(|id| id != owner.owner_id()))
|
|
.flat_map(verified_serve_targets)
|
|
.next();
|
|
if let Some((_, endpoint)) = target {
|
|
let owners: Vec<String> = member_owner_ids(&events).into_iter().collect();
|
|
break (endpoint, owners);
|
|
}
|
|
anyhow::ensure!(
|
|
Instant::now() < deadline,
|
|
"timed out waiting for a verified serve target on the relay"
|
|
);
|
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
|
};
|
|
println!("TARGET_FOUND");
|
|
|
|
let cfg = client::EmbeddedClientConfig::builder()
|
|
.api_port(CLIENT_API_PORT)
|
|
.console_port(CLIENT_CONSOLE_PORT)
|
|
.publish(false)
|
|
.auto_join(false)
|
|
.discovery_mode(MeshDiscoveryMode::Nostr)
|
|
.console_ui(true)
|
|
.startup_timeout(Duration::from_secs(180))
|
|
.owner_key(env("MESH_OWNER_KEY")?)
|
|
.owner_required(true)
|
|
.trust_policy(TrustPolicy::Allowlist)
|
|
.trust_owners(allowlist)
|
|
.build();
|
|
let node = client::start(cfg).await?;
|
|
// The relay-discovered endpoint is the dial target — the same
|
|
// `dial_endpoint_addr` step the desktop's join watcher performs. The
|
|
// desktop's watcher retries every 15s (a first QUIC dial can time out
|
|
// while the serve node's endpoint is still warming up). mesh-llm itself
|
|
// retries internally per attempt, so keep the outer budget small.
|
|
let mut dial_result = Ok(());
|
|
for attempt in 1..=3u32 {
|
|
dial_result = node.join_token(&endpoint).await;
|
|
match &dial_result {
|
|
Ok(()) => break,
|
|
Err(error) => {
|
|
eprintln!("[client] dial attempt {attempt}/3 failed: {error:#}");
|
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
|
}
|
|
}
|
|
}
|
|
dial_result?;
|
|
|
|
let http = reqwest::Client::new();
|
|
let base = node.api_base_url().to_string();
|
|
let Some(model) = wait_for_model(&http, &base, client_window()).await? else {
|
|
println!("NONE");
|
|
let _ = node.stop().await;
|
|
std::process::exit(0);
|
|
};
|
|
println!("SEEN:{model}");
|
|
match try_completion(&http, &base, &model).await {
|
|
Ok(content) => println!("INFER_OK:{content}"),
|
|
Err(error) => {
|
|
println!("INFER_FAIL:{error}");
|
|
let _ = node.stop().await;
|
|
std::process::exit(0);
|
|
}
|
|
}
|
|
|
|
// Post-attack health proof: hold the mesh session open until the
|
|
// orchestrator has run the stranger, then prove the serve node still
|
|
// routes trusted inference. This is what makes the stranger's failure an
|
|
// admission denial rather than a dead server.
|
|
let line = tokio::task::spawn_blocking(|| {
|
|
let mut line = String::new();
|
|
std::io::stdin().read_line(&mut line).map(|_| line)
|
|
})
|
|
.await??;
|
|
if line.trim() == VERIFY_AGAIN {
|
|
match try_completion(&http, &base, &model).await {
|
|
Ok(content) => println!("INFER_AGAIN_OK:{content}"),
|
|
Err(error) => println!("INFER_AGAIN_FAIL:{error}"),
|
|
}
|
|
}
|
|
let _ = node.stop().await;
|
|
// Skip C++ static destructors (ggml aborts in global teardown).
|
|
std::process::exit(0);
|
|
}
|
|
|
|
/// STRANGER (non-member C): NIP-42 auth must fail with the relay's membership
|
|
/// rejection, and the mesh must not route inference for it even with the
|
|
/// leaked endpoint address.
|
|
async fn role_stranger() -> anyhow::Result<()> {
|
|
let keys = Keys::parse(&env("BUZZ_MEMBER_NSEC")?)?;
|
|
let leaked_endpoint = env("MESH_LEAKED_ENDPOINT")?;
|
|
|
|
// DENY (relay read): the membership-gated relay must reject the
|
|
// stranger's NIP-42 auth with its membership error specifically. Any
|
|
// other failure (relay down, timeout) is inconclusive and fails the
|
|
// test; a successful auth is a gating regression and also fails.
|
|
match BuzzTestClient::connect(&relay_ws_url(), &keys).await {
|
|
Err(error) => {
|
|
let message = error.to_string();
|
|
if message.contains("not a relay member") {
|
|
println!("RELAY_DENIED_MEMBERSHIP");
|
|
} else {
|
|
println!("RELAY_ERR:{message}");
|
|
}
|
|
}
|
|
Ok(mut relay) => {
|
|
let statuses = query_events(&mut relay, vec![status_filter()])
|
|
.await
|
|
.map(|events| {
|
|
events
|
|
.iter()
|
|
.filter(|event| event.kind.as_u16() == KIND_MESH_STATUS)
|
|
.count()
|
|
})
|
|
.unwrap_or(usize::MAX);
|
|
println!("RELAY_AUTH_OK:{statuses}");
|
|
let _ = relay.disconnect().await;
|
|
}
|
|
}
|
|
|
|
// DENY (admission): dial the serve node directly with the leaked endpoint.
|
|
// The stranger's owner id is not on the allowlist, so the mesh must refuse
|
|
// to route anything to it. Note the dial itself may locally "succeed" —
|
|
// mesh-llm applies the receiving node's owner policy after the handshake —
|
|
// so the decisive probe is routed inference, cross-checked against the
|
|
// trusted client's post-attack inference by the orchestrator.
|
|
init_native_runtime().await?;
|
|
let cfg = client::EmbeddedClientConfig::builder()
|
|
.api_port(STRANGER_API_PORT)
|
|
.console_port(STRANGER_CONSOLE_PORT)
|
|
.publish(false)
|
|
.auto_join(false)
|
|
.discovery_mode(MeshDiscoveryMode::Nostr)
|
|
.console_ui(true)
|
|
.startup_timeout(Duration::from_secs(180))
|
|
.owner_key(env("MESH_OWNER_KEY")?)
|
|
.owner_required(true)
|
|
.build();
|
|
let node = client::start(cfg).await?;
|
|
let _ = node.join_token(&leaked_endpoint).await;
|
|
|
|
let http = reqwest::Client::new();
|
|
let base = node.api_base_url().to_string();
|
|
match wait_for_model(&http, &base, stranger_window()).await? {
|
|
Some(model) => {
|
|
println!("SEEN:{model}");
|
|
match try_completion(&http, &base, &model).await {
|
|
Ok(content) => println!("INFER_OK:{content}"),
|
|
Err(error) => println!("INFER_FAIL:{error}"),
|
|
}
|
|
}
|
|
None => println!("NONE"),
|
|
}
|
|
let _ = node.stop().await;
|
|
std::process::exit(0);
|
|
}
|
|
|
|
// ── Orchestrator ─────────────────────────────────────────────────────────────
|
|
|
|
fn orchestrate() -> anyhow::Result<()> {
|
|
let model = std::env::var("MESH_SMOKE_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string());
|
|
eprintln!("[lifecycle] model: {model}");
|
|
let admin =
|
|
std::env::var("BUZZ_ADMIN_BIN").unwrap_or_else(|_| "target/ci/buzz-admin".to_string());
|
|
anyhow::ensure!(
|
|
std::path::Path::new(&admin).exists(),
|
|
"buzz-admin binary not found at {admin} (set BUZZ_ADMIN_BIN)"
|
|
);
|
|
|
|
let scratch = std::env::temp_dir().join(format!("buzz-mesh-lifecycle-{}", std::process::id()));
|
|
std::fs::create_dir_all(&scratch)?;
|
|
|
|
// Nostr identities: A (serve member), B (client member), C (stranger).
|
|
let member_a = Keys::generate();
|
|
let member_b = Keys::generate();
|
|
let stranger = Keys::generate();
|
|
|
|
// MeshLLM owner keystores, one per role. The orchestrator keeps the owner
|
|
// ids so the serve role can gate on the exact expected identity set.
|
|
let make_owner = |name: &str| -> anyhow::Result<(String, String)> {
|
|
let keypair = OwnerKeypair::generate();
|
|
let path = scratch.join(format!("{name}.keystore.json"));
|
|
save_keystore(&path, &keypair, None, true)
|
|
.map_err(|error| anyhow::anyhow!("saving {name} keystore: {error}"))?;
|
|
Ok((path.display().to_string(), keypair.owner_id()))
|
|
};
|
|
let (serve_key, serve_owner_id) = make_owner("serve")?;
|
|
let (client_key, client_owner_id) = make_owner("client")?;
|
|
let (stranger_key, _stranger_owner_id) = make_owner("stranger")?;
|
|
let expected_owners = format!("{serve_owner_id},{client_owner_id}");
|
|
|
|
// MEMBERSHIP: A and B become relay members via buzz-admin (publishes the
|
|
// kind:13534 roster snapshot). C is deliberately not added.
|
|
for (label, keys) in [("A", &member_a), ("B", &member_b)] {
|
|
let status = Command::new(&admin)
|
|
.args(["add-member", "--pubkey", &keys.public_key().to_hex()])
|
|
.status()?;
|
|
anyhow::ensure!(status.success(), "buzz-admin add-member {label} failed");
|
|
eprintln!(
|
|
"[lifecycle] member {label} added: {}",
|
|
keys.public_key().to_hex()
|
|
);
|
|
}
|
|
|
|
// Isolated HOMEs (mesh-llm keeps node identity under ~/.mesh-llm), with
|
|
// the native runtime + HF caches resolved from the real environment first.
|
|
let native_cache = std::env::var_os("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR")
|
|
.map(std::path::PathBuf::from)
|
|
.unwrap_or(real_cache_dir()?.join("mesh-llm/native-runtimes"));
|
|
let hf_cache = std::env::var_os("HF_HUB_CACHE")
|
|
.map(std::path::PathBuf::from)
|
|
.unwrap_or(real_cache_dir()?.join("huggingface/hub"));
|
|
let role_home = |name: &str| -> anyhow::Result<String> {
|
|
let home = scratch.join(format!("{name}-home"));
|
|
std::fs::create_dir_all(&home)?;
|
|
Ok(home.display().to_string())
|
|
};
|
|
|
|
let exe = std::env::current_exe()?;
|
|
let secret_hex = |keys: &Keys| format!("{}", keys.secret_key().display_secret());
|
|
|
|
// SERVE child (member A).
|
|
eprintln!("[lifecycle] starting SERVE member (relay-derived allowlist)...");
|
|
let mut serve_child = Command::new(&exe)
|
|
.env("MESH_ROLE", "serve")
|
|
.env("MESH_SMOKE_MODEL", &model)
|
|
.env("BUZZ_MEMBER_NSEC", secret_hex(&member_a))
|
|
.env("MESH_OWNER_KEY", &serve_key)
|
|
.env("MESH_EXPECTED_OWNERS", &expected_owners)
|
|
.env("HOME", role_home("serve")?)
|
|
.env("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR", &native_cache)
|
|
.env("HF_HUB_CACHE", &hf_cache)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::inherit())
|
|
.spawn()?;
|
|
let serve_lines = spawn_line_reader(
|
|
serve_child
|
|
.stdout
|
|
.take()
|
|
.ok_or_else(|| anyhow::anyhow!("no serve stdout"))?,
|
|
);
|
|
let serve_guard = KillOnDrop(&mut serve_child);
|
|
expect_line(&serve_lines, "STATUS_PUBLISHED", Duration::from_secs(180))?;
|
|
eprintln!("[lifecycle] serve member published its discovery note");
|
|
|
|
// CLIENT child (member B) — started now so the serve node can see B's
|
|
// owner binding on the relay and admit it. stdin stays piped for the
|
|
// post-attack VERIFY_AGAIN request.
|
|
eprintln!("[lifecycle] starting CLIENT member (relay-driven join)...");
|
|
let mut client_child = Command::new(&exe)
|
|
.env("MESH_ROLE", "client")
|
|
.env("BUZZ_MEMBER_NSEC", secret_hex(&member_b))
|
|
.env("MESH_OWNER_KEY", &client_key)
|
|
.env("HOME", role_home("client")?)
|
|
.env("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR", &native_cache)
|
|
.env("HF_HUB_CACHE", &hf_cache)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::inherit())
|
|
.spawn()?;
|
|
let client_lines = spawn_line_reader(
|
|
client_child
|
|
.stdout
|
|
.take()
|
|
.ok_or_else(|| anyhow::anyhow!("no client stdout"))?,
|
|
);
|
|
let mut client_stdin = client_child
|
|
.stdin
|
|
.take()
|
|
.ok_or_else(|| anyhow::anyhow!("no client stdin"))?;
|
|
let client_guard = KillOnDrop(&mut client_child);
|
|
|
|
let allowlist = expect_line(&serve_lines, "ALLOWLIST:", Duration::from_secs(300))?;
|
|
anyhow::ensure!(
|
|
allowlist.split(',').map(str::trim).collect::<BTreeSet<_>>()
|
|
== BTreeSet::from([serve_owner_id.as_str(), client_owner_id.as_str()]),
|
|
"LIFECYCLE FAIL: serve allowlist {allowlist} is not exactly the expected member owners"
|
|
);
|
|
eprintln!("[lifecycle] PASS 1/6: relay-derived allowlist is exactly {{A, B}}: {allowlist}");
|
|
let endpoint = expect_line(&serve_lines, "ENDPOINT:", Duration::from_secs(600))?;
|
|
eprintln!("[lifecycle] serve endpoint acquired (relay advertisement lands with READY)");
|
|
let served = expect_line(&serve_lines, "READY:", Duration::from_secs(900))?;
|
|
eprintln!("[lifecycle] PASS 2/6: serve member ready + advertised model: {served}");
|
|
|
|
// Client verdict: discovery + join + first inference.
|
|
let (which, seen) = expect_one_of(&client_lines, &["SEEN:", "NONE"], Duration::from_secs(900))?;
|
|
anyhow::ensure!(
|
|
which == "SEEN:",
|
|
"LIFECYCLE FAIL: client member never saw the model via relay-driven join"
|
|
);
|
|
eprintln!("[lifecycle] PASS 3/6: client member discovered + joined via relay, sees: {seen}");
|
|
let (which, detail) = expect_one_of(
|
|
&client_lines,
|
|
&["INFER_OK:", "INFER_FAIL:"],
|
|
Duration::from_secs(180),
|
|
)?;
|
|
anyhow::ensure!(
|
|
which == "INFER_OK:",
|
|
"LIFECYCLE FAIL: client saw the model but inference did not route: {detail}"
|
|
);
|
|
eprintln!("[lifecycle] PASS 4/6: inference routed over the mesh: {detail:?}");
|
|
|
|
// STRANGER child (C): must be denied by the relay's membership gate and
|
|
// must not route inference through the mesh.
|
|
eprintln!("[lifecycle] starting STRANGER (non-member, leaked endpoint)...");
|
|
let mut stranger_child = Command::new(&exe)
|
|
.env("MESH_ROLE", "stranger")
|
|
.env("BUZZ_MEMBER_NSEC", secret_hex(&stranger))
|
|
.env("MESH_OWNER_KEY", &stranger_key)
|
|
.env("MESH_LEAKED_ENDPOINT", &endpoint)
|
|
.env("HOME", role_home("stranger")?)
|
|
.env("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR", &native_cache)
|
|
.env("HF_HUB_CACHE", &hf_cache)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::inherit())
|
|
.spawn()?;
|
|
let stranger_lines = spawn_line_reader(
|
|
stranger_child
|
|
.stdout
|
|
.take()
|
|
.ok_or_else(|| anyhow::anyhow!("no stranger stdout"))?,
|
|
);
|
|
let stranger_guard = KillOnDrop(&mut stranger_child);
|
|
|
|
// Relay leg: only the relay's own membership rejection counts as denied.
|
|
let (which, detail) = expect_one_of(
|
|
&stranger_lines,
|
|
&["RELAY_DENIED_MEMBERSHIP", "RELAY_AUTH_OK:", "RELAY_ERR:"],
|
|
Duration::from_secs(120),
|
|
)?;
|
|
match which {
|
|
"RELAY_DENIED_MEMBERSHIP" => {
|
|
eprintln!("[lifecycle] PASS 5/6: relay rejected the stranger's NIP-42 auth (membership gate)");
|
|
}
|
|
"RELAY_AUTH_OK:" => anyhow::bail!(
|
|
"LIFECYCLE FAIL: membership-gated relay authenticated a non-member (saw {detail} statuses)"
|
|
),
|
|
_ => anyhow::bail!(
|
|
"LIFECYCLE INCONCLUSIVE: stranger relay connect failed for a non-membership reason: {detail}"
|
|
),
|
|
}
|
|
|
|
// Mesh leg: the stranger must not complete an inference.
|
|
let (which, detail) = expect_one_of(
|
|
&stranger_lines,
|
|
&["SEEN:", "NONE"],
|
|
stranger_window() + Duration::from_secs(300),
|
|
)?;
|
|
let stranger_infer = if which == "SEEN:" {
|
|
let model = detail;
|
|
let (verdict, body) = expect_one_of(
|
|
&stranger_lines,
|
|
&["INFER_OK:", "INFER_FAIL:"],
|
|
Duration::from_secs(180),
|
|
)?;
|
|
anyhow::ensure!(
|
|
verdict != "INFER_OK:",
|
|
"LIFECYCLE FAIL: stranger reused the leaked endpoint and inferred through {model}: {body:?}"
|
|
);
|
|
format!("saw gossip for {model} but inference was rejected: {body}")
|
|
} else {
|
|
"saw no routed model".to_string()
|
|
};
|
|
// Defuse the kill-guard (the stranger exits on its own after its verdict);
|
|
// dropping it here would SIGKILL the child before we can read its status.
|
|
std::mem::forget(stranger_guard);
|
|
let stranger_status = wait_child(&mut stranger_child, Duration::from_secs(60), "stranger")?;
|
|
anyhow::ensure!(
|
|
stranger_status.success(),
|
|
"LIFECYCLE INCONCLUSIVE: stranger child exited with {stranger_status}"
|
|
);
|
|
|
|
// Differential health proof: the trusted client must still route
|
|
// inference *after* the stranger's attempt. Without this, a serve node
|
|
// that died mid-run would make the stranger's failure look like a denial.
|
|
client_stdin.write_all(format!("{VERIFY_AGAIN}\n").as_bytes())?;
|
|
client_stdin.flush()?;
|
|
let (which, detail) = expect_one_of(
|
|
&client_lines,
|
|
&["INFER_AGAIN_OK:", "INFER_AGAIN_FAIL:"],
|
|
Duration::from_secs(180),
|
|
)?;
|
|
anyhow::ensure!(
|
|
which == "INFER_AGAIN_OK:",
|
|
"LIFECYCLE FAIL: trusted client could not infer after the stranger's attempt \
|
|
(serve node unhealthy — stranger denial is inconclusive): {detail}"
|
|
);
|
|
eprintln!(
|
|
"[lifecycle] PASS 6/6: stranger denied ({stranger_infer}) while trusted inference \
|
|
still routes: {detail:?}"
|
|
);
|
|
|
|
eprintln!("[lifecycle] PASS: full relay-driven mesh lifecycle verified");
|
|
drop(client_guard);
|
|
let _ = wait_child(&mut client_child, Duration::from_secs(60), "client");
|
|
drop(serve_guard);
|
|
let _ = serve_child.wait();
|
|
let _ = std::fs::remove_dir_all(&scratch);
|
|
Ok(())
|
|
}
|
|
|
|
// ── Child-process plumbing ───────────────────────────────────────────────────
|
|
|
|
/// Lines from a child's stdout, pumped by a dedicated reader thread so waits
|
|
/// can enforce hard deadlines (`BufRead::lines` alone blocks indefinitely).
|
|
struct ChildLines {
|
|
rx: mpsc::Receiver<std::io::Result<String>>,
|
|
}
|
|
|
|
fn spawn_line_reader(stdout: ChildStdout) -> ChildLines {
|
|
let (tx, rx) = mpsc::channel();
|
|
std::thread::spawn(move || {
|
|
for line in std::io::BufReader::new(stdout).lines() {
|
|
if tx.send(line).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
ChildLines { rx }
|
|
}
|
|
|
|
/// Wait (with a hard deadline) for a line starting with `prefix`; returns the
|
|
/// suffix. Non-matching lines are skipped.
|
|
fn expect_line(lines: &ChildLines, prefix: &str, timeout: Duration) -> anyhow::Result<String> {
|
|
expect_one_of(lines, &[prefix], timeout).map(|(_, rest)| rest)
|
|
}
|
|
|
|
/// Wait (with a hard deadline) for a line starting with any of `prefixes`;
|
|
/// returns the matched prefix and the suffix.
|
|
fn expect_one_of<'a>(
|
|
lines: &ChildLines,
|
|
prefixes: &[&'a str],
|
|
timeout: Duration,
|
|
) -> anyhow::Result<(&'a str, String)> {
|
|
let deadline = Instant::now() + timeout;
|
|
loop {
|
|
let remaining = deadline
|
|
.checked_duration_since(Instant::now())
|
|
.ok_or_else(|| anyhow::anyhow!("timed out waiting for one of {prefixes:?}"))?;
|
|
match lines.rx.recv_timeout(remaining) {
|
|
Ok(Ok(line)) => {
|
|
for prefix in prefixes {
|
|
if let Some(rest) = line.strip_prefix(prefix) {
|
|
return Ok((prefix, rest.to_string()));
|
|
}
|
|
}
|
|
}
|
|
Ok(Err(error)) => {
|
|
anyhow::bail!("child stdout read error before {prefixes:?}: {error}")
|
|
}
|
|
Err(mpsc::RecvTimeoutError::Timeout) => {
|
|
anyhow::bail!("timed out waiting for one of {prefixes:?}")
|
|
}
|
|
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
|
anyhow::bail!("child exited before printing one of {prefixes:?}")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Wait for a child to exit, killing it if the deadline passes.
|
|
fn wait_child(child: &mut Child, timeout: Duration, label: &str) -> anyhow::Result<ExitStatus> {
|
|
let deadline = Instant::now() + timeout;
|
|
loop {
|
|
if let Some(status) = child.try_wait()? {
|
|
return Ok(status);
|
|
}
|
|
if Instant::now() > deadline {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
anyhow::bail!("{label} child exceeded {timeout:?} and was killed");
|
|
}
|
|
std::thread::sleep(Duration::from_millis(200));
|
|
}
|
|
}
|
|
|
|
/// Kill the child on drop so a failed assertion never leaks a process.
|
|
struct KillOnDrop<'a>(&'a mut Child);
|
|
impl Drop for KillOnDrop<'_> {
|
|
fn drop(&mut self) {
|
|
let _ = self.0.kill();
|
|
}
|
|
}
|
|
|
|
/// The real user's OS cache dir, resolved before HOME is overridden for the
|
|
/// child processes.
|
|
fn real_cache_dir() -> anyhow::Result<std::path::PathBuf> {
|
|
let home = std::env::var("HOME").map_err(|_| anyhow::anyhow!("HOME is not set"))?;
|
|
#[cfg(target_os = "macos")]
|
|
return Ok(std::path::PathBuf::from(home).join("Library/Caches"));
|
|
#[cfg(not(target_os = "macos"))]
|
|
return Ok(std::path::PathBuf::from(home).join(".cache"));
|
|
}
|
|
|
|
/// Poll `/models` until a model id appears or the window closes.
|
|
async fn wait_for_model(
|
|
http: &reqwest::Client,
|
|
api_base: &str,
|
|
window: Duration,
|
|
) -> anyhow::Result<Option<String>> {
|
|
let url = format!("{api_base}/models");
|
|
let deadline = Instant::now() + window;
|
|
while Instant::now() < deadline {
|
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
|
if let Ok(resp) = http.get(&url).send().await {
|
|
let body = resp.text().await.unwrap_or_default();
|
|
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) {
|
|
if let Some(id) = json["data"].get(0).and_then(|m| m["id"].as_str()) {
|
|
return Ok(Some(id.to_string()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// One chat completion against a node's OpenAI endpoint; Ok(content) only if
|
|
/// it really routed and produced non-empty output.
|
|
async fn try_completion(
|
|
http: &reqwest::Client,
|
|
api_base: &str,
|
|
model: &str,
|
|
) -> anyhow::Result<String> {
|
|
let resp = http
|
|
.post(format!("{api_base}/chat/completions"))
|
|
.timeout(Duration::from_secs(120))
|
|
.json(&serde_json::json!({
|
|
"model": model,
|
|
"messages": [{"role": "user", "content": "Reply with exactly one word: PONG"}],
|
|
"max_tokens": 16,
|
|
"temperature": 0.0
|
|
}))
|
|
.send()
|
|
.await?;
|
|
let status = resp.status();
|
|
let body = resp.text().await?;
|
|
if !status.is_success() {
|
|
anyhow::bail!("{status}: {body}");
|
|
}
|
|
let content = serde_json::from_str::<serde_json::Value>(&body)?["choices"][0]["message"]
|
|
["content"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.to_string();
|
|
if content.trim().is_empty() {
|
|
anyhow::bail!("empty content");
|
|
}
|
|
Ok(content)
|
|
}
|