diff --git a/src/cli/pairing.rs b/src/cli/pairing.rs index 41d7f4c..93a48e7 100644 --- a/src/cli/pairing.rs +++ b/src/cli/pairing.rs @@ -62,6 +62,23 @@ pub(super) fn cmd_dial(name: &str, message: Option<&str>, as_json: bool) -> Resu "kind": "already_pinned", "handle": handle, })); + // A peer can be pinned in trust yet have NO usable relay slot — its + // endpoint token is still empty because the peer's pair_drop_ack + // hasn't landed (common right after pairing, a daemon/MCP restart, + // or when we're not federation-reachable so the ack can't arrive). + // The bare-nick dial used to stop at a reassuring `already_pinned`, + // so an operator whose `wire send`s were bouncing `peer_unknown` + // would re-dial the bare nick forever, never seeing the real cause + // (which only the send path surfaced). Surface the SAME actionable + // reason here, via the one shared classifier, so the two can't + // drift — the warning names the cause and the exact next command. + if let Some(reason) = crate::send::unsendable_reason(handle) { + steps.push(json!({ + "step": "warning", + "kind": "no_usable_slot", + "detail": reason, + })); + } } DialTarget::LocalSister { session_name, .. } => { steps.push(json!({ @@ -113,7 +130,16 @@ pub(super) fn cmd_dial(name: &str, message: Option<&str>, as_json: bool) -> Resu println!("wire dial: resolved `{name}` → handle `{send_handle}`"); for s in &steps { let step = s.get("step").and_then(Value::as_str).unwrap_or("?"); - println!(" - {step}"); + // A `warning` step carries an actionable `detail` (e.g. pinned but + // no usable slot yet) — print it, not a bare "warning" label, so + // the operator sees the cause + next command inline. + if step == "warning" + && let Some(detail) = s.get("detail").and_then(Value::as_str) + { + println!(" - WARN: {detail}"); + } else { + println!(" - {step}"); + } } if message.is_some() { println!(" (use `wire tail {send_handle}` to read replies)"); @@ -275,6 +301,9 @@ pub(crate) fn resolve_name_to_target(name: &str) -> Result { // keyed by handle (not an array); iterate as a map. if config::is_initialized().unwrap_or(false) { let trust = config::read_trust().unwrap_or(serde_json::Value::Null); + // For the effective (not raw) tier of a matched peer — same computation + // `wire status`/`wire peers` use, so dial/whois agree with them. + let relay_state = config::read_relay_state().unwrap_or_default(); if let Some(agents) = trust.get("agents").and_then(Value::as_object) { for (handle_key, agent) in agents { let did = agent.get("did").and_then(Value::as_str).unwrap_or(""); @@ -283,15 +312,16 @@ pub(crate) fn resolve_name_to_target(name: &str) -> Result { } let handle = handle_key.clone(); let character = crate::character::Character::from_did(did); - let tier = agent - .get("tier") - .and_then(Value::as_str) - .unwrap_or("UNKNOWN") - .to_string(); let matches = handle.eq_ignore_ascii_case(needle) || did.eq_ignore_ascii_case(needle) || character.nickname.eq_ignore_ascii_case(needle); if matches { + // Surface the EFFECTIVE tier — the same PENDING_ACK + // computation every other surface uses — not the raw stored + // tier. A pinned-but-not-yet-acked peer then reports + // PENDING_ACK on dial/whois consistently, instead of a + // misleading VERIFIED that contradicts `wire status`. + let tier = crate::trust::effective_tier(&trust, &relay_state, &handle); return Ok(DialTarget::PinnedPeer { handle, did: did.to_string(), @@ -1748,3 +1778,67 @@ mod self_pair_guard_tests { .unwrap(); } } + +#[cfg(test)] +mod resolve_tier_tests { + use super::*; + use serde_json::json; + + /// `resolve_name_to_target` (the shared dial/whois resolver) must report + /// the EFFECTIVE tier — the same PENDING_ACK computation `wire status` / + /// `wire peers` use — not the raw stored tier. Regression guard for the + /// dial-says-VERIFIED-while-send-bounces-peer_unknown bug: before this, + /// the resolver passed the raw `VERIFIED` straight through, so a dial / + /// whois on a pinned-but-not-yet-acked peer contradicted every other + /// surface. + #[test] + fn resolution_reports_effective_pending_ack_then_verified_when_token_lands() { + config::test_support::with_temp_home(|| { + // Peer pinned VERIFIED in trust. (write_trust runs first: it + // create_dir_all's the config dir, so the bare-fs writes below land.) + config::write_trust(&json!({ + "version": 1, + "agents": { + "willard": { + "tier": "VERIFIED", + "did": "did:wire:willard-deadbeef", + "card": {"capabilities": ["wire/v3.1"]} + } + } + })) + .unwrap(); + // Minimal init so is_initialized() (private key + card) is true. + config::write_private_key(&[1u8; 32]).unwrap(); + config::write_agent_card(&json!({"did": "did:wire:self-0000", "handle": "self"})) + .unwrap(); + // ...but its reply slot has no token yet (pair_drop_ack not landed). + config::write_relay_state(&json!({ + "peers": {"willard": {"endpoints": [ + {"relay_url": "https://relay", "slot_id": "abc", "slot_token": "", "scope": "federation"} + ]}} + })) + .unwrap(); + let tier = match resolve_name_to_target("willard").unwrap() { + DialTarget::PinnedPeer { tier, .. } => tier, + _ => panic!("expected PinnedPeer"), + }; + assert_eq!( + tier, "PENDING_ACK", + "resolver must surface the effective tier, not the raw VERIFIED" + ); + + // Once the token lands, the same resolution reports VERIFIED. + config::write_relay_state(&json!({ + "peers": {"willard": {"endpoints": [ + {"relay_url": "https://relay", "slot_id": "abc", "slot_token": "tok123", "scope": "federation"} + ]}} + })) + .unwrap(); + let tier = match resolve_name_to_target("willard").unwrap() { + DialTarget::PinnedPeer { tier, .. } => tier, + _ => panic!("expected PinnedPeer"), + }; + assert_eq!(tier, "VERIFIED"); + }); + } +} diff --git a/src/mcp.rs b/src/mcp.rs index daa44f0..51067fc 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -1647,13 +1647,32 @@ fn tool_dial(args: &Value) -> Result { match crate::cli::resolve_name_to_target(name) { Ok(crate::cli::DialTarget::PinnedPeer { handle, did, tier, .. - }) => Ok(json!({ - "name_input": name, - "status": "already_pinned", - "peer_handle": handle, - "did": did, - "tier": tier, - })), + }) => { + // Pinned ≠ sendable. If the peer's relay slot still has no token + // (their pair_drop_ack hasn't landed — common right after pairing, + // an MCP/daemon restart, or when we're not federation-reachable so + // the ack can't arrive), a follow-up wire_send bounces + // `peer_unknown`. A bare `already_pinned` here is exactly what sent + // an MCP agent into the re-dial loop. Surface the SAME actionable + // reason the send path emits, via the one shared classifier, so the + // dial + send surfaces can't drift. `sendable` is the machine flag; + // `reason` (present only when blocked) the cause + next command. + let reason = crate::send::unsendable_reason(&handle); + let mut out = json!({ + "name_input": name, + "status": "already_pinned", + "peer_handle": handle, + "did": did, + "tier": tier, + "sendable": reason.is_none(), + }); + if let Some(r) = reason + && let Some(obj) = out.as_object_mut() + { + obj.insert("reason".into(), json!(r)); + } + Ok(out) + } Ok(crate::cli::DialTarget::LocalSister { session_name, .. }) => { let drop = crate::cli::add_local_sister_core(&session_name).map_err(|e| format!("{e:#}"))?; diff --git a/src/send.rs b/src/send.rs index 89a373c..f9de0cc 100644 --- a/src/send.rs +++ b/src/send.rs @@ -342,6 +342,47 @@ fn peer_unknown_reason( } } +/// Classify why `peer` is (un)sendable from live trust + relay-state. +/// Returns `None` when the peer has at least one endpoint carrying a +/// non-empty `slot_token` — i.e. a send has a route. Returns +/// `Some(reason)` — the SAME actionable string the send path surfaces on +/// `peer_unknown` — when the peer is not pinned, has no endpoint, or has an +/// endpoint whose `slot_token` is still empty (the `pair_drop_ack` hasn't +/// landed yet). Shared by the send path (`delivery_json`) and the dial path +/// (`cmd_dial`) so the two surfaces can't drift: a bare-nick `wire dial` +/// that resolves an already-pinned-but-unsendable peer can show the operator +/// the exact same cause + next-command that a bouncing `wire send` would. +pub(crate) fn unsendable_reason(peer: &str) -> Option { + let trust = crate::config::read_trust().unwrap_or_default(); + let state = crate::config::read_relay_state().unwrap_or_default(); + let trusted = trust.get("agents").and_then(|a| a.get(peer)).is_some(); + let eps = crate::endpoints::peer_endpoints_in_priority_order(&state, peer); + let has_endpoint = !eps.is_empty(); + // Mirror the send loop's usability test EXACTLY (the `continue` skip in + // `sync_send`): an endpoint routes only when relay_url + slot_id + + // slot_token are ALL non-empty. A token sitting on an otherwise-malformed + // endpoint is skipped there, so it must not read as "usable" here either. + let has_usable_slot = eps + .iter() + .any(|e| !e.relay_url.is_empty() && !e.slot_id.is_empty() && !e.slot_token.is_empty()); + // RFC-007 D3: the send path also delivers over Nostr when no HTTP endpoint + // routes, provided the peer has a recorded `nostr_transport` AND this + // session holds a secp transport key. A peer reachable only that way IS + // sendable — don't warn on dial that its HTTP slot has no token. + let nostr_reachable = crate::endpoints::peer_nostr_transport(&state, peer).is_some() + && crate::config::read_nostr_key().is_ok(); + if has_usable_slot || nostr_reachable { + None + } else { + Some(peer_unknown_reason( + peer, + trusted, + has_endpoint, + has_usable_slot, + )) + } +} + /// Render a `SyncDelivery` as the JSON value `wire send --json` / /// `tool_send` return. Fields are flat (no nested struct) so JSON /// consumers can read `.status` + `.event_id` directly without @@ -394,21 +435,13 @@ pub fn delivery_json(d: &SyncDelivery, peer: &str) -> Value { // Classify against live state so the message names the real cause // and the real fix (a FULL `@relay` dial, not the nickname short- // circuit which reports already_pinned without re-registering). - let trust = crate::config::read_trust().unwrap_or_default(); - let state = crate::config::read_relay_state().unwrap_or_default(); - let trusted = trust.get("agents").and_then(|a| a.get(peer)).is_some(); - let eps = crate::endpoints::peer_endpoints_in_priority_order(&state, peer); - let has_endpoint = !eps.is_empty(); - let has_usable_slot = eps.iter().any(|e| !e.slot_token.is_empty()); - obj.insert( - "reason".into(), - json!(peer_unknown_reason( - peer, - trusted, - has_endpoint, - has_usable_slot - )), - ); + // Via the shared `unsendable_reason` classifier the dial path + // reuses. `None` here would mean a usable slot exists despite the + // send reporting PeerUnknown (a route raced away mid-send) — fall + // back to the generic "could not be reached" guidance. + let reason = unsendable_reason(peer) + .unwrap_or_else(|| peer_unknown_reason(peer, true, true, true)); + obj.insert("reason".into(), json!(reason)); } } Value::Object(obj) @@ -439,6 +472,111 @@ mod tests { assert!(r.contains("could not be reached"), "{r}"); } + #[test] + fn unsendable_reason_reads_live_state() { + use crate::endpoints::{Endpoint, EndpointScope, pin_peer_endpoints}; + crate::config::test_support::with_temp_home(|| { + // Unknown peer, empty home → the send path's "not pinned" verdict. + let r = unsendable_reason("ghost").expect("unknown peer is unsendable"); + assert!(r.contains("is not pinned"), "{r}"); + + // Peer with a usable federation slot → sendable → None. Guards the + // common bare-nick dial: no spurious warning when a route exists. + let mut st = crate::config::read_relay_state().unwrap(); + pin_peer_endpoints( + &mut st, + "live", + &[Endpoint { + relay_url: "https://wireup.net".into(), + slot_id: "slot-live".into(), + slot_token: "tok-abc".into(), + scope: EndpointScope::Federation, + }], + ) + .unwrap(); + crate::config::write_relay_state(&st).unwrap(); + assert!( + unsendable_reason("live").is_none(), + "peer with a non-empty slot_token must read as sendable" + ); + + // Pinned in trust but its endpoint token is still empty (the + // pair_drop_ack hasn't landed — the #284.6 desync the dial path + // must now surface instead of a bland `already_pinned`). + let mut st2 = crate::config::read_relay_state().unwrap(); + pin_peer_endpoints( + &mut st2, + "pending", + &[Endpoint { + relay_url: "https://wireup.net".into(), + slot_id: "slot-pending".into(), + slot_token: String::new(), + scope: EndpointScope::Federation, + }], + ) + .unwrap(); + crate::config::write_relay_state(&st2).unwrap(); + crate::config::update_trust(|t| { + t.get_mut("agents") + .and_then(Value::as_object_mut) + .unwrap() + .insert( + "pending".into(), + json!({"did": "did:wire:pending-0000", "tier": "VERIFIED"}), + ); + Ok(()) + }) + .unwrap(); + let r = unsendable_reason("pending").expect("empty-token peer is unsendable"); + assert!(r.contains("no token yet"), "{r}"); + + // Reachable only over Nostr: empty HTTP token, but a recorded + // nostr_transport + a local nostr key → sendable (the RFC-007 D3 + // fallback the send path takes), so NO dial warning. + let mut st3 = crate::config::read_relay_state().unwrap(); + pin_peer_endpoints( + &mut st3, + "nostronly", + &[Endpoint { + relay_url: "https://wireup.net".into(), + slot_id: "slot-n".into(), + slot_token: String::new(), + scope: EndpointScope::Federation, + }], + ) + .unwrap(); + st3["peers"]["nostronly"]["nostr_transport"] = + json!({"npub": "npub1xxx", "relay": "wss://relay.example"}); + crate::config::write_relay_state(&st3).unwrap(); + crate::config::write_nostr_key(&[3u8; 32]).unwrap(); + assert!( + unsendable_reason("nostronly").is_none(), + "a Nostr-reachable peer must read as sendable despite an empty HTTP token" + ); + + // Malformed endpoint: a token present but relay_url/slot_id empty is + // NOT a usable route (the send loop skips it), so still unsendable — + // matches the send path's `continue` skip exactly. + let mut st4 = crate::config::read_relay_state().unwrap(); + pin_peer_endpoints( + &mut st4, + "malformed", + &[Endpoint { + relay_url: String::new(), + slot_id: String::new(), + slot_token: "tok-orphan".into(), + scope: EndpointScope::Federation, + }], + ) + .unwrap(); + crate::config::write_relay_state(&st4).unwrap(); + assert!( + unsendable_reason("malformed").is_some(), + "a token on an endpoint with empty relay_url/slot_id is not usable" + ); + }); + } + #[test] fn status_str_matches_variant() { let d = SyncDelivery::Delivered {