use anyhow::{Context, Result, anyhow, bail};
use serde_json::{Value, json};
use crate::config;
pub(super) fn cmd_dial(name: &str, message: Option<&str>, as_json: bool) -> Result<()> {
if name.contains('@') {
cmd_add(name, None, false, true)
.map_err(|e| anyhow!("wire dial: federation pair to `{name}` failed: {e:#}"))?;
if let Some(msg) = message {
let bare = name.split('@').next().unwrap_or(name);
super::comms::cmd_send(bare, "claim", msg, None, false, false, as_json)?;
}
return Ok(());
}
let resolution = match resolve_name_to_target(name) {
Ok(r) => r,
Err(e) if as_json => {
let pool = super::known_local_names();
let suggestions = super::closest_candidates(name, &pool, 3, 3);
println!(
"{}",
serde_json::to_string(&json!({
"name_input": name,
"found": false,
"candidates": suggestions,
"error": format!("{e:#}"),
}))?
);
return Ok(());
}
Err(e) => return Err(e),
};
let mut steps: Vec<Value> = Vec::new();
match &resolution {
DialTarget::PinnedPeer { handle, .. } => {
steps.push(json!({
"step": "resolved",
"kind": "already_pinned",
"handle": handle,
}));
}
DialTarget::LocalSister { session_name, .. } => {
steps.push(json!({
"step": "resolved",
"kind": "local_sister",
"session": session_name,
}));
cmd_add_local_sister(session_name, true).map_err(|e| {
anyhow!("dial: local-sister pair to `{session_name}` failed: {e:#}")
})?;
steps.push(json!({
"step": "paired",
"via": "local_sister",
}));
}
}
let send_handle = match &resolution {
DialTarget::PinnedPeer { handle, .. } => handle.clone(),
DialTarget::LocalSister { handle, .. } => handle.clone(),
};
let send_result = if let Some(msg) = message {
let r = super::comms::cmd_send(&send_handle, "claim", msg, None, false, false, true);
match &r {
Ok(()) => steps.push(json!({"step": "sent", "to": send_handle, "kind": "claim"})),
Err(e) => steps.push(json!({"step": "send_failed", "error": format!("{e:#}")})),
}
Some(r)
} else {
None
};
if as_json {
println!(
"{}",
serde_json::to_string(&json!({
"name_input": name,
"resolved_handle": send_handle,
"steps": steps,
}))?
);
} else {
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}");
}
if message.is_some() {
println!(" (use `wire tail {send_handle}` to read replies)");
}
}
if let Some(Err(e)) = send_result {
return Err(e);
}
Ok(())
}
pub(super) fn cmd_whois_local(name: &str, as_json: bool) -> Result<()> {
let resolution = match resolve_name_to_target(name) {
Ok(r) => r,
Err(e) if as_json => {
let pool = super::known_local_names();
let suggestions = super::closest_candidates(name, &pool, 3, 3);
println!(
"{}",
serde_json::to_string(&json!({
"name_input": name,
"found": false,
"candidates": suggestions,
"error": format!("{e:#}"),
}))?
);
return Ok(());
}
Err(e) => return Err(e),
};
match resolution {
DialTarget::PinnedPeer {
handle,
did,
nickname,
emoji,
tier,
} => {
let op_claims = config::read_trust()
.ok()
.and_then(|t| {
t.get("agents")
.and_then(Value::as_object)
.and_then(|m| m.get(&handle))
.and_then(|a| a.get("card").cloned())
})
.map(|c| super::op_claims_from_card(&c))
.unwrap_or_default();
if as_json {
let mut payload = serde_json::Map::new();
payload.insert("kind".into(), json!("pinned_peer"));
payload.insert("handle".into(), json!(handle));
payload.insert("did".into(), json!(did));
payload.insert("nickname".into(), json!(nickname));
payload.insert("emoji".into(), json!(emoji));
payload.insert("tier".into(), json!(tier));
for (k, v) in &op_claims {
payload.insert(k.clone(), v.clone());
}
println!("{}", serde_json::to_string(&payload)?);
} else {
let n = nickname.as_deref().unwrap_or("(no character)");
let e = emoji.as_deref().unwrap_or("?");
println!("{e} {n}");
println!(" handle: {handle}");
println!(" did: {did}");
println!(" tier: {tier}");
if let Some(op_did) = op_claims.get("op_did").and_then(Value::as_str) {
println!(" op_did: {op_did}");
}
println!(" reach: pinned peer (already in trust ring + slot pinned)");
}
}
DialTarget::LocalSister {
session_name,
handle,
did,
nickname,
emoji,
} => {
if as_json {
println!(
"{}",
serde_json::to_string(&json!({
"kind": "local_sister",
"session_name": session_name,
"handle": handle,
"did": did,
"nickname": nickname,
"emoji": emoji,
}))?
);
} else {
let n = nickname.as_deref().unwrap_or("(no character)");
let e = emoji.as_deref().unwrap_or("?");
println!("{e} {n}");
println!(" session: {session_name}");
println!(" handle: {handle}");
println!(
" did: {}",
did.as_deref().unwrap_or("(card unreadable)")
);
println!(" reach: local sister on this machine — `wire dial {n}` pairs us");
}
}
}
Ok(())
}
pub(crate) enum DialTarget {
PinnedPeer {
handle: String,
did: String,
nickname: Option<String>,
emoji: Option<String>,
tier: String,
},
LocalSister {
session_name: String,
handle: String,
did: Option<String>,
nickname: Option<String>,
emoji: Option<String>,
},
}
pub(crate) fn resolve_name_to_target(name: &str) -> Result<DialTarget> {
let needle = name.trim();
if needle.is_empty() {
bail!("empty name");
}
if config::is_initialized().unwrap_or(false) {
let trust = config::read_trust().unwrap_or(serde_json::Value::Null);
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("");
if did.is_empty() {
continue;
}
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 {
return Ok(DialTarget::PinnedPeer {
handle,
did: did.to_string(),
nickname: Some(character.nickname),
emoji: Some(character.emoji.to_string()),
tier,
});
}
}
}
}
if let Some(session_name) = crate::session::resolve_local_sister(needle) {
let sessions = crate::session::list_sessions().unwrap_or_default();
let s = sessions.iter().find(|s| s.name == session_name);
if let Some(s) = s {
return Ok(DialTarget::LocalSister {
session_name: s.name.clone(),
handle: s.handle.clone().unwrap_or_else(|| s.name.clone()),
did: s.did.clone(),
nickname: s.character.as_ref().map(|c| c.nickname.clone()),
emoji: s.character.as_ref().map(|c| c.emoji.to_string()),
});
}
}
let pool = super::known_local_names();
let suggestions = super::closest_candidates(name, &pool, 3, 3);
if suggestions.is_empty() {
bail!(
"no peer matched `{name}`.\n\
Tried: pinned peers (`wire peers`) + local sister sessions \
(`wire session list-local`).\n\
For cross-machine federation: `wire dial <handle>@<relay-domain>`."
);
}
bail!(
"no peer matched `{name}`.\n\
Did you mean: {}?\n\
List all: `wire peers`, `wire session list-local`.",
suggestions
.iter()
.map(|s| format!("`{s}`"))
.collect::<Vec<_>>()
.join(", ")
);
}
pub(super) fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
let body =
std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
let card: Value =
serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
crate::agent_card::verify_agent_card(&card)
.map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
let mut trust = config::read_trust()?;
crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
let did = card.get("did").and_then(Value::as_str).unwrap_or("");
let handle = crate::agent_card::display_handle_from_did(did).to_string();
config::write_trust(&trust)?;
if as_json {
println!(
"{}",
serde_json::to_string(&json!({
"handle": handle,
"did": did,
"tier": "VERIFIED",
"pinned": true,
}))?
);
} else {
println!("pinned {handle} ({did}) at tier VERIFIED");
}
Ok(())
}
pub(super) fn cmd_invite(
relay: &str,
ttl: u64,
uses: u32,
share: bool,
as_json: bool,
) -> Result<()> {
let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
let share_payload: Option<Value> = if share {
let client = reqwest::blocking::Client::new();
let single_use = if uses == 1 { Some(1u32) } else { None };
let body = json!({
"invite_url": url,
"ttl_seconds": ttl,
"uses": single_use,
});
let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
let resp = client.post(&endpoint).json(&body).send()?;
if !resp.status().is_success() {
let code = resp.status();
let txt = resp.text().unwrap_or_default();
bail!("relay {code} on /v1/invite/register: {txt}");
}
let parsed: Value = resp.json()?;
let token = parsed
.get("token")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
.to_string();
let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
let curl_line = format!("curl -fsSL {share_url} | sh");
Some(json!({
"token": token,
"share_url": share_url,
"curl": curl_line,
"expires_unix": parsed.get("expires_unix"),
}))
} else {
None
};
if as_json {
let mut out = json!({
"invite_url": url,
"ttl_secs": ttl,
"uses": uses,
"relay": relay,
});
if let Some(s) = &share_payload {
out["share"] = s.clone();
}
println!("{}", serde_json::to_string(&out)?);
} else if let Some(s) = share_payload {
let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
println!("{curl}");
} else {
eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
eprintln!("# TTL: {ttl}s. Uses: {uses}.");
println!("{url}");
}
Ok(())
}
pub(super) fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
let resolved = if url.starts_with("http://") || url.starts_with("https://") {
let sep = if url.contains('?') { '&' } else { '?' };
let resolve_url = format!("{url}{sep}format=url");
let client = reqwest::blocking::Client::new();
let resp = client
.get(&resolve_url)
.send()
.with_context(|| format!("GET {resolve_url}"))?;
if !resp.status().is_success() {
bail!("could not resolve short URL {url} (HTTP {})", resp.status());
}
let body = resp.text().unwrap_or_default().trim().to_string();
if !body.starts_with("wire://pair?") {
bail!(
"short URL {url} did not resolve to a wire:// invite. \
(got: {}{})",
body.chars().take(80).collect::<String>(),
if body.chars().count() > 80 { "…" } else { "" }
);
}
body
} else {
url.to_string()
};
let result = crate::pair_invite::accept_invite(&resolved)?;
if as_json {
println!("{}", serde_json::to_string(&result)?);
} else {
let did = result
.get("paired_with")
.and_then(Value::as_str)
.unwrap_or("?");
println!("paired with {did}");
println!(
"you can now: wire send {} <kind> <body>",
crate::agent_card::display_handle_from_did(did)
);
}
Ok(())
}
pub(super) fn cmd_whois(
handle: Option<&str>,
as_json: bool,
relay_override: Option<&str>,
) -> Result<()> {
if let Some(h) = handle {
let parsed = crate::pair_profile::parse_handle(h)?;
if config::is_initialized()? {
let card = config::read_agent_card()?;
let local_handle = card
.get("profile")
.and_then(|p| p.get("handle"))
.and_then(Value::as_str)
.map(str::to_string);
if local_handle.as_deref() == Some(h) {
return cmd_whois(None, as_json, None);
}
}
let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
if as_json {
println!("{}", serde_json::to_string(&resolved)?);
} else {
print_resolved_profile(&resolved);
}
return Ok(());
}
let card = config::read_agent_card()?;
if as_json {
let profile = card.get("profile").cloned().unwrap_or(Value::Null);
let mut payload = serde_json::Map::new();
payload.insert(
"did".into(),
card.get("did").cloned().unwrap_or(Value::Null),
);
payload.insert("profile".into(), profile);
for (k, v) in super::op_claims_from_card(&card) {
payload.insert(k, v);
}
println!("{}", serde_json::to_string(&payload)?);
} else {
print!("{}", crate::pair_profile::render_self_summary()?);
}
Ok(())
}
fn print_resolved_profile(resolved: &Value) {
let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
let relay = resolved
.get("relay_url")
.and_then(Value::as_str)
.unwrap_or("");
let slot = resolved
.get("slot_id")
.and_then(Value::as_str)
.unwrap_or("");
let profile = resolved
.get("card")
.and_then(|c| c.get("profile"))
.cloned()
.unwrap_or(Value::Null);
println!("{did}");
println!(" nick: {nick}");
if !relay.is_empty() {
println!(" relay_url: {relay}");
}
if !slot.is_empty() {
println!(" slot_id: {slot}");
}
let pick =
|k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
if let Some(s) = pick("display_name") {
println!(" display_name: {s}");
}
if let Some(s) = pick("emoji") {
println!(" emoji: {s}");
}
if let Some(s) = pick("motto") {
println!(" motto: {s}");
}
if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
let joined: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
println!(" vibe: {}", joined.join(", "));
}
if let Some(s) = pick("pronouns") {
println!(" pronouns: {s}");
}
}
fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
let peer_domain = peer_domain.trim().to_ascii_lowercase();
if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
return true;
}
let our_host = super::host_of_url(our_relay_url).to_ascii_lowercase();
if !our_host.is_empty() && our_host == peer_domain {
return true;
}
false
}
fn resolve_local_session<'a>(
sessions: &'a [crate::session::SessionInfo],
input: &str,
) -> Result<&'a crate::session::SessionInfo, ResolveError> {
if let Some(s) = sessions.iter().find(|s| s.name == input) {
return Ok(s);
}
let nick_matches: Vec<&crate::session::SessionInfo> = sessions
.iter()
.filter(|s| {
s.character
.as_ref()
.map(|c| c.nickname == input)
.unwrap_or(false)
})
.collect();
match nick_matches.len() {
0 => Err(ResolveError::NotFound),
1 => Ok(nick_matches[0]),
_ => Err(ResolveError::Ambiguous(
nick_matches.iter().map(|s| s.name.clone()).collect(),
)),
}
}
#[derive(Debug)]
pub(crate) enum ResolveError {
NotFound,
Ambiguous(Vec<String>),
}
pub(crate) fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
let trust = match config::read_trust() {
Ok(t) => t,
Err(_) => return Ok(None),
};
let agents = match trust.get("agents").and_then(|a| a.as_object()) {
Some(a) => a,
None => return Ok(None),
};
if agents.contains_key(input) {
return Ok(Some(input.to_string()));
}
let mut nick_matches: Vec<String> = Vec::new();
for (handle, agent) in agents.iter() {
let character = match agent.get("card") {
Some(card) => crate::character::Character::from_card(card),
None => match agent.get("did").and_then(Value::as_str) {
Some(did) => crate::character::Character::from_did(did),
None => continue,
},
};
if character.nickname == input {
nick_matches.push(handle.clone());
}
}
match nick_matches.len() {
0 => Ok(None),
1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
_ => Err(ResolveError::Ambiguous(nick_matches)),
}
}
pub(crate) struct LocalSisterDrop {
pub resolved_session: String,
pub paired_with_did: String,
pub peer_handle: String,
pub event_id: String,
pub delivered_via: String,
pub delivery_relay_url: String,
}
pub(crate) fn add_local_sister_core(sister_name: &str) -> Result<LocalSisterDrop> {
let sessions = crate::session::list_sessions()?;
let sister = match resolve_local_session(&sessions, sister_name) {
Ok(s) => s,
Err(ResolveError::NotFound) => bail!(
"no sister session named `{sister_name}` (matched by session name or character nickname). \
Run `wire session list` to see what's available."
),
Err(ResolveError::Ambiguous(candidates)) => bail!(
"nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
Disambiguate by passing the session name (one of those listed) instead of the nickname.",
candidates.len(),
candidates.join(", ")
),
};
let our_card =
config::read_agent_card().map_err(|_| anyhow!("not initialized — run `wire up` first"))?;
let our_did = our_card
.get("did")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("agent-card missing did"))?
.to_string();
if let Some(sister_did) = sister.did.as_deref()
&& sister_did == our_did
{
bail!("refusing to add self (`{sister_name}` is this very session)");
}
let sister_card_path = sister
.home_dir
.join("config")
.join("wire")
.join("agent-card.json");
let sister_card: Value = serde_json::from_slice(
&std::fs::read(&sister_card_path)
.with_context(|| format!("reading sister card {sister_card_path:?}"))?,
)
.with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
let sister_relay_state: Value = std::fs::read(
sister
.home_dir
.join("config")
.join("wire")
.join("relay.json"),
)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
let sister_did = sister_card
.get("did")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("sister card missing did"))?
.to_string();
let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
if sister_endpoints.is_empty() {
bail!(
"sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
);
}
let sister_local = sister_endpoints
.iter()
.find(|e| e.scope == crate::endpoints::EndpointScope::Local);
let delivery_endpoint = match sister_local {
Some(e) => e.clone(),
None => sister_endpoints[0].clone(),
};
let our_relay_state = config::read_relay_state()?;
let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
if our_endpoints.is_empty() {
bail!(
"this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
);
}
let our_advertised = our_endpoints
.iter()
.find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
.cloned()
.unwrap_or_else(|| our_endpoints[0].clone());
let mut trust = config::read_trust()?;
crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
config::write_trust(&trust)?;
let mut relay_state = config::read_relay_state()?;
crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
config::write_relay_state(&relay_state)?;
let sk_seed = config::read_private_key()?;
let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
let pk_b64 = our_card
.get("verify_keys")
.and_then(Value::as_object)
.and_then(|m| m.values().next())
.and_then(|v| v.get("key"))
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
let pk_bytes = crate::signing::b64decode(pk_b64)?;
let now = time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default();
let mut body = json!({
"card": our_card,
"relay_url": our_advertised.relay_url,
"slot_id": our_advertised.slot_id,
"slot_token": our_advertised.slot_token,
});
body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
let event = json!({
"schema_version": crate::signing::EVENT_SCHEMA_VERSION,
"timestamp": now,
"from": our_did,
"to": sister_did,
"type": "pair_drop",
"kind": 1100u32,
"body": body,
});
let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
client
.post_event(
&delivery_endpoint.slot_id,
&delivery_endpoint.slot_token,
&signed,
)
.with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
let delivered_via = match delivery_endpoint.scope {
crate::endpoints::EndpointScope::Local => "local",
crate::endpoints::EndpointScope::Lan => "lan",
crate::endpoints::EndpointScope::Uds => "uds",
crate::endpoints::EndpointScope::Federation => "federation",
}
.to_string();
Ok(LocalSisterDrop {
resolved_session: sister.name.clone(),
paired_with_did: sister_did,
peer_handle: sister_handle,
event_id,
delivered_via,
delivery_relay_url: delivery_endpoint.relay_url.clone(),
})
}
pub(crate) fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
let drop = add_local_sister_core(sister_name)?;
if drop.resolved_session != sister_name {
eprintln!(
"wire add: resolved nickname `{sister_name}` → session `{}`",
drop.resolved_session
);
}
if as_json {
println!(
"{}",
serde_json::to_string(&json!({
"handle": sister_name,
"paired_with": drop.paired_with_did,
"peer_handle": drop.peer_handle,
"event_id": drop.event_id,
"delivered_via": drop.delivered_via,
"status": "drop_sent",
}))?
);
} else {
println!(
"→ found sister `{sister_name}` (did={})\n→ pinned peer locally\n→ pair_drop delivered to {} slot on {}\nawaiting pair_drop_ack from {} to complete bilateral pin.",
drop.paired_with_did, drop.delivered_via, drop.delivery_relay_url, drop.peer_handle
);
}
Ok(())
}
pub(super) fn cmd_add(
handle_arg: &str,
relay_override: Option<&str>,
local_sister: bool,
as_json: bool,
) -> Result<()> {
if local_sister {
let resolved = crate::session::resolve_local_sister(handle_arg)
.unwrap_or_else(|| handle_arg.to_string());
return cmd_add_local_sister(&resolved, as_json);
}
if !handle_arg.contains('@')
&& let Some(resolved) = crate::session::resolve_local_sister(handle_arg)
{
eprintln!(
"wire add: `{handle_arg}` resolved to local sister session `{resolved}` \
— routing via --local-sister (disk-read card, no relay lookup)."
);
return cmd_add_local_sister(&resolved, as_json);
}
if !handle_arg.contains('@') {
bail!(
"`{handle_arg}` doesn't match any local sister session and has no \
@<relay> suffix for federation.\n\
— Local sisters: `wire session list-local` (operator types name OR \
character nickname)\n\
— Federation: `wire add <handle>@<relay-domain>` (e.g. \
`wire add alice@wireup.net`)"
);
}
let parsed = crate::pair_profile::parse_handle(handle_arg)?;
let (our_did, our_relay, our_slot_id, our_slot_token) =
crate::pair_invite::ensure_self_with_relay(relay_override)?;
if our_did == format!("did:wire:{}", parsed.nick) {
bail!("refusing to add self (handle matches own DID)");
}
if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
return cmd_add_accept_pending(
handle_arg,
&parsed.nick,
&pending,
&our_relay,
&our_slot_id,
&our_slot_token,
as_json,
);
}
if !is_known_relay_domain(&parsed.domain, &our_relay) {
eprintln!(
"wire add: WARN unfamiliar relay domain `{}`.",
parsed.domain
);
eprintln!(
" This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
super::host_of_url(&our_relay)
);
eprintln!(
" and not on the known-good list. If you meant `{}@wireup.net`, ",
parsed.nick
);
eprintln!(
" run `wire add {}@wireup.net` instead. Otherwise verify with your",
parsed.nick
);
eprintln!(" peer out-of-band that they actually run a relay at this domain");
eprintln!(" before relying on the pair. (See issue #9.4.)");
}
let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
let peer_card = resolved
.get("card")
.cloned()
.ok_or_else(|| anyhow!("resolved missing card"))?;
let peer_did = resolved
.get("did")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("resolved missing did"))?
.to_string();
let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
reject_self_pair_after_resolution(&our_did, &peer_did)?;
let peer_slot_id = resolved
.get("slot_id")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("resolved missing slot_id"))?
.to_string();
let peer_relay = resolved
.get("relay_url")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| relay_override.map(str::to_string))
.unwrap_or_else(|| format!("https://{}", parsed.domain));
let mut trust = config::read_trust()?;
crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
config::write_trust(&trust)?;
let mut relay_state = config::read_relay_state()?;
let mut endpoints: Vec<crate::endpoints::Endpoint> = relay_state
.get("peers")
.and_then(|p| p.get(&peer_handle))
.and_then(|e| e.get("endpoints"))
.and_then(|a| serde_json::from_value::<Vec<crate::endpoints::Endpoint>>(a.clone()).ok())
.unwrap_or_default();
let fed_token = endpoints
.iter()
.find(|e| {
e.relay_url == peer_relay && e.scope == crate::endpoints::EndpointScope::Federation
})
.map(|e| e.slot_token.clone())
.unwrap_or_default();
let fed_ep = crate::endpoints::Endpoint {
relay_url: peer_relay.clone(),
slot_id: peer_slot_id.clone(),
slot_token: fed_token, scope: crate::endpoints::EndpointScope::Federation,
};
if let Some(existing) = endpoints
.iter_mut()
.find(|e| e.relay_url == fed_ep.relay_url)
{
*existing = fed_ep;
} else {
endpoints.push(fed_ep);
}
crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints)?;
config::write_relay_state(&relay_state)?;
let our_card = config::read_agent_card()?;
let sk_seed = config::read_private_key()?;
let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
let pk_b64 = our_card
.get("verify_keys")
.and_then(Value::as_object)
.and_then(|m| m.values().next())
.and_then(|v| v.get("key"))
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
let pk_bytes = crate::signing::b64decode(pk_b64)?;
let now = time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default();
let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
let mut body = json!({
"card": our_card,
"relay_url": our_relay,
"slot_id": our_slot_id,
"slot_token": our_slot_token,
});
if !our_endpoints.is_empty() {
body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
}
let event = json!({
"schema_version": crate::signing::EVENT_SCHEMA_VERSION,
"timestamp": now,
"from": our_did,
"to": peer_did,
"type": "pair_drop",
"kind": 1100u32,
"body": body,
});
let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
let client = crate::relay_client::RelayClient::new(&peer_relay);
let resp = client.handle_intro(&parsed.nick, &signed)?;
let event_id = signed
.get("event_id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if as_json {
println!(
"{}",
serde_json::to_string(&json!({
"handle": handle_arg,
"paired_with": peer_did,
"peer_handle": peer_handle,
"event_id": event_id,
"drop_response": resp,
"status": "drop_sent",
}))?
);
} else {
println!(
"→ resolved {handle_arg} (did={peer_did})\n→ pinned peer locally\n→ intro dropped to {peer_relay}\nawaiting pair_drop_ack from {peer_handle} to complete bilateral pin."
);
}
Ok(())
}
fn cmd_add_accept_pending(
handle_arg: &str,
peer_nick: &str,
pending: &crate::pending_inbound_pair::PendingInboundPair,
_our_relay: &str,
_our_slot_id: &str,
_our_slot_token: &str,
as_json: bool,
) -> Result<()> {
let mut trust = config::read_trust()?;
crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
config::write_trust(&trust)?;
let mut relay_state = config::read_relay_state()?;
let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
vec![crate::endpoints::Endpoint::federation(
pending.peer_relay_url.clone(),
pending.peer_slot_id.clone(),
pending.peer_slot_token.clone(),
)]
} else {
pending.peer_endpoints.clone()
};
crate::endpoints::pin_peer_endpoints(
&mut relay_state,
&pending.peer_handle,
&endpoints_to_pin,
)?;
config::write_relay_state(&relay_state)?;
crate::pair_invite::send_pair_drop_ack(&pending.peer_handle, &endpoints_to_pin).with_context(
|| {
format!(
"pair_drop_ack send to {} (across {} endpoint(s)) failed",
pending.peer_handle,
endpoints_to_pin.len()
)
},
)?;
crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
if as_json {
println!(
"{}",
serde_json::to_string(&json!({
"handle": handle_arg,
"paired_with": pending.peer_did,
"peer_handle": pending.peer_handle,
"status": "bilateral_accepted",
"via": "pending_inbound",
}))?
);
} else {
println!(
"→ accepted pending pair from {peer}\n→ pinned VERIFIED, slot_token recorded\n→ shipped our slot_token back via pair_drop_ack\nbilateral pair complete. Send with `wire send {peer} \"...\"`.",
peer = pending.peer_handle,
);
}
Ok(())
}
pub(super) fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
let nick = crate::agent_card::bare_handle(peer_nick);
let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
anyhow!(
"no pending pair request from {nick}. Run `wire pending` to see who is waiting, \
or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
)
})?;
let (_our_did, our_relay, our_slot_id, our_slot_token) =
crate::pair_invite::ensure_self_with_relay(None)?;
let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
cmd_add_accept_pending(
&handle_arg,
nick,
&pending,
&our_relay,
&our_slot_id,
&our_slot_token,
as_json,
)
}
pub(super) fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
let items = crate::pending_inbound_pair::list_pending_inbound()?;
if as_json {
println!("{}", serde_json::to_string(&items)?);
return Ok(());
}
if items.is_empty() {
println!("no pending pair requests — your inbox is clear.");
return Ok(());
}
let plural = if items.len() == 1 { "" } else { "s" };
println!("{} pending pair request{plural}:\n", items.len());
for p in &items {
let ch = crate::character::Character::from_did(&p.peer_did);
let glyph = crate::character::emoji_with_fallback(&ch);
println!(
" {glyph} {nick} ({handle}) wants to pair with you",
nick = ch.nickname,
handle = p.peer_handle,
);
}
println!();
println!(
"→ to accept any: `wire accept <name>` (e.g. `wire accept {first}`)",
first = items
.first()
.map(|p| {
let ch = crate::character::Character::from_did(&p.peer_did);
ch.nickname
})
.unwrap_or_else(|| "<name>".to_string())
);
println!("→ to refuse: `wire reject <name>`");
Ok(())
}
pub(super) fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
let nick = crate::agent_card::bare_handle(peer_nick);
let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
crate::pending_inbound_pair::consume_pending_inbound(nick)?;
if as_json {
println!(
"{}",
serde_json::to_string(&json!({
"peer": nick,
"rejected": existed.is_some(),
"had_pending": existed.is_some(),
}))?
);
} else if existed.is_some() {
println!(
"→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
);
} else {
println!("no pending pair from {nick} — nothing to reject");
}
Ok(())
}
pub(super) fn cmd_block_peer(did: &str, note: Option<String>, as_json: bool) -> Result<()> {
if !did.starts_with("did:wire:") {
bail!(
"`{did}` is not a wire DID. Pass a session DID (`did:wire:<handle>-<8hex>`) \
or an operator DID (`did:wire:op:<handle>-<32hex>`). Find a peer's DID with \
`wire whois <name>` or `wire peers`."
);
}
let mut bl = crate::blocklist::Blocklist::load();
let newly = bl.block(did, note.clone());
bl.save()?;
if as_json {
println!(
"{}",
serde_json::to_string(&json!({
"did": did,
"blocked": true,
"newly_added": newly,
"note": note,
}))?
);
} else if newly {
println!(
"→ blocked {did}\n→ this peer can no longer be org-auto-paired or notify-prompt you. \
(A deliberate `wire dial` + SAS pair still overrides the block.)"
);
} else {
println!("{did} was already blocked — note refreshed.");
}
Ok(())
}
pub(super) fn cmd_unblock_peer(did: &str, as_json: bool) -> Result<()> {
let mut bl = crate::blocklist::Blocklist::load();
let existed = bl.unblock(did);
bl.save()?;
if as_json {
println!(
"{}",
serde_json::to_string(&json!({ "did": did, "unblocked": existed }))?
);
} else if existed {
println!("→ unblocked {did} — org-easing paths apply again per your policy.");
} else {
println!("{did} was not on the block-list — nothing to do.");
}
Ok(())
}
pub(super) fn cmd_blocked(as_json: bool) -> Result<()> {
let bl = crate::blocklist::Blocklist::load();
if as_json {
let entries: Vec<Value> = bl
.entries()
.map(|(did, e)| json!({ "did": did, "at": e.at, "note": e.note }))
.collect();
println!("{}", serde_json::to_string(&json!({ "blocked": entries }))?);
return Ok(());
}
if bl.is_empty() {
println!("no peers blocked. `wire block-peer <did>` adds one (RFC-001 §T16).");
return Ok(());
}
println!("blocked peers ({}):", bl.len());
for (did, e) in bl.entries() {
match &e.note {
Some(note) => println!(" {did} ({}; {note})", e.at),
None => println!(" {did} ({})", e.at),
}
}
Ok(())
}
fn reject_self_pair_after_resolution(our_did: &str, peer_did: &str) -> Result<()> {
if our_did == peer_did {
bail!(
"refusing to self-pair: resolved peer DID `{peer_did}` matches your own \
DID. Two terminals can collapse onto one wire identity when the per-\
session key isn't reaching the wire process (issue #30 / #29).\n\n\
Diagnose:\n \
• `wire whoami` in each terminal — DIDs MUST differ.\n \
• `echo $WIRE_SESSION_ID` (bash) / `echo $env:WIRE_SESSION_ID` \
(PowerShell) — must be set + distinct per session.\n\n\
Force distinct identities before relaunching the agent:\n \
• bash/zsh: `export WIRE_SESSION_ID=\"$(uuidgen)\"`\n \
• PowerShell: `$env:WIRE_SESSION_ID = [guid]::NewGuid().ToString()`"
);
}
Ok(())
}
#[cfg(test)]
mod self_pair_guard_tests {
use super::*;
#[test]
fn reject_self_pair_after_resolution_blocks_matching_dids() {
let err = reject_self_pair_after_resolution(
"did:wire:winter-bay-4092b577",
"did:wire:winter-bay-4092b577",
)
.unwrap_err()
.to_string();
assert!(
err.contains("refusing to self-pair"),
"must explicitly refuse, not silently bail: {err}"
);
assert!(
err.contains("did:wire:winter-bay-4092b577"),
"must include the colliding DID so the operator can grep their `wire whoami` output: {err}"
);
assert!(
err.contains("issue #30") || err.contains("issue #29"),
"must point at the tracking issue so historical context is one search away: {err}"
);
assert!(
err.contains("WIRE_SESSION_ID"),
"remediation must name the env var operators set: {err}"
);
assert!(
err.contains("uuidgen") || err.contains("NewGuid"),
"remediation must include a concrete command to mint a unique id: {err}"
);
}
#[test]
fn reject_self_pair_after_resolution_allows_distinct_dids() {
reject_self_pair_after_resolution(
"did:wire:winter-bay-4092b577",
"did:wire:cedar-bayou-0616dc6c",
)
.unwrap();
reject_self_pair_after_resolution("did:wire:ed25519:abc123", "did:wire:ed25519:def456")
.unwrap();
reject_self_pair_after_resolution(
"did:wire:noble-canyon-deadbeef",
"did:wire:noble-canyon-cafef00d",
)
.unwrap();
}
}