Skip to main content

wire/
pair_profile.rs

1//! Agent profile + handle parsing (v0.5 — agentic hotline).
2//!
3//! Three-layer identity:
4//!   1. DID (`did:wire:<hash>`) — immutable cryptographic anchor (unchanged).
5//!   2. Handle (`nick@domain`) — mutable, human-readable, DNS-anchored.
6//!   3. Profile — freeform personality (emoji, motto, vibe, pronouns, `now`).
7//!
8//! Profile fields live inside the existing signed agent-card under a `profile`
9//! key. Editing any field re-signs the card. Card signature thus covers DID,
10//! handle, AND personality atomically — peers verifying the card get both
11//! identity and vibe in one signed blob.
12//!
13//! See `docs/rfc/` for the current protocol design.
14
15use anyhow::{Result, anyhow, bail};
16use serde_json::{Value, json};
17
18use crate::config;
19
20pub const PROFILE_SCHEMA_VERSION: &str = "v0.5";
21
22/// Reserved nick set — refuse to mint any of these as the local part of a
23/// handle. Length-1 nicks also reserved (impose `nick.len() >= 2`).
24///
25/// Categories (alphabetical within each group):
26///   - protocol primitives:  agent, system, wire
27///   - common-handle-pattern admins (NOT pre-claimed; reserved for the
28///     domain operator to claim if they choose):  abuse, admin, api, contact,
29///     help, info, noreply, postmaster, root, security, support, webmaster
30///   - meta/audience selectors:  all, everyone, here, me, none, null, self,
31///     team, you
32///   - system / daemon-shaped:  bot, daemon, kernel, robot, server, service, sys
33///   - role / staff names:  mod, moderator, official, ops, owner, staff
34///   - test / placeholder names:  bar, baz, demo, example, foo, test
35///   - brand defense (third-party AI vendors — discourage squat-impersonation):
36///     anthropic, claude, copilot, cursor, gemini, mistral, openai
37///   - slancha = wire's developer org — defensive even though pre-claimed.
38pub const RESERVED_NICKS: &[&str] = &[
39    "abuse",
40    "admin",
41    "agent",
42    "all",
43    "anthropic",
44    "api",
45    "bar",
46    "baz",
47    "bot",
48    "claude",
49    "contact",
50    "copilot",
51    "cursor",
52    "daemon",
53    "demo",
54    "everyone",
55    "example",
56    "foo",
57    "gemini",
58    "help",
59    "here",
60    "hostmaster",
61    "info",
62    "kernel",
63    "me",
64    "mistral",
65    "mod",
66    "moderator",
67    "none",
68    "noreply",
69    "null",
70    "official",
71    "openai",
72    "ops",
73    "owner",
74    "postmaster",
75    "robot",
76    "root",
77    "security",
78    "self",
79    "server",
80    "service",
81    "slancha",
82    "staff",
83    "support",
84    "sys",
85    "system",
86    "team",
87    "test",
88    "webmaster",
89    "wire",
90    "you",
91];
92
93/// Parsed handle: `nick@domain`. `domain` is lowercased.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct Handle {
96    pub nick: String,
97    pub domain: String,
98}
99
100impl Handle {
101    pub fn as_string(&self) -> String {
102        format!("{}@{}", self.nick, self.domain)
103    }
104}
105
106impl std::fmt::Display for Handle {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        write!(f, "{}@{}", self.nick, self.domain)
109    }
110}
111
112/// Parse `nick@domain`. Returns `Err` on malformed inputs or reserved nicks.
113///
114/// Nick rules: 2-32 chars, `[a-z0-9_-]`. Domain rules: DNS-label-shaped,
115/// dot-separated, lowercase ASCII — OR (E4) a loopback authority with a port
116/// (`127.0.0.1:PORT` / `localhost:PORT`) for a local-dev / sandbox relay. We
117/// don't fully validate domain syntax here — DNS resolution will fail later if
118/// the operator typo'd it.
119pub fn parse_handle(s: &str) -> Result<Handle> {
120    let (nick, domain) = s
121        .split_once('@')
122        .ok_or_else(|| anyhow!("handle missing '@' separator: {s:?}"))?;
123    if nick.is_empty() || domain.is_empty() {
124        bail!("handle has empty nick or domain: {s:?}");
125    }
126    // Resolve-time check uses syntax only — clients must still be able to
127    // PARSE + RESOLVE a reserved nick (e.g. `wire add slancha@wireup.net`
128    // when slancha is in RESERVED_NICKS but already-claimed by the org).
129    // Reservation is a CLAIM-time concern; enforced by relay handle_claim
130    // and CLI cmd_claim via is_valid_nick().
131    if !nick_syntax_ok(nick) {
132        bail!(
133            "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-]"
134        );
135    }
136    if !is_valid_domain(domain) {
137        bail!(
138            "domain {domain:?} invalid — expected a dot-separated lowercase-ASCII domain (e.g. wireup.net) or a loopback authority (127.0.0.1:PORT / localhost:PORT)"
139        );
140    }
141    Ok(Handle {
142        nick: nick.to_string(),
143        domain: domain.to_string(),
144    })
145}
146
147/// True iff `s` is a syntactically valid nick: 2-32 chars, lowercase
148/// `[a-z0-9_-]`. Does NOT check the reserved list — call `is_valid_nick`
149/// for that (which combines syntax + reservation, intended for claim sites).
150pub fn nick_syntax_ok(s: &str) -> bool {
151    let len = s.len();
152    if !(2..=32).contains(&len) {
153        return false;
154    }
155    s.bytes()
156        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
157}
158
159/// True iff `s` is syntactically valid AND not reserved. Use this at CLAIM
160/// time (relay's handle_claim, CLI's cmd_claim). For resolve/parse,
161/// `nick_syntax_ok` is the right primitive — reserved handles must still
162/// be resolvable so clients can pair against pre-claimed org handles.
163pub fn is_valid_nick(s: &str) -> bool {
164    nick_syntax_ok(s) && !RESERVED_NICKS.contains(&s)
165}
166
167fn is_valid_domain(s: &str) -> bool {
168    if s.is_empty() || s.len() > 253 {
169        return false;
170    }
171    // E4: a `host:port` authority is accepted ONLY when the host is a loopback
172    // literal — a local-dev / sandbox relay speaks http on a nonstandard port,
173    // and a federation handle can't otherwise carry a `:port`. Non-loopback
174    // host+port stays rejected (public handles are port-less by convention).
175    // Port is 1..=65535; 0 is the OS wildcard, not a bindable relay.
176    if let Some((host, port)) = s.rsplit_once(':') {
177        return crate::endpoints::is_loopback_host(host)
178            && matches!(port.parse::<u16>(), Ok(p) if p >= 1);
179    }
180    // Lowercase ASCII, dot-separated labels of 1..=63 chars each.
181    s.split('.').all(|label| {
182        !label.is_empty()
183            && label.len() <= 63
184            && label
185                .bytes()
186                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
187            && !label.starts_with('-')
188            && !label.ends_with('-')
189    })
190}
191
192/// Construct the relay base URL for a handle `domain` when no explicit
193/// `relay_url` hint is available. A loopback authority (E4) speaks plain http on
194/// its port; every other domain is a public relay → https (default 443). Shares
195/// `endpoints::is_loopback_host` with `infer_scope_from_url` so the scheme this
196/// picks and the scope that gets advertised can never diverge.
197pub fn relay_url_for_domain(domain: &str) -> String {
198    let host = domain.rsplit_once(':').map(|(h, _)| h).unwrap_or(domain);
199    if crate::endpoints::is_loopback_host(host) {
200        format!("http://{domain}")
201    } else {
202        format!("https://{domain}")
203    }
204}
205
206/// Editable profile fields. All optional; unset fields stay `null` in the
207/// signed card.
208pub const PROFILE_FIELDS: &[&str] = &[
209    "display_name",
210    "emoji",
211    "motto",
212    "vibe",
213    "pronouns",
214    "avatar_url",
215    "handle",
216    "now",
217    "listed",
218    "role",
219];
220
221/// Read this agent's profile blob from the agent-card. Returns an empty
222/// object if no profile has been set yet.
223pub fn read_profile() -> Result<Value> {
224    let card = config::read_agent_card()?;
225    Ok(card.get("profile").cloned().unwrap_or_else(|| json!({})))
226}
227
228/// Set a single profile field and re-sign the agent-card. `value` must be a
229/// JSON value the caller has already parsed/validated (string for most fields;
230/// array for `vibe`; object for `now`).
231pub fn write_profile_field(field: &str, value: Value) -> Result<Value> {
232    if !PROFILE_FIELDS.contains(&field) {
233        bail!(
234            "unknown profile field {field:?}; allowed: {}",
235            PROFILE_FIELDS.join(", ")
236        );
237    }
238    // Handle gets extra validation.
239    if field == "handle" {
240        let s = value
241            .as_str()
242            .ok_or_else(|| anyhow!("handle must be a string"))?;
243        parse_handle(s)?;
244    }
245    if field == "vibe" && !value.is_array() {
246        bail!("vibe must be a JSON array of strings");
247    }
248    if field == "now" && !(value.is_null() || value.is_object()) {
249        bail!("now must be a JSON object with text/since/ttl_secs or null");
250    }
251
252    let mut card = config::read_agent_card()?;
253    let card_obj = card
254        .as_object_mut()
255        .ok_or_else(|| anyhow!("agent-card is not a JSON object"))?;
256
257    // Get or create the profile sub-object.
258    let profile = card_obj
259        .entry("profile".to_string())
260        .or_insert_with(|| json!({"schema_version": PROFILE_SCHEMA_VERSION}));
261    let profile_obj = profile
262        .as_object_mut()
263        .ok_or_else(|| anyhow!("profile field is not an object"))?;
264
265    if value.is_null() {
266        profile_obj.remove(field);
267    } else {
268        profile_obj.insert(field.to_string(), value);
269    }
270    profile_obj.insert("schema_version".to_string(), json!(PROFILE_SCHEMA_VERSION));
271
272    // Re-sign the whole card (signature covers profile via card_canonical).
273    let sk_seed = config::read_private_key()?;
274    // Strip prior signature before re-signing.
275    card_obj.remove("signature");
276    let resigned = crate::agent_card::sign_agent_card(&card, &sk_seed);
277    config::write_agent_card(&resigned)?;
278
279    Ok(resigned.get("profile").cloned().unwrap_or(Value::Null))
280}
281
282/// Resolve a `nick@domain` handle via the remote relay's
283/// `.well-known/wire/agent` endpoint. Returns the parsed JSON payload
284/// `{nick, did, card, slot_id, relay_url, claimed_at}` on success. Verifies
285/// the card signature; on tamper, returns `Err`.
286///
287/// The relay-URL hint helps: if `relay_url` is `Some`, that base is used.
288/// Otherwise we assume `https://<domain>` (matches operator's DNS-anchored
289/// setup, e.g. `wireup.net`).
290pub fn resolve_handle(handle: &Handle, relay_url: Option<&str>) -> anyhow::Result<Value> {
291    let base = relay_url
292        .map(str::to_string)
293        .unwrap_or_else(|| relay_url_for_domain(&handle.domain));
294    let client = crate::relay_client::RelayClient::new(&base);
295
296    // v0.5.1: try the wire-native endpoint first (richer), fall back to the
297    // A2A v1.0 endpoint, then extract the wire extension from the A2A card.
298    // This lets `wire whois` resolve agents whose relay only serves the A2A
299    // schema (other A2A v1.0 implementations like agent-card-go, MSFT Agent
300    // Framework, A2A .NET SDK) and not just wire-native ones.
301    match client.well_known_agent(&handle.nick) {
302        Ok(resolved) => verify_wire_native_payload(&resolved).map(|()| resolved),
303        Err(_wire_err) => {
304            // Fall back to A2A endpoint.
305            let a2a_card = client.well_known_agent_card_a2a(&handle.nick)?;
306            unwrap_a2a_to_wire_payload(&a2a_card)
307        }
308    }
309}
310
311/// Verify the wire-native resolve payload has matching DID in container + card,
312/// and that the card signature is valid.
313fn verify_wire_native_payload(resolved: &Value) -> anyhow::Result<()> {
314    let card = resolved
315        .get("card")
316        .ok_or_else(|| anyhow!("resolved payload missing 'card' field"))?;
317    crate::agent_card::verify_agent_card(card)
318        .map_err(|e| anyhow!("resolved card signature invalid: {e}"))?;
319    let did_in_resp = resolved
320        .get("did")
321        .and_then(Value::as_str)
322        .ok_or_else(|| anyhow!("resolved payload missing 'did'"))?;
323    let did_in_card = card
324        .get("did")
325        .and_then(Value::as_str)
326        .ok_or_else(|| anyhow!("resolved card missing 'did'"))?;
327    if did_in_resp != did_in_card {
328        bail!("resolved DID mismatch: payload={did_in_resp} card={did_in_card}");
329    }
330    Ok(())
331}
332
333/// Given an A2A v1.0 AgentCard, extract the wire extension (if present) and
334/// return a wire-native-shaped payload `{did, nick, card, slot_id, relay_url,
335/// claimed_at}`. If no wire extension is present, return a degraded payload
336/// (still useful for `wire whois` display) with the A2A-only fields.
337fn unwrap_a2a_to_wire_payload(a2a: &Value) -> anyhow::Result<Value> {
338    let wire_ext = a2a
339        .get("extensions")
340        .and_then(Value::as_array)
341        .and_then(|exts| {
342            exts.iter().find(|e| {
343                e.get("uri")
344                    .and_then(Value::as_str)
345                    .map(|u| u.starts_with("https://slancha.ai/wire/ext"))
346                    .unwrap_or(false)
347            })
348        });
349    if let Some(ext) = wire_ext {
350        let params = ext
351            .get("params")
352            .cloned()
353            .ok_or_else(|| anyhow!("A2A wire extension missing params"))?;
354        // Verify wire card sig inside the extension.
355        if let Some(card) = params.get("card") {
356            crate::agent_card::verify_agent_card(card)
357                .map_err(|e| anyhow!("A2A wire extension card sig invalid: {e}"))?;
358        }
359        return Ok(params);
360    }
361
362    // No wire extension. Return a degraded but useful payload built from A2A
363    // standard fields. `wire add` will detect the missing slot_id and refuse
364    // to pair (no mailbox to drop into), but `wire whois` can still render.
365    Ok(json!({
366        "did": a2a.get("id").cloned().unwrap_or(Value::Null),
367        "nick": a2a.get("name").cloned().unwrap_or(Value::Null),
368        "card": Value::Null,
369        "slot_id": Value::Null,
370        "relay_url": a2a.get("endpoint").cloned().unwrap_or(Value::Null),
371        "claimed_at": Value::Null,
372        "a2a_only": true,
373        "a2a_card": a2a.clone(),
374    }))
375}
376
377/// Render the local agent's profile as a friendly multi-line string for
378/// `wire whois` with no argument (i.e., show self).
379pub fn render_self_summary() -> Result<String> {
380    let card = config::read_agent_card()?;
381    let did = card
382        .get("did")
383        .and_then(Value::as_str)
384        .unwrap_or("did:wire:?")
385        .to_string();
386    let local_handle = crate::agent_card::display_handle_from_did(&did).to_string();
387    let profile = card.get("profile").cloned().unwrap_or(Value::Null);
388
389    let mut out = String::new();
390    let line = |out: &mut String, k: &str, v: &str| {
391        if !v.is_empty() {
392            out.push_str(&format!("  {k:14}{v}\n"));
393        }
394    };
395
396    out.push_str(&format!("{did}\n"));
397
398    if let Some(handle) = profile.get("handle").and_then(Value::as_str) {
399        line(&mut out, "handle:", handle);
400    } else {
401        line(&mut out, "handle:", &format!("{local_handle}@(unset)"));
402    }
403    if let Some(name) = profile.get("display_name").and_then(Value::as_str) {
404        line(&mut out, "display_name:", name);
405    }
406    if let Some(emoji) = profile.get("emoji").and_then(Value::as_str) {
407        line(&mut out, "emoji:", emoji);
408    }
409    if let Some(motto) = profile.get("motto").and_then(Value::as_str) {
410        line(&mut out, "motto:", motto);
411    }
412    if let Some(vibe) = profile.get("vibe").and_then(Value::as_array) {
413        let joined: Vec<String> = vibe
414            .iter()
415            .filter_map(|v| v.as_str().map(str::to_string))
416            .collect();
417        line(&mut out, "vibe:", &joined.join(", "));
418    }
419    if let Some(pronouns) = profile.get("pronouns").and_then(Value::as_str) {
420        line(&mut out, "pronouns:", pronouns);
421    }
422    if let Some(now) = profile.get("now")
423        && let Some(text) = now.get("text").and_then(Value::as_str)
424    {
425        line(&mut out, "now:", text);
426    }
427    Ok(out)
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn parse_handle_round_trip() {
436        let h = parse_handle("coffee-ghost@anthropic.dev").unwrap();
437        assert_eq!(h.nick, "coffee-ghost");
438        assert_eq!(h.domain, "anthropic.dev");
439        assert_eq!(h.as_string(), "coffee-ghost@anthropic.dev");
440    }
441
442    #[test]
443    fn parse_handle_accepts_underscore_and_digits() {
444        assert!(parse_handle("dragonfly_42@home.arpa").is_ok());
445        assert!(parse_handle("v2@wireup.net").is_ok());
446    }
447
448    #[test]
449    fn parse_handle_accepts_loopback_with_port() {
450        // E4: loopback authorities carry a `:port` for local-dev / sandbox relays.
451        for h in [
452            "bob@127.0.0.1:8771",
453            "bob@localhost:8771",
454            "bob@127.0.0.1:65535",
455            "bob@127.0.0.1:1",
456        ] {
457            assert!(parse_handle(h).is_ok(), "expected {h:?} to parse");
458        }
459        // No-port loopback already parsed; keep it working.
460        assert!(parse_handle("bob@127.0.0.1").is_ok());
461        // Round-trip preserves the port.
462        assert_eq!(
463            parse_handle("bob@127.0.0.1:8771").unwrap().as_string(),
464            "bob@127.0.0.1:8771"
465        );
466    }
467
468    #[test]
469    fn parse_handle_rejects_nonloopback_port_and_bad_ports() {
470        // Non-loopback host + port stays rejected (public handles are port-less).
471        assert!(parse_handle("bob@evil.com:1337").is_err());
472        assert!(parse_handle("bob@wireup.net:8443").is_err());
473        // Port out of range / zero / non-numeric / empty.
474        assert!(parse_handle("bob@127.0.0.1:0").is_err());
475        assert!(parse_handle("bob@127.0.0.1:65536").is_err());
476        assert!(parse_handle("bob@127.0.0.1:abc").is_err());
477        assert!(parse_handle("bob@:8771").is_err()); // empty host
478        // IPv6 loopback is intentionally NOT accepted as a handle (would need
479        // `[::1]:port` bracketing the handle path doesn't carry) — use 127.0.0.1.
480        assert!(parse_handle("bob@::1:8771").is_err());
481        // A public domain is unchanged (no regression).
482        assert!(parse_handle("bob@wireup.net").is_ok());
483    }
484
485    #[test]
486    fn relay_url_for_domain_scheme() {
487        // Loopback → http (local relays speak plaintext); public → https.
488        assert_eq!(
489            relay_url_for_domain("127.0.0.1:8771"),
490            "http://127.0.0.1:8771"
491        );
492        assert_eq!(relay_url_for_domain("localhost:9"), "http://localhost:9");
493        assert_eq!(relay_url_for_domain("127.0.0.1"), "http://127.0.0.1");
494        // Public path is unchanged — the regression guard for the 4 call sites.
495        assert_eq!(relay_url_for_domain("wireup.net"), "https://wireup.net");
496        assert_eq!(
497            relay_url_for_domain("anthropic.dev"),
498            "https://anthropic.dev"
499        );
500    }
501
502    #[test]
503    fn parse_handle_rejects_no_at() {
504        assert!(parse_handle("paul").is_err());
505        assert!(parse_handle("paul.example.com").is_err());
506    }
507
508    #[test]
509    fn parse_handle_rejects_empty_parts() {
510        assert!(parse_handle("@example.com").is_err());
511        assert!(parse_handle("paul@").is_err());
512    }
513
514    #[test]
515    fn parse_handle_accepts_reserved_nicks_for_resolution() {
516        // Reserved nicks must still PARSE so clients can resolve / `wire add`
517        // pre-claimed org handles like slancha@wireup.net. Reservation is a
518        // CLAIM-time concern — covered by `is_valid_nick_rejects_reserved`
519        // below.
520        for r in RESERVED_NICKS {
521            // Skip single-char entries — they fail syntax regardless.
522            if r.len() < 2 {
523                continue;
524            }
525            let s = format!("{r}@example.com");
526            assert!(
527                parse_handle(&s).is_ok(),
528                "expected reserved nick {r:?} to parse OK for resolution"
529            );
530        }
531    }
532
533    #[test]
534    fn is_valid_nick_rejects_reserved() {
535        for r in RESERVED_NICKS {
536            assert!(
537                !is_valid_nick(r),
538                "expected is_valid_nick to reject reserved nick {r:?} (claim-time check)"
539            );
540        }
541    }
542
543    #[test]
544    fn parse_handle_rejects_single_char_nick() {
545        assert!(parse_handle("a@example.com").is_err());
546    }
547
548    #[test]
549    fn parse_handle_rejects_uppercase_or_emoji_in_nick() {
550        assert!(parse_handle("Paul@example.com").is_err());
551        assert!(parse_handle("p👻@example.com").is_err());
552    }
553
554    #[test]
555    fn parse_handle_rejects_overlong_nick() {
556        let long = "a".repeat(33);
557        let s = format!("{long}@example.com");
558        assert!(parse_handle(&s).is_err());
559    }
560
561    #[test]
562    fn parse_handle_rejects_bad_domain() {
563        assert!(parse_handle("paul@-bad.example.com").is_err());
564        assert!(parse_handle("paul@bad-.example.com").is_err());
565        assert!(parse_handle("paul@.bad.com").is_err());
566    }
567
568    #[test]
569    fn is_valid_nick_lower_bound() {
570        assert!(!is_valid_nick("a"));
571        assert!(is_valid_nick("ab"));
572    }
573}