Skip to content
Merged
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
106 changes: 100 additions & 6 deletions src/cli/pairing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!({
Expand Down Expand Up @@ -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)");
Expand Down Expand Up @@ -275,6 +301,9 @@ pub(crate) fn resolve_name_to_target(name: &str) -> Result<DialTarget> {
// 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("");
Expand All @@ -283,15 +312,16 @@ pub(crate) fn resolve_name_to_target(name: &str) -> Result<DialTarget> {
}
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(),
Expand Down Expand Up @@ -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");
});
}
}
33 changes: 26 additions & 7 deletions src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1647,13 +1647,32 @@ fn tool_dial(args: &Value) -> Result<Value, String> {
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:#}"))?;
Expand Down
168 changes: 153 additions & 15 deletions src/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down