fix(cli): clearer desktop pairing errors, paired default relay, and auth desktop list (#9196)

Follow-ups to the CLI's Buzz desktop credential support, for a person
running the CLI with a desktop pairing.

- **The "no credentials" error names the relay.** It says which relay
the CLI tried and which relays this CLI is paired for, and tells you to
pass `--relay`. When a pairing exists for that relay but no running
desktop accepts it, it says so instead.
- **Default relay for a paired person.** With no `--relay`, no
`BUZZ_RELAY_URL`, no inherited agent credential and no explicit key, the
CLI uses the one relay that a running desktop still accepts this CLI's
pairing for, confirmed by `describe` on a same-user connection. Stored
pairings alone never count. With zero or several such relays, the
default stays `http://localhost:3000`. A desktop's refusal fails the
command with its message. When another paired desktop's check fails (its
same-user check times out or can't confirm the owner, or it stays rate
limited), the command fails rather than pick a relay, even if one other
relay was accepted, since that desktop may accept a relay of its own; a
timeout or exhausted throttle fails with a retryable error. `auth
desktop unpair` never runs discovery, and `auth desktop pair` never asks
other desktops for their pairings, so one desktop's refusal can't block
pairing another. Help text updated.
- **`buzz auth desktop list`.** Lists each pairing's instance, relays
and pubkey, with `accepted`, `not accepted` (revoked or no longer
bound), `not running`, `unconfirmed`, `rate limited` or `refused` (the
last three with an `error`), using the same check as the default relay.
Desktops are asked at once, without waiting out throttling. It never
prints tokens, needs no relay, prints `[]` with no pairings, and
`--format compact` drops the pubkey.
- **Retryable same-user timeout.** When a paired desktop's same-user
check times out and nothing else serves, the command now fails with a
retryable `unavailable` error (exit 2) instead of "no credentials" (exit
3).
- **Regression tests** for the NIP-42 AUTH event naming the relay with
its trailing slash, for skipping a paired desktop whose user can't be
confirmed, without sending it anything, and for relay selection never
asking desktops for pairings with an explicit key, `auth desktop pair`
or `auth desktop unpair`, or over an inherited agent relay.

---------

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-11-06 14:11:49 -05:00
committed by GitHub
parent 744f3946f6
commit c8d736642e
4 changed files with 739 additions and 24 deletions

View File

@@ -4220,6 +4220,51 @@ mod ws_deadline_tests {
assert_eq!(accepted.load(Ordering::SeqCst), 1);
}
/// The NIP-42 AUTH event names the configured community URL, path and
/// trailing slash kept, as the relay checks it.
#[tokio::test]
async fn the_auth_event_names_the_relay_with_its_trailing_slash() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let named = Arc::new(std::sync::Mutex::new(None::<String>));
let log = named.clone();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
let _ = ws
.send(Message::Text(r#"["AUTH","challenge"]"#.into()))
.await;
while let Some(Ok(Message::Text(text))) = ws.next().await {
let frame: serde_json::Value = serde_json::from_str(&text).unwrap();
if frame[0] == "AUTH" {
let relay = frame[1]["tags"]
.as_array()
.unwrap()
.iter()
.find(|t| t[0] == "relay")
.map(|t| t[1].as_str().unwrap().to_owned());
*log.lock().unwrap() = relay;
}
let ok = serde_json::json!(["OK", frame[1]["id"], true, ""]);
let _ = ws.send(Message::Text(ok.to_string().into())).await;
}
});
let keys = Keys::generate();
let c = BuzzClient::new(
format!("http://{addr}/team/"),
Credentials::from_keys(keys.clone()),
)
.unwrap();
let event = EventBuilder::new(Kind::Custom(20001), "")
.sign_with_keys(&keys)
.unwrap();
c.publish_ephemeral_event(event).await.unwrap();
assert_eq!(
named.lock().unwrap().as_deref(),
Some(format!("ws://{addr}/team/").as_str())
);
}
#[test]
fn auth_keeps_the_configured_relay_path() {
let client = BuzzClient::new(

View File

@@ -81,11 +81,37 @@ pub async fn resolve(settings: Settings, relay_url: &str) -> Result<Credentials,
}
return Credentials::from_desktop(provider).await;
}
Err(CliError::Auth(
"no credentials: set BUZZ_PRIVATE_KEY, pass --private-key, or run \
`buzz auth desktop pair` with Buzz running"
.into(),
))
#[cfg(unix)]
let paired = crate::desktop_provider::paired_relays();
#[cfg(not(unix))]
let paired = Vec::new();
Err(no_credentials(relay_url, &paired))
}
/// The error for a relay no link serves, naming it and the relays this CLI
/// is paired for, so a missing `--relay` is plain.
fn no_credentials(relay_url: &str, paired: &[String]) -> CliError {
let fix = "set BUZZ_PRIVATE_KEY, pass --private-key, or run `buzz auth desktop pair` \
with Buzz running";
#[cfg(unix)]
let this_one = crate::desktop_provider::provider_relay_url(relay_url)
.is_ok_and(|relay| paired.contains(&relay));
#[cfg(not(unix))]
let this_one = false;
CliError::Auth(if paired.is_empty() {
format!("no credentials for {relay_url}: {fix}")
} else if this_one {
format!(
"no credentials for {relay_url}: this CLI is paired for it, but no running Buzz \
desktop accepts the pairing; open Buzz and retry, or check `buzz auth desktop list`"
)
} else {
format!(
"no credentials for {relay_url}: this CLI is paired with Buzz only for {}; pass \
--relay (or set BUZZ_RELAY_URL) to use a pairing, or {fix}",
paired.join(", ")
)
})
}
/// The explicit-key link, with its headless evidence source. Without an
@@ -802,4 +828,33 @@ mod tests {
);
assert_eq!(id.len(), 32);
}
#[test]
fn no_credentials_names_the_relay_and_the_paired_ones() {
let message = |relay: &str, paired: &[&str]| {
let paired: Vec<String> = paired.iter().map(|r| (*r).to_owned()).collect();
match no_credentials(relay, &paired) {
CliError::Auth(m) => m,
other => panic!("{other:?}"),
}
};
let none = message("http://localhost:3000", &[]);
assert!(none.starts_with("no credentials for http://localhost:3000: set BUZZ_PRIVATE_KEY"));
let elsewhere = message("http://localhost:3000", &["wss://team.example/"]);
assert!(
elsewhere.contains("paired with Buzz only for wss://team.example/"),
"{elsewhere}"
);
assert!(elsewhere.contains("pass --relay"), "{elsewhere}");
#[cfg(unix)]
{
let here = message("https://team.example", &["wss://team.example/"]);
assert!(
here.contains("no running Buzz desktop accepts the pairing"),
"{here}"
);
}
}
}

