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 `SPEC_v0_5.md` for the full 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. We don't fully validate domain syntax
116/// here — DNS resolution will fail later if the operator typo'd it.
117pub fn parse_handle(s: &str) -> Result<Handle> {
118    let (nick, domain) = s
119        .split_once('@')
120        .ok_or_else(|| anyhow!("handle missing '@' separator: {s:?}"))?;
121    if nick.is_empty() || domain.is_empty() {
122        bail!("handle has empty nick or domain: {s:?}");
123    }
124    // Resolve-time check uses syntax only — clients must still be able to
125    // PARSE + RESOLVE a reserved nick (e.g. `wire add slancha@wireup.net`
126    // when slancha is in RESERVED_NICKS but already-claimed by the org).
127    // Reservation is a CLAIM-time concern; enforced by relay handle_claim
128    // and CLI cmd_claim via is_valid_nick().
129    if !nick_syntax_ok(nick) {
130        bail!(
131            "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-]"
132        );
133    }
134    if !is_valid_domain(domain) {
135        bail!("domain {domain:?} invalid — must be lowercase ASCII, dot-separated");
136    }
137    Ok(Handle {
138        nick: nick.to_string(),
139        domain: domain.to_string(),
140    })
141}
142
143/// True iff `s` is a syntactically valid nick: 2-32 chars, lowercase
144/// `[a-z0-9_-]`. Does NOT check the reserved list — call `is_valid_nick`
145/// for that (which combines syntax + reservation, intended for claim sites).
146pub fn nick_syntax_ok(s: &str) -> bool {
147    let len = s.len();
148    if !(2..=32).contains(&len) {
149        return false;
150    }
151    s.bytes()
152        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
153}
154
155/// True iff `s` is syntactically valid AND not reserved. Use this at CLAIM
156/// time (relay's handle_claim, CLI's cmd_claim). For resolve/parse,
157/// `nick_syntax_ok` is the right primitive — reserved handles must still
158/// be resolvable so clients can pair against pre-claimed org handles.
159pub fn is_valid_nick(s: &str) -> bool {
160    nick_syntax_ok(s) && !RESERVED_NICKS.contains(&s)
161}
162
163fn is_valid_domain(s: &str) -> bool {
164    if s.is_empty() || s.len() > 253 {
165        return false;
166    }
167    // Lowercase ASCII, dot-separated labels of 1..=63 chars each.
168    s.split('.').all(|label| {
169        !label.is_empty()
170            && label.len() <= 63
171            && label
172                .bytes()
173                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
174            && !label.starts_with('-')
175            && !label.ends_with('-')
176    })
177}
178
179/// Editable profile fields. All optional; unset fields stay `null` in the
180/// signed card.
181pub const PROFILE_FIELDS: &[&str] = &[
182    "display_name",
183    "emoji",
184    "motto",
185    "vibe",
186    "pronouns",
187    "avatar_url",
188    "handle",
189    "now",
190    "listed",
191];
192
193/// Read this agent's profile blob from the agent-card. Returns `Value::Null`
194/// if no profile fields have ever been set (back-compat with v0.4 cards).
195pub fn read_profile() -> Result<Value> {
196    let card = config::read_agent_card()?;
197    Ok(card.get("profile").cloned().unwrap_or(Value::Null))
198}
199
200/// Set a single profile field and re-sign the agent-card. `value` must be a
201/// JSON value the caller has already parsed/validated (string for most fields;
202/// array for `vibe`; object for `now`).
203pub fn write_profile_field(field: &str, value: Value) -> Result<Value> {
204    if !PROFILE_FIELDS.contains(&field) {
205        bail!(
206            "unknown profile field {field:?}; allowed: {}",
207            PROFILE_FIELDS.join(", ")
208        );
209    }
210    // Handle gets extra validation.
211    if field == "handle" {
212        let s = value
213            .as_str()
214            .ok_or_else(|| anyhow!("handle must be a string"))?;
215        parse_handle(s)?;
216    }
217    if field == "vibe" && !value.is_array() {
218        bail!("vibe must be a JSON array of strings");
219    }
220    if field == "now" && !(value.is_null() || value.is_object()) {
221        bail!("now must be a JSON object with text/since/ttl_secs or null");
222    }
223
224    let mut card = config::read_agent_card()?;
225    let card_obj = card
226        .as_object_mut()
227        .ok_or_else(|| anyhow!("agent-card is not a JSON object"))?;
228
229    // Get or create the profile sub-object.
230    let profile = card_obj
231        .entry("profile".to_string())
232        .or_insert_with(|| json!({"schema_version": PROFILE_SCHEMA_VERSION}));
233    let profile_obj = profile
234        .as_object_mut()
235        .ok_or_else(|| anyhow!("profile field is not an object"))?;
236
237    if value.is_null() {
238        profile_obj.remove(field);
239    } else {
240        profile_obj.insert(field.to_string(), value);
241    }
242    profile_obj.insert("schema_version".to_string(), json!(PROFILE_SCHEMA_VERSION));
243
244    // Re-sign the whole card (signature covers profile via card_canonical).
245    let sk_seed = config::read_private_key()?;
246    // Strip prior signature before re-signing.
247    card_obj.remove("signature");
248    let resigned = crate::agent_card::sign_agent_card(&card, &sk_seed);
249    config::write_agent_card(&resigned)?;
250
251    Ok(resigned.get("profile").cloned().unwrap_or(Value::Null))
252}
253
254/// Resolve a `nick@domain` handle via the remote relay's
255/// `.well-known/wire/agent` endpoint. Returns the parsed JSON payload
256/// `{nick, did, card, slot_id, relay_url, claimed_at}` on success. Verifies
257/// the card signature; on tamper, returns `Err`.
258///
259/// The relay-URL hint helps: if `relay_url` is `Some`, that base is used.
260/// Otherwise we assume `https://<domain>` (matches operator's DNS-anchored
261/// setup, e.g. `wireup.net`).
262pub fn resolve_handle(handle: &Handle, relay_url: Option<&str>) -> anyhow::Result<Value> {
263    let base = relay_url
264        .map(str::to_string)
265        .unwrap_or_else(|| format!("https://{}", handle.domain));
266    let client = crate::relay_client::RelayClient::new(&base);
267
268    // v0.5.1: try the wire-native endpoint first (richer), fall back to the
269    // A2A v1.0 endpoint, then extract the wire extension from the A2A card.
270    // This lets `wire whois` resolve agents whose relay only serves the A2A
271    // schema (other A2A v1.0 implementations like agent-card-go, MSFT Agent
272    // Framework, A2A .NET SDK) and not just wire-native ones.
273    match client.well_known_agent(&handle.nick) {
274        Ok(resolved) => verify_wire_native_payload(&resolved).map(|()| resolved),
275        Err(_wire_err) => {
276            // Fall back to A2A endpoint.
277            let a2a_card = client.well_known_agent_card_a2a(&handle.nick)?;
278            unwrap_a2a_to_wire_payload(&a2a_card)
279        }
280    }
281}
282
283/// Verify the wire-native resolve payload has matching DID in container + card,
284/// and that the card signature is valid.
285fn verify_wire_native_payload(resolved: &Value) -> anyhow::Result<()> {
286    let card = resolved
287        .get("card")
288        .ok_or_else(|| anyhow!("resolved payload missing 'card' field"))?;
289    crate::agent_card::verify_agent_card(card)
290        .map_err(|e| anyhow!("resolved card signature invalid: {e}"))?;
291    let did_in_resp = resolved
292        .get("did")
293        .and_then(Value::as_str)
294        .ok_or_else(|| anyhow!("resolved payload missing 'did'"))?;
295    let did_in_card = card
296        .get("did")
297        .and_then(Value::as_str)
298        .ok_or_else(|| anyhow!("resolved card missing 'did'"))?;
299    if did_in_resp != did_in_card {
300        bail!("resolved DID mismatch: payload={did_in_resp} card={did_in_card}");
301    }
302    Ok(())
303}
304
305/// Given an A2A v1.0 AgentCard, extract the wire extension (if present) and
306/// return a wire-native-shaped payload `{did, nick, card, slot_id, relay_url,
307/// claimed_at}`. If no wire extension is present, return a degraded payload
308/// (still useful for `wire whois` display) with the A2A-only fields.
309fn unwrap_a2a_to_wire_payload(a2a: &Value) -> anyhow::Result<Value> {
310    let wire_ext = a2a
311        .get("extensions")
312        .and_then(Value::as_array)
313        .and_then(|exts| {
314            exts.iter().find(|e| {
315                e.get("uri")
316                    .and_then(Value::as_str)
317                    .map(|u| u.starts_with("https://slancha.ai/wire/ext"))
318                    .unwrap_or(false)
319            })
320        });
321    if let Some(ext) = wire_ext {
322        let params = ext
323            .get("params")
324            .cloned()
325            .ok_or_else(|| anyhow!("A2A wire extension missing params"))?;
326        // Verify wire card sig inside the extension.
327        if let Some(card) = params.get("card") {
328            crate::agent_card::verify_agent_card(card)
329                .map_err(|e| anyhow!("A2A wire extension card sig invalid: {e}"))?;
330        }
331        return Ok(params);
332    }
333
334    // No wire extension. Return a degraded but useful payload built from A2A
335    // standard fields. `wire add` will detect the missing slot_id and refuse
336    // to pair (no mailbox to drop into), but `wire whois` can still render.
337    Ok(json!({
338        "did": a2a.get("id").cloned().unwrap_or(Value::Null),
339        "nick": a2a.get("name").cloned().unwrap_or(Value::Null),
340        "card": Value::Null,
341        "slot_id": Value::Null,
342        "relay_url": a2a.get("endpoint").cloned().unwrap_or(Value::Null),
343        "claimed_at": Value::Null,
344        "a2a_only": true,
345        "a2a_card": a2a.clone(),
346    }))
347}
348
349/// Render the local agent's profile as a friendly multi-line string for
350/// `wire whois` with no argument (i.e., show self).
351pub fn render_self_summary() -> Result<String> {
352    let card = config::read_agent_card()?;
353    let did = card
354        .get("did")
355        .and_then(Value::as_str)
356        .unwrap_or("did:wire:?")
357        .to_string();
358    let local_handle = crate::agent_card::display_handle_from_did(&did).to_string();
359    let profile = card.get("profile").cloned().unwrap_or(Value::Null);
360
361    let mut out = String::new();
362    let line = |out: &mut String, k: &str, v: &str| {
363        if !v.is_empty() {
364            out.push_str(&format!("  {k:14}{v}\n"));
365        }
366    };
367
368    out.push_str(&format!("{}\n", did));
369
370    if let Some(handle) = profile.get("handle").and_then(Value::as_str) {
371        line(&mut out, "handle:", handle);
372    } else {
373        line(&mut out, "handle:", &format!("{local_handle}@(unset)"));
374    }
375    if let Some(name) = profile.get("display_name").and_then(Value::as_str) {
376        line(&mut out, "display_name:", name);
377    }
378    if let Some(emoji) = profile.get("emoji").and_then(Value::as_str) {
379        line(&mut out, "emoji:", emoji);
380    }
381    if let Some(motto) = profile.get("motto").and_then(Value::as_str) {
382        line(&mut out, "motto:", motto);
383    }
384    if let Some(vibe) = profile.get("vibe").and_then(Value::as_array) {
385        let joined: Vec<String> = vibe
386            .iter()
387            .filter_map(|v| v.as_str().map(str::to_string))
388            .collect();
389        line(&mut out, "vibe:", &joined.join(", "));
390    }
391    if let Some(pronouns) = profile.get("pronouns").and_then(Value::as_str) {
392        line(&mut out, "pronouns:", pronouns);
393    }
394    if let Some(now) = profile.get("now")
395        && let Some(text) = now.get("text").and_then(Value::as_str)
396    {
397        line(&mut out, "now:", text);
398    }
399    Ok(out)
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn parse_handle_round_trip() {
408        let h = parse_handle("coffee-ghost@anthropic.dev").unwrap();
409        assert_eq!(h.nick, "coffee-ghost");
410        assert_eq!(h.domain, "anthropic.dev");
411        assert_eq!(h.as_string(), "coffee-ghost@anthropic.dev");
412    }
413
414    #[test]
415    fn parse_handle_accepts_underscore_and_digits() {
416        assert!(parse_handle("dragonfly_42@home.arpa").is_ok());
417        assert!(parse_handle("v2@wireup.net").is_ok());
418    }
419
420    #[test]
421    fn parse_handle_rejects_no_at() {
422        assert!(parse_handle("paul").is_err());
423        assert!(parse_handle("paul.example.com").is_err());
424    }
425
426    #[test]
427    fn parse_handle_rejects_empty_parts() {
428        assert!(parse_handle("@example.com").is_err());
429        assert!(parse_handle("paul@").is_err());
430    }
431
432    #[test]
433    fn parse_handle_accepts_reserved_nicks_for_resolution() {
434        // Reserved nicks must still PARSE so clients can resolve / `wire add`
435        // pre-claimed org handles like slancha@wireup.net. Reservation is a
436        // CLAIM-time concern — covered by `is_valid_nick_rejects_reserved`
437        // below.
438        for r in RESERVED_NICKS {
439            // Skip single-char entries — they fail syntax regardless.
440            if r.len() < 2 {
441                continue;
442            }
443            let s = format!("{r}@example.com");
444            assert!(
445                parse_handle(&s).is_ok(),
446                "expected reserved nick {r:?} to parse OK for resolution"
447            );
448        }
449    }
450
451    #[test]
452    fn is_valid_nick_rejects_reserved() {
453        for r in RESERVED_NICKS {
454            assert!(
455                !is_valid_nick(r),
456                "expected is_valid_nick to reject reserved nick {r:?} (claim-time check)"
457            );
458        }
459    }
460
461    #[test]
462    fn parse_handle_rejects_single_char_nick() {
463        assert!(parse_handle("a@example.com").is_err());
464    }
465
466    #[test]
467    fn parse_handle_rejects_uppercase_or_emoji_in_nick() {
468        assert!(parse_handle("Paul@example.com").is_err());
469        assert!(parse_handle("p👻@example.com").is_err());
470    }
471
472    #[test]
473    fn parse_handle_rejects_overlong_nick() {
474        let long = "a".repeat(33);
475        let s = format!("{long}@example.com");
476        assert!(parse_handle(&s).is_err());
477    }
478
479    #[test]
480    fn parse_handle_rejects_bad_domain() {
481        assert!(parse_handle("paul@-bad.example.com").is_err());
482        assert!(parse_handle("paul@bad-.example.com").is_err());
483        assert!(parse_handle("paul@.bad.com").is_err());
484    }
485
486    #[test]
487    fn is_valid_nick_lower_bound() {
488        assert!(!is_valid_nick("a"));
489        assert!(is_valid_nick("ab"));
490    }
491}