clawdentity_core/pairing/
peers.rs1use 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
40pub 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
58pub 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
76pub 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
100pub 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
150pub 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}