Skip to main content

clawdentity_core/pairing/
peers.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use crate::db::SqliteStore;
8use crate::db_peers::{PeerRecord, UpsertPeerInput, list_peers, upsert_peer};
9use crate::did::parse_agent_did;
10use crate::error::{CoreError, Result};
11
12const OPENCLAW_RELAY_RUNTIME_FILE_NAME: &str = "openclaw-relay.json";
13const FILE_MODE: u32 = 0o600;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct PeerEntry {
18    pub did: String,
19    pub proxy_url: String,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub agent_name: Option<String>,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub human_name: Option<String>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct PeersConfig {
28    pub peers: BTreeMap<String, PeerEntry>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct PersistPeerInput {
33    pub alias: Option<String>,
34    pub did: String,
35    pub proxy_url: String,
36    pub agent_name: Option<String>,
37    pub human_name: Option<String>,
38}
39
40/// TODO(clawdentity): document `derive_peer_alias_base`.
41pub fn derive_peer_alias_base(peer_did: &str) -> String {
42    if let Ok(parsed) = parse_agent_did(peer_did) {
43        let suffix = parsed
44            .ulid
45            .chars()
46            .rev()
47            .take(8)
48            .collect::<String>()
49            .chars()
50            .rev()
51            .collect::<String>()
52            .to_ascii_lowercase();
53        return format!("peer-{suffix}");
54    }
55    "peer".to_string()
56}
57
58/// TODO(clawdentity): document `load_peers_config`.
59pub fn load_peers_config(store: &SqliteStore) -> Result<PeersConfig> {
60    let peers = list_peers(store)?;
61    let mut by_alias = BTreeMap::<String, PeerEntry>::new();
62    for peer in peers {
63        by_alias.insert(
64            peer.alias,
65            PeerEntry {
66                did: peer.did,
67                proxy_url: peer.proxy_url,
68                agent_name: peer.agent_name,
69                human_name: peer.human_name,
70            },
71        );
72    }
73    Ok(PeersConfig { peers: by_alias })
74}
75
76/// TODO(clawdentity): document `resolve_peer_alias`.
77pub fn resolve_peer_alias(store: &SqliteStore, peer_did: &str) -> Result<String> {
78    let existing = list_peers(store)?;
79    for peer in &existing {
80        if peer.did == peer_did {
81            return Ok(peer.alias.clone());
82        }
83    }
84
85    let base = derive_peer_alias_base(peer_did);
86    if !existing.iter().any(|peer| peer.alias == base) {
87        return Ok(base);
88    }
89
90    let mut index = 2_usize;
91    loop {
92        let candidate = format!("{base}-{index}");
93        if !existing.iter().any(|peer| peer.alias == candidate) {
94            return Ok(candidate);
95        }
96        index += 1;
97    }
98}
99
100/// TODO(clawdentity): document `persist_peer`.
101pub fn persist_peer(store: &SqliteStore, input: PersistPeerInput) -> Result<PeerRecord> {
102    let did = input.did.trim().to_string();
103    if did.is_empty() {
104        return Err(CoreError::InvalidInput("peer did is required".to_string()));
105    }
106    let alias = match input.alias {
107        Some(alias) if !alias.trim().is_empty() => alias.trim().to_string(),
108        _ => resolve_peer_alias(store, &did)?,
109    };
110
111    upsert_peer(
112        store,
113        UpsertPeerInput {
114            alias,
115            did,
116            proxy_url: input.proxy_url,
117            agent_name: input.agent_name,
118            human_name: input.human_name,
119        },
120    )
121}
122
123fn set_secure_permissions(path: &Path) -> Result<()> {
124    #[cfg(unix)]
125    {
126        use std::os::unix::fs::PermissionsExt;
127        fs::set_permissions(path, fs::Permissions::from_mode(FILE_MODE)).map_err(|source| {
128            CoreError::Io {
129                path: path.to_path_buf(),
130                source,
131            }
132        })?;
133    }
134    Ok(())
135}
136
137fn parse_snapshot_path_from_runtime_config(raw: &str) -> Option<PathBuf> {
138    let parsed = serde_json::from_str::<serde_json::Value>(raw).ok()?;
139    let path = parsed
140        .get("relayTransformPeersPath")
141        .and_then(|value| value.as_str())?
142        .trim();
143    if path.is_empty() {
144        None
145    } else {
146        Some(PathBuf::from(path))
147    }
148}
149
150/// TODO(clawdentity): document `sync_openclaw_relay_peers_snapshot`.
151pub fn sync_openclaw_relay_peers_snapshot(config_dir: &Path, peers: &PeersConfig) -> Result<()> {
152    let runtime_config_path = config_dir.join(OPENCLAW_RELAY_RUNTIME_FILE_NAME);
153    let runtime_raw = match fs::read_to_string(&runtime_config_path) {
154        Ok(raw) => raw,
155        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
156        Err(source) => {
157            return Err(CoreError::Io {
158                path: runtime_config_path,
159                source,
160            });
161        }
162    };
163
164    let Some(snapshot_path) = parse_snapshot_path_from_runtime_config(&runtime_raw) else {
165        return Ok(());
166    };
167    if let Some(parent) = snapshot_path.parent() {
168        fs::create_dir_all(parent).map_err(|source| CoreError::Io {
169            path: parent.to_path_buf(),
170            source,
171        })?;
172    }
173
174    let body = serde_json::to_string_pretty(peers)?;
175    fs::write(&snapshot_path, format!("{body}\n")).map_err(|source| CoreError::Io {
176        path: snapshot_path.clone(),
177        source,
178    })?;
179    set_secure_permissions(&snapshot_path)?;
180    Ok(())
181}
182
183#[cfg(test)]
184mod tests {
185    use tempfile::TempDir;
186
187    use crate::db::SqliteStore;
188
189    use super::{
190        PersistPeerInput, load_peers_config, persist_peer, sync_openclaw_relay_peers_snapshot,
191    };
192
193    #[test]
194    fn persist_peer_generates_alias_and_loads_config() {
195        let temp = TempDir::new().expect("temp dir");
196        let store = SqliteStore::open_path(temp.path().join("db.sqlite3")).expect("open db");
197
198        let peer = persist_peer(
199            &store,
200            PersistPeerInput {
201                alias: None,
202                did: "did:cdi:registry.clawdentity.com:agent:01HF7YAT00W6W7CM7N3W5FDXT4"
203                    .to_string(),
204                proxy_url: "https://proxy.example/hooks/agent".to_string(),
205                agent_name: Some("Alpha".to_string()),
206                human_name: Some("Alice".to_string()),
207            },
208        )
209        .expect("persist");
210        assert!(peer.alias.starts_with("peer-"));
211
212        let loaded = load_peers_config(&store).expect("load");
213        assert_eq!(loaded.peers.len(), 1);
214    }
215
216    #[test]
217    fn sync_writes_peer_snapshot_when_runtime_config_references_path() {
218        let temp = TempDir::new().expect("temp dir");
219        let snapshot_path = temp.path().join("relay-peers.json");
220        std::fs::write(
221            temp.path().join("openclaw-relay.json"),
222            format!(
223                "{{\"relayTransformPeersPath\":\"{}\"}}",
224                snapshot_path.display()
225            ),
226        )
227        .expect("runtime config");
228
229        let peers = super::PeersConfig {
230            peers: [(
231                "alpha".to_string(),
232                super::PeerEntry {
233                    did: "did:cdi:registry.clawdentity.com:agent:01HF7YAT00W6W7CM7N3W5FDXT4"
234                        .to_string(),
235                    proxy_url: "https://proxy.example/hooks/agent".to_string(),
236                    agent_name: None,
237                    human_name: None,
238                },
239            )]
240            .into_iter()
241            .collect(),
242        };
243        sync_openclaw_relay_peers_snapshot(temp.path(), &peers).expect("sync");
244        assert!(snapshot_path.exists());
245    }
246}