Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ futures-util = "0.3"

# WebSocket client (test client)
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
rustls-platform-verifier = "0.7"
url = "2"

# Property-based testing (dev-only)
Expand Down
15 changes: 8 additions & 7 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -233,14 +233,15 @@ desktop-release-build target="aarch64-apple-darwin":
#!/usr/bin/env bash
set -euo pipefail
TARGET={{target}}
mkdir -p desktop/src-tauri/binaries
touch "desktop/src-tauri/binaries/buzz-acp-$TARGET"
touch "desktop/src-tauri/binaries/buzz-agent-$TARGET"
touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET"
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
touch "desktop/src-tauri/binaries/buzz-$TARGET"
cargo build --release --target "$TARGET" \
-p buzz-acp \
-p buzz-agent \
-p buzz-dev-mcp \
-p git-credential-nostr \
-p buzz-cli
./scripts/bundle-sidecars.sh "$TARGET"
pnpm install
cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}}
cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target "$TARGET"

# Run desktop checks suitable for CI / pre-push
desktop-ci: desktop-check desktop-test desktop-tauri-fmt-check desktop-build desktop-tauri-check desktop-tauri-test
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-acp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ tokio = { workspace = true }

# WebSocket
tokio-tungstenite = { workspace = true }
rustls-platform-verifier = { workspace = true }

# Codec (bounded line reads)
tokio-util = { workspace = true }
Expand Down
28 changes: 23 additions & 5 deletions crates/buzz-acp/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
//! channel. `next_event()` reads from the event receiver.

use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use std::time::Duration;

/// Default capacity of the event channel from background task to harness.
Expand Down Expand Up @@ -119,15 +120,21 @@ use buzz_core::kind::{
};
use futures_util::{SinkExt, StreamExt};
use nostr::{Event, EventBuilder, Keys, Kind, RelayUrl, Tag};
use rustls_platform_verifier::ConfigVerifierExt;
use serde_json::{json, Value};
use tokio::sync::mpsc;
use tokio::time::timeout;
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
use tokio_tungstenite::{
connect_async_tls_with_config, tungstenite::Message, Connector, MaybeTlsStream, WebSocketStream,
};
use tracing::{debug, info, warn};
use uuid::Uuid;

use crate::config::ChannelFilter;

#[cfg(test)]
use tokio_tungstenite::connect_async;