View File

@@ -70,6 +70,9 @@ pub enum ProviderError {
Transport(String),
/// The same-user check ran out of time before anything was sent.
Unconfirmed,
/// The provider port is held by another user, or no owner was found:
/// nothing was sent.
OtherUser,
}
impl ProviderError {
@@ -112,6 +115,10 @@ impl ProviderError {
Self::Unconfirmed => CliError::Unavailable(
"Buzz desktop: the same-user check timed out; nothing was sent".into(),
),
Self::OtherUser => CliError::Auth(
"Buzz desktop: could not confirm the provider runs as this user; nothing was sent"
.into(),
),
}
}
}
@@ -293,6 +300,10 @@ async fn connect(port: u16, check: bool) -> Result<TcpStream, ProviderError> {
.local_addr()
.map_err(|_| ProviderError::Transport("no local address".into()))?
.port();
#[cfg(test)]
if tests::SAME_USER_HANGS.try_with(|_| ()).is_ok() {
return Err(ProviderError::Unconfirmed);
}
confirm_same_user(server_side_uid(port, local)).await?;
}
Ok(stream)
@@ -305,9 +316,7 @@ async fn confirm_same_user(
) -> Result<(), ProviderError> {
match tokio::time::timeout(SAME_USER_TIMEOUT, owner).await {
Ok(Some(uid)) if uid == current_uid() => Ok(()),
Ok(_) => Err(ProviderError::Transport(
"could not confirm the provider runs as this user; nothing was sent".into(),
)),
Ok(_) => Err(ProviderError::OtherUser),
Err(_) => Err(ProviderError::Unconfirmed),
}
}
@@ -568,7 +577,9 @@ impl Provider {
Ok(raw) => raw,
Err(ProviderError::Revoked) => return Ok(Err(Skip::Revoked)),
Err(ProviderError::NoCommonVersion) => return Ok(Err(Skip::NotApplicable)),
Err(ProviderError::Transport(_)) => return Ok(Err(Skip::Unreachable)),
Err(ProviderError::Transport(_) | ProviderError::OtherUser) => {
return Ok(Err(Skip::Unreachable))
}
Err(ProviderError::Unconfirmed) => return Ok(Err(Skip::Unconfirmed)),
Err(e) => return Err(e.into_cli("describe")),
};
@@ -1064,9 +1075,7 @@ pub async fn inherited_relay() -> Result<Option<String>, CliError> {
.unwrap_or_default();
let mut distinct: Vec<String> = Vec::new();
for relay in relays {
if !distinct.iter().any(|known| same_relay(known, &relay)) {
distinct.push(provider_relay_url(&relay).unwrap_or(relay));
}
push_distinct(&mut distinct, relay);
}
match distinct.len() {
0 => Ok(None),
@@ -1079,6 +1088,213 @@ pub async fn inherited_relay() -> Result<Option<String>, CliError> {
}
}
/// Add `relay` to `list` in bound form, unless it is already there.
fn push_distinct(list: &mut Vec<String>, relay: String) {
if !list.iter().any(|known| same_relay(known, &relay)) {
list.push(provider_relay_url(&relay).unwrap_or(relay));
}
}
/// The relay a paired person's command given none acts on: the one relay a
/// running desktop still accepts this CLI's pairing for, by its `describe`.
/// A stored pairing alone does not count, since its desktop may have revoked
/// it or stopped. `None` for an agent (its inherited token decides), or when
/// no relay or several qualify.
pub async fn paired_relay() -> Result<Option<String>, CliError> {
if inherited()?.is_some() {
return Ok(None);
}
accepted_relay(&Keychain, advertisements()).await
}
async fn accepted_relay(
store: &dyn PairingStore,
advertised: Vec<(String, Endpoint)>,
) -> Result<Option<String>, CliError> {
let Ok(Ok(pairings)) = store.read().map(parse_pairings) else {
return Ok(None);
};
let mut accepted = Vec::new();
let mut transient = None;
for (instance, endpoint) in advertised {
let Some(pairing) = pairings.0.get(&instance) else {
continue;
};
match probe(&endpoint, pairing, RATE_LIMIT_RETRIES).await {
Probe::Accepted(relays) => {
for relay in relays {
push_distinct(&mut accepted, relay);
}
}
Probe::NotAccepted | Probe::NotRunning => {}
// A desktop that answered with a refusal has decided.
Probe::Refused(e) => return Err(e),
Probe::Unconfirmed(e) | Probe::Throttled(e) => keep_transient(&mut transient, e),
}
}
match (accepted.len(), transient) {
// A desktop whose check failed may accept a relay of its own: report
// it rather than pick, or fall back to, a relay it might contradict.
(0 | 1, Some(e)) => Err(e),
(1, None) => Ok(accepted.pop()),
_ => Ok(None),
}
}
/// Keep the first transient failure, except that a retryable one replaces an
/// identity mismatch: the command may succeed once that desktop answers.
fn keep_transient(kept: &mut Option<CliError>, e: CliError) {
let better = kept.as_ref().is_none_or(|k| {
!crate::error::is_retryable_error(k) && crate::error::is_retryable_error(&e)
});
if better {
*kept = Some(e);
}
}
/// What a running desktop says about a stored pairing now.
enum Probe {
/// The pairing's relays it still binds for the token.
Accepted(Vec<String>),
/// It answered: the token is revoked, or binds none of the relays.
NotAccepted,
/// The advertised endpoint could not be reached or did not answer.
NotRunning,
/// The same-user check timed out, or the port's owner is another user.
Unconfirmed(CliError),
/// It is still rate-limiting after `retries` waits.
Throttled(CliError),
/// It refused `describe` (`denied`, `unavailable`, ...): final.
Refused(CliError),
}
/// Ask the desktop at `endpoint`, on a same-user connection, which of
/// `pairing`'s relays it binds for its token. The one check behind both the
/// default relay and `auth desktop list`.
async fn probe(endpoint: &Endpoint, pairing: &Pairing, retries: u32) -> Probe {
let raw = match call_with(
endpoint,
Some(&pairing.token),
true,
"describe",
retries,
&Vec::new,
)
.await
{
Ok(raw) => raw,
Err(ProviderError::Revoked | ProviderError::NoCommonVersion) => return Probe::NotAccepted,
Err(ProviderError::Transport(_)) => return Probe::NotRunning,
Err(e @ (ProviderError::Unconfirmed | ProviderError::OtherUser)) => {
return Probe::Unconfirmed(e.into_cli("describe"))
}
Err(
e @ ProviderError::Code {
code: "rate_limited",
..
},
) => return Probe::Throttled(e.into_cli("describe")),
Err(e) => return Probe::Refused(e.into_cli("describe")),
};
let Ok(describe) = serde_json::from_str::<Describe>(&raw) else {
return Probe::Refused(CliError::Auth(
"Buzz desktop: unreadable `describe` answer".into(),
));
};
let mut accepted = Vec::new();
for relay in describe.relays {
let bound = provider_relay_url(&relay).is_ok_and(|bound| {
pairing
.relays
.iter()
.any(|paired| same_relay(&bound, paired))
});
if bound {
push_distinct(&mut accepted, relay);
}
}
if accepted.is_empty() {
Probe::NotAccepted
} else {
Probe::Accepted(accepted)
}
}
/// This CLI's pairings, without their tokens, each with what a running
/// desktop says about it now: `accepted`, `not accepted` (revoked or no
/// longer bound), `not running`, `unconfirmed` (same-user check failed),
/// `rate limited` or `refused`. Local and read-only. Desktops are asked at
/// once, each without waiting out throttling.
pub async fn list() -> Result<Vec<serde_json::Value>, CliError> {
list_with(&Keychain, advertisements()).await
}
async fn list_with(
store: &dyn PairingStore,
advertised: Vec<(String, Endpoint)>,
) -> Result<Vec<serde_json::Value>, CliError> {
let pairings = parse_pairings(store.read()?)?;
let rows = pairings.0.into_iter().map(|(instance, pairing)| {
let running = advertised
.iter()
.find(|(i, _)| *i == instance)
.map(|(_, e)| e.clone());
async move {
let (status, error) = match running {
None => ("not running", None),
Some(endpoint) => match probe(&endpoint, &pairing, 0).await {
Probe::Accepted(_) => ("accepted", None),
Probe::NotAccepted => ("not accepted", None),
Probe::NotRunning => ("not running", None),
Probe::Unconfirmed(e) => ("unconfirmed", Some(e)),
Probe::Throttled(e) => ("rate limited", Some(e)),
Probe::Refused(e) => ("refused", Some(e)),
},
};
let mut row = serde_json::json!({
"instance": instance,
"relays": pairing.relays,
"pubkey": pairing.pubkey,
"status": status,
});
if let Some(e) = error {
row["error"] = e.to_string().into();
}
row
}
});
let asked: Vec<_> = rows.map(tokio::spawn).collect();
let mut listed = Vec::with_capacity(asked.len());
for row in asked {
listed.push(
row.await
.map_err(|e| CliError::Other(format!("listing failed: {e}")))?,
);
}
Ok(listed)
}
/// The relays this CLI's stored pairings are for, in bound form, without
/// asking any desktop. For error hints only: a stored pairing may be stale.
pub fn paired_relays() -> Vec<String> {
// Tests never read the real keychain.
#[cfg(test)]
return Vec::new();
#[cfg(not(test))]
paired_relays_in(&Keychain)
}
fn paired_relays_in(store: &dyn PairingStore) -> Vec<String> {
let Ok(Ok(pairings)) = store.read().map(parse_pairings) else {
return Vec::new();
};
let mut relays = Vec::new();
for relay in pairings.0.into_values().flat_map(|p| p.relays) {
push_distinct(&mut relays, relay);
}
relays
}
async fn discover_with(
inherited: Option<(Endpoint, String)>,
store: &dyn PairingStore,
@@ -1122,6 +1338,7 @@ async fn paired_link(
};
let relay = provider_relay_url(relay_url)?;
let mut revoked = Vec::new();
let mut unconfirmed = false;
let mut found = Ok(None);
for (instance, endpoint) in advertised {
let Some(pairing) = pairings.0.get(&instance) else {
@@ -1143,7 +1360,10 @@ async fn paired_link(
);
}
// A stale or unconfirmed advertisement decides nothing: skip it.
Ok(Err(Skip::NotApplicable | Skip::Unreachable | Skip::Unconfirmed)) => {}
Ok(Err(Skip::NotApplicable | Skip::Unreachable)) => {}
// A same-user check that ran out of time decided nothing either,
// but if nothing else serves, the command is worth retrying.
Ok(Err(Skip::Unconfirmed)) => unconfirmed = true,
// The desktop answered (denied, unavailable, ...): that is final.
Err(e) => {
found = Err(e);
@@ -1152,7 +1372,10 @@ async fn paired_link(
}
}
forget_revoked(store, &revoked);
found
match found {
Ok(None) if unconfirmed => Err(ProviderError::Unconfirmed.into_cli("describe")),
found => found,
}
}
#[derive(serde::Deserialize)]
@@ -1170,7 +1393,24 @@ pub async fn pair(
client_name: &str,
instance: Option<&str>,
) -> Result<serde_json::Value, CliError> {
let mut found = advertisements();
pair_among(
&Keychain,
advertisements(),
relay_url,
client_name,
instance,
)
.await
}
/// [`pair`] among the `found` desktops, storing in `store`.
async fn pair_among(
store: &dyn PairingStore,
mut found: Vec<(String, Endpoint)>,
relay_url: &str,
client_name: &str,
instance: Option<&str>,
) -> Result<serde_json::Value, CliError> {
if let Some(want) = instance {
found.retain(|(i, _)| i == want);
}
@@ -1189,7 +1429,7 @@ pub async fn pair(
)));
}
};
pair_with(&Keychain, instance, endpoint, relay_url, client_name).await
pair_with(store, instance, endpoint, relay_url, client_name).await
}
async fn pair_with(
@@ -1282,7 +1522,10 @@ pub(crate) mod tests {
tokio::task_local! {
/// Pretend the caller runs as this user ID.
pub(crate) static CALLER_UID: u32;
/// Make every same-user check run out of time.
pub(crate) static SAME_USER_HANGS: ();
}
use std::sync::Mutex as StdMutex;
#[test]
@@ -2152,6 +2395,279 @@ pub(crate) mod tests {
assert!(store.json.lock().unwrap().is_none());
}
/// A paired desktop whose user cannot be confirmed is skipped without a
/// byte sent, so the chain moves on; a check that only ran out of time
/// leaves the command retryable rather than without credentials.
#[tokio::test]
async fn an_unconfirmed_paired_desktop_is_skipped() {
let (endpoint, seen) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await;
let store = MemoryStore::with(&[("a", "ta", &[RELAY])]);
let ads = || vec![("a".to_owned(), endpoint.clone())];
let found = CALLER_UID
.scope(u32::MAX - 1, discover_with(None, &store, ads, RELAY))
.await
.unwrap();
assert!(found.is_none());
let ads = || vec![("a".to_owned(), endpoint.clone())];
let err = SAME_USER_HANGS
.scope((), discover_with(None, &store, ads, RELAY))
.await
.unwrap_err();
assert!(crate::error::is_retryable_error(&err), "{err:?}");
assert_eq!(crate::error::exit_code(&err), 2);
assert!(seen.lock().unwrap().is_empty());
assert_eq!(store.instances(), ["a"]);
}
/// A command given no relay uses the one relay a running desktop still
/// accepts a pairing for, never a stored pairing alone.
#[tokio::test]
async fn the_default_relay_is_the_one_a_running_desktop_accepts() {
const OTHER: &str = "wss://other.example/";
let (good, _) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await;
let (other, _) = desktop(nostr::Keys::generate(), vec![OTHER.into()]).await;
let (revoked, _) =
provider(|_, _| serde_json::json!({"result": "", "error": "revoked"})).await;
let gone = Endpoint::parse("http://127.0.0.1:1/rpc").unwrap();
let store = MemoryStore::with(&[
("a", "ta", &[RELAY]),
("b", "tb", &[OTHER]),
("c", "tc", &[OTHER]),
("d", "td", &[OTHER]),
]);
let ad = |i: &str, e: &Endpoint| (i.to_owned(), e.clone());
let one = vec![ad("a", &good), ad("c", &revoked), ad("d", &gone)];
assert_eq!(
accepted_relay(&store, one).await.unwrap().as_deref(),
Some("wss://community.example/")
);
// Several accepted relays, or none, keep the built-in default.
let two = vec![ad("a", &good), ad("b", &other)];
assert_eq!(accepted_relay(&store, two).await.unwrap(), None);
let none = vec![ad("c", &revoked), ad("d", &gone)];
assert_eq!(accepted_relay(&store, none).await.unwrap(), None);
// A desktop that no longer binds the paired relay does not count.
let rebound = vec![ad("b", &good)];
assert_eq!(accepted_relay(&store, rebound).await.unwrap(), None);
assert_eq!(
paired_relays_in(&store),
["wss://community.example/", OTHER]
);
}
/// With no relay given, a desktop that refuses is final, and a check that
/// may pass on retry stays retryable, never a fallback to localhost.
#[tokio::test]
async fn the_default_relay_keeps_desktop_failures() {
let store = MemoryStore::with(&[("a", "ta", &[RELAY])]);
let (denied, _) = provider(
|_, _| serde_json::json!({"result": "", "error": "denied", "message": "locked"}),
)
.await;
let err = accepted_relay(&store, vec![("a".into(), denied)])
.await
.unwrap_err();
assert!(!crate::error::is_retryable_error(&err), "{err:?}");
assert!(err.to_string().contains("locked"), "{err}");
let (throttled, _) = provider(
|_, _| serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 1}),
)
.await;
let err = accepted_relay(&store, vec![("a".into(), throttled)])
.await
.unwrap_err();
assert!(crate::error::is_retryable_error(&err), "{err:?}");
let (good, _) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await;
let err = SAME_USER_HANGS
.scope((), accepted_relay(&store, vec![("a".into(), good.clone())]))
.await
.unwrap_err();
assert_eq!(crate::error::exit_code(&err), 2, "{err:?}");
// A still-throttled desktop might add a second relay, so one
// accepted relay elsewhere does not settle the choice.
let (throttled, _) = provider(
|_, _| serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 1}),
)
.await;
let store = MemoryStore::with(&[("a", "ta", &[RELAY]), ("b", "tb", &[RELAY])]);
let ads = vec![("a".into(), good), ("b".into(), throttled)];
let err = accepted_relay(&store, ads).await.unwrap_err();
assert_eq!(crate::error::exit_code(&err), 2, "{err:?}");
}
/// With no usable relay, a retryable failure is reported over an
/// identity mismatch, in either order.
#[test]
fn a_retryable_failure_outranks_an_identity_mismatch() {
let mismatch = || ProviderError::OtherUser.into_cli("describe");
let timeout = || ProviderError::Unconfirmed.into_cli("describe");
for order in [[mismatch(), timeout()], [timeout(), mismatch()]] {
let mut kept = None;
for e in order {
keep_transient(&mut kept, e);
}
assert_eq!(crate::error::exit_code(&kept.unwrap()), 2);
}
let mut kept = None;
keep_transient(&mut kept, mismatch());
assert_eq!(crate::error::exit_code(&kept.unwrap()), 3);
}
/// Through the command's relay selection with no `--relay`: a desktop's
/// refusal and a retryable failure both fail the command.
#[tokio::test]
async fn a_command_given_no_relay_reports_desktop_failures() {
use clap::Parser;
let cli = crate::Cli::try_parse_from(["buzz", "users", "get"]).unwrap();
assert!(cli.relay.is_none());
let store = MemoryStore::with(&[("a", "ta", &[RELAY])]);
let (denied, _) = provider(
|_, _| serde_json::json!({"result": "", "error": "denied", "message": "locked"}),
)
.await;
let paired = accepted_relay(&store, vec![("a".into(), denied)]);
let err = crate::default_relay_with(&cli.command, false, async { Ok(None) }, paired)
.await
.unwrap_err();
assert!(err.to_string().contains("locked"), "{err}");
let (good, _) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await;
let paired = accepted_relay(&store, vec![("a".into(), good.clone())]);
let err = SAME_USER_HANGS
.scope(
(),
crate::default_relay_with(&cli.command, false, async { Ok(None) }, paired),
)
.await
.unwrap_err();
assert_eq!(crate::error::exit_code(&err), 2, "{err:?}");
let paired = accepted_relay(&store, vec![("a".into(), good)]);
let relay = crate::default_relay_with(&cli.command, false, async { Ok(None) }, paired)
.await
.unwrap();
assert_eq!(relay, "wss://community.example/");
}
/// `auth desktop pair --instance b` with no `--relay`, through relay
/// selection and command dispatch: desktop A, which would refuse, is
/// never asked, and B gets exactly one pair request.
#[tokio::test]
async fn pairing_a_chosen_desktop_ignores_another_desktops_refusal() {
use clap::Parser;
let cli =
crate::Cli::try_parse_from(["buzz", "auth", "desktop", "pair", "--instance", "b"])
.unwrap();
let store = MemoryStore::with(&[("a", "ta", &[RELAY])]);
let (refusing, refused) = provider(
|_, _| serde_json::json!({"result": "", "error": "denied", "message": "locked"}),
)
.await;
let pubkey = nostr::Keys::generate().public_key().to_hex();
let answer = serde_json::json!({
"token": "tok", "instance": "b", "pubkey": pubkey, "relays": [RELAY],
});
let reply = answer.to_string();
let (b, seen) = provider(move |_, _| serde_json::json!({ "result": reply })).await;
let found = vec![("a".to_owned(), refusing.clone()), ("b".to_owned(), b)];
let paired = accepted_relay(&store, vec![("a".into(), refusing)]);
let relay = crate::default_relay_with(&cli.command, false, async { Ok(None) }, paired)
.await
.unwrap();
let result = std::cell::RefCell::new(None);
let connected = crate::connect(
&relay,
cli.command,
async |sub, relay| {
let crate::AuthCmd::Desktop(crate::DesktopAuthCmd::Pair { name, instance }) = sub
else {
unreachable!()
};
let out = pair_among(&store, found, relay, &name, instance.as_deref()).await?;
*result.borrow_mut() = Some(out);
Ok(())
},
async |_| unreachable!(),
)
.await
.unwrap();
assert!(connected.is_none());
assert!(refused.lock().unwrap().is_empty(), "A was asked");
let asked = seen.lock().unwrap();
assert_eq!(asked.len(), 1);
assert_eq!(asked[0].2["method"], "pair");
assert_eq!(
asked[0].2["params"],
serde_json::json!(["buzz CLI", "ws://localhost:3000/"])
);
assert_eq!(
result.into_inner().unwrap(),
serde_json::json!({"instance": "b", "pubkey": pubkey, "relays": [RELAY]})
);
}
/// A crashed desktop's leftover advertisement reads as not running, and
/// a throttled one is reported at once, not waited out.
#[tokio::test]
async fn list_tells_a_stale_advertisement_from_a_rejection() {
let closed = {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
Endpoint::parse(&format!("http://127.0.0.1:{port}/rpc")).unwrap()
};
let (throttled, seen) = provider(
|_, _| serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 30}),
)
.await;
let store = MemoryStore::with(&[("a", "ta", &[RELAY]), ("b", "tb", &[RELAY])]);
let ads = vec![("a".to_owned(), closed), ("b".to_owned(), throttled)];
let started = std::time::Instant::now();
let listed = list_with(&store, ads).await.unwrap();
assert!(started.elapsed() < Duration::from_secs(5));
assert_eq!(listed[0]["status"], "not running");
assert_eq!(listed[1]["status"], "rate limited");
assert_eq!(seen.lock().unwrap().len(), 1, "no retries");
}
/// The listing never shows a token and reports each pairing's state the
/// way the default relay judges it.
#[tokio::test]
async fn list_shows_each_pairing_without_its_token() {
assert!(list_with(&MemoryStore::default(), Vec::new())
.await
.unwrap()
.is_empty());
let (good, _) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await;
let (revoked, _) =
provider(|_, _| serde_json::json!({"result": "", "error": "revoked"})).await;
let store = MemoryStore::with(&[
("a", "secret-a", &[RELAY]),
("b", "secret-b", &[RELAY]),
("c", "secret-c", &[RELAY]),
]);
let ads = vec![("a".to_owned(), good), ("b".to_owned(), revoked)];
let listed = list_with(&store, ads).await.unwrap();
let text = serde_json::to_string(&listed).unwrap();
assert!(!text.contains("secret"), "{text}");
let status: Vec<_> = listed.iter().map(|p| p["status"].clone()).collect();
assert_eq!(status, ["accepted", "not accepted", "not running"]);
assert_eq!(listed[0]["relays"], serde_json::json!([RELAY]));
assert_eq!(
store.instances(),
["a", "b", "c"],
"listing changes nothing"
);
}
/// When this user cannot be confirmed on the server side, not one byte
/// (so no token) reaches the endpoint.
#[tokio::test]

