Skip to main content

wire/
init.rs

1//! Idempotent local-identity creation.
2//!
3//! `init_self_idempotent` is the single writeable identity-creation entry
4//! point safe to expose to agents (via MCP `wire_init` / auto-init) and to the
5//! invite-accept path: it can't change an operator's existing identity. It
6//! lived in `pair_session` historically (the SAS pairing module) but is not
7//! SAS-specific — it only ensures the local keypair + agent-card + relay slot
8//! exist. Relocated here when the SAS flow was removed (RFC-005 follow-on).
9
10use anyhow::{Result, anyhow, bail};
11use serde_json::{Value, json};
12
13/// MCP-callable init: idempotent if already inited under the same handle,
14/// errors on different-handle conflict, accepts optional --relay binding.
15///
16/// This is the only writeable identity-creation entry point safe to expose
17/// to agents — it can't change the operator's existing identity.
18pub fn init_self_idempotent(
19    handle: &str,
20    name: Option<&str>,
21    relay: Option<&str>,
22) -> Result<Value> {
23    use crate::agent_card::{build_agent_card, sign_agent_card};
24    use crate::signing::{fingerprint, generate_keypair, make_key_id};
25    use crate::trust::{add_self_to_trust, empty_trust};
26
27    if !handle
28        .chars()
29        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
30    {
31        bail!("handle must be ASCII alphanumeric / '-' / '_' (got {handle:?})");
32    }
33
34    if crate::config::is_initialized()? {
35        let card = crate::config::read_agent_card()?;
36        let existing_did = card
37            .get("did")
38            .and_then(Value::as_str)
39            .unwrap_or("")
40            .to_string();
41        // Prefer the explicit `handle` field on the card (v0.5.7+);
42        // fall back to the DID prefix-and-pubkey-suffix strip for legacy.
43        let existing_handle = card
44            .get("handle")
45            .and_then(Value::as_str)
46            .map(str::to_string)
47            .unwrap_or_else(|| {
48                crate::agent_card::display_handle_from_did(&existing_did).to_string()
49            });
50        // One-name rule: the on-disk identity is authoritative and the passed
51        // `handle` is a vestigial seed (often the hostname from
52        // default_handle()). Never re-key on re-init — adopt the existing
53        // persona handle for all downstream fields. (Previously this bailed on
54        // a handle mismatch, which broke claim / MCP / pairing on any session
55        // whose persona handle differed from the hostname seed.)
56        let handle: &str = &existing_handle;
57        let pk_b64 = card
58            .get("verify_keys")
59            .and_then(Value::as_object)
60            .and_then(|m| m.values().next())
61            .and_then(|v| v.get("key"))
62            .and_then(Value::as_str)
63            .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
64        let pk_bytes = crate::signing::b64decode(pk_b64)?;
65        let mut out = json!({
66            "did": existing_did,
67            "handle": handle,
68            "fingerprint": fingerprint(&pk_bytes),
69            "key_id": make_key_id(handle, &pk_bytes),
70            "config_dir": crate::config::config_dir()?.to_string_lossy(),
71            "already_initialized": true,
72        });
73        let mut relay_state = crate::config::read_relay_state()?;
74        if let Some(url) = relay {
75            let url = url.trim_end_matches('/');
76            // Bind iff we don't already hold a slot on THIS relay. Fixes
77            // the v0.11 no-op where an already-initialized identity whose
78            // `self` was non-null-but-unbound (e.g. `self relay: ?`) never
79            // allocated the requested relay slot — `relay_state["self"]`
80            // wasn't strictly null, so the old guard skipped binding and
81            // wire_claim then failed with 404 unknown slot. Additive:
82            // keeps any other slots (matches cmd_bind_relay).
83            let already = crate::endpoints::self_endpoints(&relay_state)
84                .into_iter()
85                .find(|e| e.relay_url == url);
86            if let Some(ep) = already {
87                out["relay_url"] = json!(url);
88                out["slot_id"] = json!(ep.slot_id);
89            } else {
90                let client = crate::relay_client::RelayClient::new(url);
91                client.check_healthz()?;
92                let alloc = client.allocate_slot(Some(handle))?;
93                crate::endpoints::upsert_self_endpoint(
94                    &mut relay_state,
95                    crate::endpoints::Endpoint {
96                        relay_url: url.to_string(),
97                        slot_id: alloc.slot_id.clone(),
98                        slot_token: alloc.slot_token,
99                        scope: crate::endpoints::infer_scope_from_url(url),
100                    },
101                );
102                crate::config::write_relay_state(&relay_state)?;
103                out["relay_url"] = json!(url);
104                out["slot_id"] = json!(alloc.slot_id);
105            }
106        }
107        return Ok(out);
108    }
109
110    crate::config::ensure_dirs()?;
111    let (sk_seed, pk_bytes) = generate_keypair();
112    crate::config::write_private_key(&sk_seed)?;
113
114    // One-name rule: derive the persona from the keypair fingerprint, not the
115    // passed `handle` (a vestigial seed — often the hostname from
116    // default_handle()). Deriving here means EVERY init path, including the
117    // auto-init used by claim / MCP / pairing, yields a unique fp-derived
118    // persona instead of a shared hostname. This was the root of "every new
119    // session on a box shows the same handle".
120    let synth_did = crate::agent_card::did_for_with_key(handle, &pk_bytes);
121    let persona = crate::character::Character::from_did(&synth_did).nickname;
122    let handle: &str = &persona;
123
124    let card = build_agent_card(handle, &pk_bytes, name, None, None);
125    // Card-emit (RFC-001 Phase 1b): attach operator/org claims if this machine
126    // is enrolled. Fail-soft no-op when not enrolled — non-enrolled cards are
127    // byte-identical. Signed below, so the self-signature covers the claims.
128    let card = crate::enroll::with_op_claims_if_enrolled(card)?;
129    let signed = sign_agent_card(&card, &sk_seed);
130    crate::config::write_agent_card(&signed)?;
131    let mut trust = empty_trust();
132    add_self_to_trust(&mut trust, handle, &pk_bytes);
133    crate::config::write_trust(&trust)?;
134
135    let mut out = json!({
136        "did": crate::agent_card::did_for_with_key(handle, &pk_bytes),
137        "handle": handle,
138        "fingerprint": fingerprint(&pk_bytes),
139        "key_id": make_key_id(handle, &pk_bytes),
140        "config_dir": crate::config::config_dir()?.to_string_lossy(),
141        "already_initialized": false,
142    });
143
144    if let Some(url) = relay {
145        let client = crate::relay_client::RelayClient::new(url);
146        client.check_healthz()?;
147        let alloc = client.allocate_slot(Some(handle))?;
148        let mut rs = crate::config::read_relay_state()?;
149        rs["self"] = json!({
150            "relay_url": url,
151            "slot_id": alloc.slot_id.clone(),
152            "slot_token": alloc.slot_token,
153        });
154        crate::config::write_relay_state(&rs)?;
155        out["relay_url"] = json!(url);
156        out["slot_id"] = json!(alloc.slot_id);
157    }
158
159    Ok(out)
160}