/// Metadata about a channel, populated at discovery time.
#[derive(Debug, Clone)]
pub struct ChannelInfo {
Expand Down Expand Up @@ -3830,11 +3837,22 @@ async fn do_connect(
let parsed = relay_url
.parse::<url::Url>()
.map_err(|e| RelayError::Http(format!("invalid relay URL: {e}")))?;
let _ = rustls::crypto::ring::default_provider().install_default();
let tls_connector = rustls::ClientConfig::with_platform_verifier()
.map(|config| Connector::Rustls(Arc::new(config)))
.map_err(|error| {
RelayError::Http(format!(
"failed to initialize platform TLS verifier: {error}"
))
})?;

let (ws, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(parsed.as_str()))
.await
.map_err(|_| RelayError::ConnectionClosed)? // timeout → treat as connection failure
.map_err(|e| RelayError::WebSocket(Box::new(e)))?;
let (ws, _response) = tokio::time::timeout(
CONNECT_TIMEOUT,
connect_async_tls_with_config(parsed.as_str(), None, false, Some(tls_connector)),
)
.await
.map_err(|_| RelayError::ConnectionClosed)? // timeout → treat as connection failure
.map_err(|e| RelayError::WebSocket(Box::new(e)))?;
debug!("connected to relay at {relay_url}");

let mut ws = ws;
Expand Down
2 changes: 2 additions & 0 deletions crates/buzz-ws-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ repository.workspace = true
nostr = { workspace = true }
tokio = { workspace = true }
tokio-tungstenite = { workspace = true }
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
rustls-platform-verifier = { workspace = true }
futures-util = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
Expand Down
17 changes: 13 additions & 4 deletions crates/buzz-ws-client/src/connection.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
use std::collections::VecDeque;
use std::sync::Arc;
use std::time::Duration;

use futures_util::{SinkExt, StreamExt};
use nostr::{Event, Keys, Tag};
use rustls_platform_verifier::ConfigVerifierExt;
use serde_json::{json, Value};
use tokio::time::timeout;
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
use tokio_tungstenite::{
connect_async_tls_with_config, tungstenite::Message, Connector, MaybeTlsStream, WebSocketStream,
};
use tracing::debug;

use crate::error::WsClientError;
Expand Down Expand Up @@ -46,13 +50,18 @@ impl NostrWsConnection {

/// Connects to the relay at `url` without performing authentication.
pub async fn connect(url: &str) -> Result<Self, WsClientError> {
let _ = rustls::crypto::ring::default_provider().install_default();
let parsed = url
.parse::<url::Url>()
.map_err(|e| WsClientError::Url(e.to_string()))?;
let tls_connector = rustls::ClientConfig::with_platform_verifier()
.map(|config| Connector::Rustls(Arc::new(config)))
.map_err(|error| WsClientError::Url(format!("TLS verifier: {error}")))?;

let (ws, _response) = connect_async(parsed.as_str())
.await
.map_err(WsClientError::WebSocket)?;
let (ws, _response) =
connect_async_tls_with_config(parsed.as_str(), None, false, Some(tls_connector))
.await
.map_err(WsClientError::WebSocket)?;

debug!("connected to relay at {url}");

Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ ed25519-dalek = "=3.0.0-rc.0"
tokio = { version = "1", features = ["fs", "sync", "rt", "macros", "time", "net", "io-util"] }
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
tokio-util = { version = "0.7", features = ["rt"] }
rustls-platform-verifier = "0.7"
bytes = "1"
futures-util = "0.3"
opus = "0.3"
Expand Down
10 changes: 8 additions & 2 deletions desktop/src-tauri/src/commands/relay_members.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ pub async fn get_my_relay_membership(
.await?;

let Some(event) = events.first() else {
return Ok(serde_json::json!({ "member": null }));
return Ok(serde_json::json!({
"snapshot_found": false,
"member": null
}));
};

let members_value = nostr_convert::relay_members_from_event(event);
Expand All @@ -85,7 +88,10 @@ pub async fn get_my_relay_membership(
.cloned()
});

Ok(serde_json::json!({ "member": me }))
Ok(serde_json::json!({
"snapshot_found": true,
"member": me
}))
}

#[tauri::command]
Expand Down
25 changes: 23 additions & 2 deletions desktop/src-tauri/src/native_websocket.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
use std::{collections::HashMap, sync::Arc, time::Duration};

use futures_util::{SinkExt, StreamExt};
use rustls_platform_verifier::ConfigVerifierExt;
use serde::{Deserialize, Serialize};
use tauri::{ipc::Channel, plugin::TauriPlugin, Manager, Runtime};
use tokio::sync::{mpsc, oneshot, Mutex};
use tokio_tungstenite::{
connect_async,
connect_async_tls_with_config,
tungstenite::protocol::{frame::coding::CloseCode, CloseFrame, Message},
Connector,
};
use tokio_util::sync::CancellationToken;

Expand All @@ -20,6 +22,12 @@ pub(crate) fn install_crypto_provider() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
}

fn platform_tls_connector() -> Result<Connector, String> {
rustls::ClientConfig::with_platform_verifier()
.map(|config| Connector::Rustls(Arc::new(config)))
.map_err(|error| format!("failed to initialize platform TLS verifier: {error}"))
}

type Id = u32;

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -127,9 +135,13 @@ async fn open_connection(
on_message: Channel<serde_json::Value>,
) -> Result<Id, String> {
let connect_cancel = manager.connect_cancel.lock().await.clone();
let tls_connector = platform_tls_connector()?;
let (socket, _) = tokio::select! {
_ = connect_cancel.cancelled() => return Err("WebSocket connection cancelled".to_string()),
result = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(url)) => result
result = tokio::time::timeout(
CONNECT_TIMEOUT,
connect_async_tls_with_config(url, None, false, Some(tls_connector)),
) => result
.map_err(|_| "WebSocket connection timed out".to_string())?
.map_err(|error| error.to_string())?,
};
Expand Down Expand Up @@ -337,6 +349,15 @@ mod tests {
Channel::new(|_: InvokeResponseBody| Ok(()))
}

#[test]
fn platform_tls_connector_initializes() {
install_crypto_provider();
assert!(
matches!(platform_tls_connector(), Ok(Connector::Rustls(_))),
"platform TLS verifier must initialize"
);
}

#[tokio::test]
async fn secure_websocket_reaches_tls_without_panicking() {
install_crypto_provider();
Expand Down
27 changes: 20 additions & 7 deletions desktop/src/shared/api/relayMembers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { relayClient } from "@/shared/api/relayClient";
import { invokeTauri, signRelayEvent } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
import {
getMyRelayMembershipSnapshot,
invokeTauri,
signRelayEvent,
} from "@/shared/api/tauri";
import type {
RelayEvent,
RelayMember,
Expand Down Expand Up @@ -127,11 +130,21 @@ async function relayRequiresMembership(): Promise<boolean> {
}

export async function getMyRelayMembershipLookup(): Promise<RelayMembershipLookup> {
const [{ pubkey }, membershipRequired] = await Promise.all([
getIdentity(),
relayRequiresMembership(),
]);
return loadRelayMembershipLookup(pubkey, membershipRequired);
const membershipRequired = await relayRequiresMembership();
if (!membershipRequired) {
return {
snapshotFound: false,
membershipRequired: false,
membership: null,
};
}

const { snapshotFound, membership } = await getMyRelayMembershipSnapshot();
return {
snapshotFound,
membershipRequired: true,
membership,
};
}

export async function getMyRelayMembership(): Promise<RelayMember | null> {
Expand Down
34 changes: 19 additions & 15 deletions desktop/src/shared/api/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,11 @@ type RawListRelayMembersResponse = {
members: RawRelayMember[];
};

type RawMyRelayMembershipResponse = {
snapshot_found: boolean;
member: RawRelayMember | null;
};

type RawCanvasResponse = {
content: string | null;
updated_at: number | null;
Expand Down Expand Up @@ -797,21 +802,20 @@ export async function listRelayMembers(): Promise<RelayMember[]> {
}

export async function getMyRelayMembership(): Promise<RelayMember | null> {
try {
const raw = await invokeTauri<RawRelayMember>("get_my_relay_membership");
return fromRawRelayMember(raw);
} catch (error) {
// "relay returned 404 Not Found" = not a relay member — return null so
// the UI hides the Members tab. Re-throw real errors (network, auth, 500)
// so React Query surfaces them.
if (
error instanceof Error &&
error.message.startsWith("relay returned 404")
) {
return null;
}
throw error;
}
return (await getMyRelayMembershipSnapshot()).membership;
}

export async function getMyRelayMembershipSnapshot(): Promise<{
snapshotFound: boolean;
membership: RelayMember | null;
}> {
const response = await invokeTauri<RawMyRelayMembershipResponse>(
"get_my_relay_membership",
);
return {
snapshotFound: response.snapshot_found,
membership: response.member ? fromRawRelayMember(response.member) : null,
};
}

export async function addRelayMember(
Expand Down
10 changes: 10 additions & 0 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9843,6 +9843,16 @@ export function maybeInstallE2eTauriMocks() {
return getRelayHttpUrl(activeConfig);
case "relay_requires_membership":
return activeConfig?.mock?.relayRequiresMembership ?? false;
case "get_my_relay_membership": {
const pubkey = getMockMemberPubkey(activeConfig);
const member =
mockRelayMembers.find((candidate) => candidate.pubkey === pubkey) ??
null;
return {
snapshot_found: true,
member,
};
}
case "discover_acp_providers":
return handleDiscoverAcpRuntimes(activeConfig);
case "discover_acp_auth_methods":
Expand Down
24 changes: 24 additions & 0 deletions desktop/tests/e2e/onboarding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2982,6 +2982,30 @@ test("open relay skips membership gating during onboarding", async ({

await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await expect(page.getByTestId("membership-denied")).toHaveCount(0);
expect(await commandCount(page, "get_my_relay_membership")).toBe(0);
});

test("gated relay member uses the native membership preflight", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
relayRequiresMembership: true,
relayRole: "member",
},
{ skipOnboardingSeed: true },
);
await page.goto("/");

await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();

await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await expect(page.getByText("Server error — try again")).toHaveCount(0);
await expect(page.getByTestId("membership-denied")).toHaveCount(0);
expect(await commandCount(page, "get_my_relay_membership")).toBe(1);
});

test("membership denial can import a different invited key", async ({
Expand Down
Loading