View File

@@ -129,7 +129,10 @@ Buzz CLI — interact with a Buzz relay
Configuration (flags override env vars):
BUZZ_RELAY_URL Relay base URL [default: the relay an agent's inherited
Buzz desktop credential acts on, else http://localhost:3000]
Buzz desktop credential acts on; else, with no private key,
the one relay a running Buzz desktop accepts this CLI's
pairing for (see `buzz auth desktop list`); else
http://localhost:3000]
BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [unless a Buzz desktop provides it]
BUZZ_AUTH_TAG NIP-OA auth tag JSON [optional]
BUZZ_FEDERATED_IDENTITY_TOKEN Your NIP-FA sign-in session token [optional]
@@ -147,7 +150,8 @@ Errors are JSON on stderr: {\"error\": \"<category>\", \"message\": \"<detail>\"
struct Cli {
/// Relay URL (http:// or https://). Overrides BUZZ_RELAY_URL env var.
/// Without either, the relay an agent's inherited Buzz desktop credential
/// acts on; otherwise http://localhost:3000.
/// acts on; else, with no private key, the one relay a running Buzz
/// desktop accepts this CLI's pairing for; otherwise http://localhost:3000.
#[arg(long, env = "BUZZ_RELAY_URL")]
relay: Option<String>,
@@ -376,23 +380,58 @@ enum DesktopAuthCmd {
#[arg(long)]
instance: Option<String>,
},
/// List this CLI's pairings: instance, relays, pubkey, and whether a
/// running Buzz desktop accepts each now. Never prints tokens; needs no
/// relay.
List,
}
/// The relay for a command given none: the one an agent's inherited Buzz
/// desktop credential acts on, else `http://localhost:3000`. Local-only
/// unpairing never asks the desktop.
async fn default_relay(command: &Cmd) -> Result<String, CliError> {
/// desktop credential acts on; else, without an explicit key, the one relay
/// a running Buzz desktop still accepts this CLI's pairing for; else
/// `http://localhost:3000`. Local-only unpairing never asks the desktop,
/// and pairing never asks for other pairings.
async fn default_relay(command: &Cmd, explicit_key: bool) -> Result<String, CliError> {
#[cfg(unix)]
let (inherited, paired) = (
desktop_provider::inherited_relay(),
desktop_provider::paired_relay(),
);
#[cfg(not(unix))]
let (inherited, paired) = (async { Ok(None) }, async { Ok(None) });
default_relay_with(command, explicit_key, inherited, paired).await
}
/// [`default_relay`], with the agent and paired-person selectors (each
/// awaited only when needed) parameters so a test can run it against a fake
/// desktop.
pub(crate) async fn default_relay_with(
command: &Cmd,
explicit_key: bool,
inherited: impl std::future::Future<Output = Result<Option<String>, CliError>>,
paired: impl std::future::Future<Output = Result<Option<String>, CliError>>,
) -> Result<String, CliError> {
#[cfg(unix)]
if !matches!(
command,
Cmd::Auth(AuthCmd::Desktop(DesktopAuthCmd::Unpair { .. }))
) {
if let Some(relay) = desktop_provider::inherited_relay().await? {
if let Some(relay) = inherited.await? {
return Ok(relay);
}
// Pairing asks its own desktop; another desktop's answer is moot.
let pairing = matches!(
command,
Cmd::Auth(AuthCmd::Desktop(DesktopAuthCmd::Pair { .. }))
);
if !explicit_key && !pairing {
if let Some(relay) = paired.await? {
return Ok(relay);
}
}
}
#[cfg(not(unix))]
let _ = command;
let _ = (command, explicit_key, inherited, paired);
Ok("http://localhost:3000".to_owned())
}
@@ -404,11 +443,34 @@ async fn run_auth(cmd: AuthCmd, relay_url: &str) -> Result<(), CliError> {
desktop_provider::pair(relay_url, &name, instance.as_deref()).await?
}
DesktopAuthCmd::Unpair { instance } => desktop_provider::unpair(instance.as_deref())?,
DesktopAuthCmd::List => unreachable!("listing runs before any relay is chosen"),
};
println!("{out}");
Ok(())
}
/// `auth desktop list`, in `format`: `compact` drops the pubkey.
#[cfg(unix)]
async fn list_pairings(format: &OutputFormat) -> Result<(), CliError> {
let mut listed = desktop_provider::list().await?;
if matches!(format, OutputFormat::Compact) {
for pairing in &mut listed {
if let Some(fields) = pairing.as_object_mut() {
fields.remove("pubkey");
}
}
}
println!("{}", serde_json::Value::Array(listed));
Ok(())
}
#[cfg(not(unix))]
async fn list_pairings(_: &OutputFormat) -> Result<(), CliError> {
Err(CliError::Usage(
"the Buzz desktop credential provider is not available on Windows yet".into(),
))
}
#[cfg(not(unix))]
async fn run_auth(_: AuthCmd, _: &str) -> Result<(), CliError> {
Err(CliError::Usage(
@@ -2566,9 +2628,14 @@ async fn run(cli: Cli) -> Result<(), CliError> {
..Default::default()
}
.with_env_evidence();
// Listing pairings is local: it needs no relay and runs no discovery.
if let Cmd::Auth(AuthCmd::Desktop(DesktopAuthCmd::List)) = cli.command {
return list_pairings(&cli.format).await;
}
let explicit_key = settings.private_key.is_some();
let relay = match cli.relay {
Some(relay) => relay,
None => default_relay(&cli.command).await?,
None => default_relay(&cli.command, explicit_key).await?,
};
let Some((command, client)) = connect(&relay, cli.command, run_auth, async |relay| {
credentials::resolve(settings, relay).await
@@ -2681,6 +2748,38 @@ mod tests {
Cli::command().debug_assert();
}
/// Relay selection asks only what a command needs: an explicit key never
/// asks for a pairing, unpairing asks nothing, pairing asks for no other
/// pairing, and an agent's inherited relay comes first. `None` for
/// `inherited` means it must not be asked; the paired lookup never is.
#[cfg(unix)]
#[tokio::test]
async fn relay_selection_asks_only_what_a_command_needs() {
use clap::Parser;
const INHERITED: &str = "wss://inherited.example/";
const DEFAULT: &str = "http://localhost:3000";
let cases = [
("users get", true, Some(None), DEFAULT),
("auth desktop unpair", false, None, DEFAULT),
("auth desktop pair", false, Some(None), DEFAULT),
("users get", false, Some(Some(INHERITED)), INHERITED),
];
for (args, explicit_key, inherited, want) in cases {
let argv = std::iter::once("buzz").chain(args.split(' '));
let cli = Cli::try_parse_from(argv).unwrap();
let inherited = async move {
Ok(inherited
.unwrap_or_else(|| panic!("`{args}` asked for an inherited relay"))
.map(str::to_owned))
};
let paired = async { panic!("`{args}` asked for a paired relay") };
let relay = default_relay_with(&cli.command, explicit_key, inherited, paired)
.await
.unwrap();
assert_eq!(relay, want, "`{args}`");
}
}
#[test]
fn messages_send_from_thread_conflicts_with_reply_and_broadcast() {
let base = [