clawdentity_core/providers/openclaw/
setup.rs1use std::collections::BTreeMap;
2use std::fs;
3use std::io::ErrorKind;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::{CoreError, Result};
9
10pub const OPENCLAW_AGENT_FILE_NAME: &str = "openclaw-agent-name";
11pub const OPENCLAW_RELAY_RUNTIME_FILE_NAME: &str = "openclaw-relay.json";
12pub const OPENCLAW_CONNECTORS_FILE_NAME: &str = "openclaw-connectors.json";
13pub const OPENCLAW_DEFAULT_BASE_URL: &str = "http://127.0.0.1:18789";
14
15const FILE_MODE: u32 = 0o600;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct OpenclawRelayRuntimeConfig {
20 pub openclaw_base_url: String,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub openclaw_hook_token: Option<String>,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub relay_transform_peers_path: Option<String>,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub updated_at: Option<String>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct OpenclawConnectorAssignment {
32 pub connector_base_url: String,
33 pub updated_at: String,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub struct OpenclawConnectorsConfig {
38 pub agents: BTreeMap<String, OpenclawConnectorAssignment>,
39}
40
41fn parse_non_empty(value: &str, field: &str) -> Result<String> {
42 let trimmed = value.trim();
43 if trimmed.is_empty() {
44 return Err(CoreError::InvalidInput(format!("{field} is required")));
45 }
46 Ok(trimmed.to_string())
47}
48
49fn normalize_http_url(value: &str, field: &'static str) -> Result<String> {
50 let parsed = url::Url::parse(value.trim()).map_err(|_| CoreError::InvalidUrl {
51 context: field,
52 value: value.to_string(),
53 })?;
54 if parsed.scheme() != "http" && parsed.scheme() != "https" {
55 return Err(CoreError::InvalidUrl {
56 context: field,
57 value: value.to_string(),
58 });
59 }
60 Ok(parsed.to_string())
61}
62
63fn write_secure_text(path: &Path, content: &str) -> Result<()> {
64 if let Some(parent) = path.parent() {
65 fs::create_dir_all(parent).map_err(|source| CoreError::Io {
66 path: parent.to_path_buf(),
67 source,
68 })?;
69 }
70 fs::write(path, content).map_err(|source| CoreError::Io {
71 path: path.to_path_buf(),
72 source,
73 })?;
74 #[cfg(unix)]
75 {
76 use std::os::unix::fs::PermissionsExt;
77 fs::set_permissions(path, fs::Permissions::from_mode(FILE_MODE)).map_err(|source| {
78 CoreError::Io {
79 path: path.to_path_buf(),
80 source,
81 }
82 })?;
83 }
84 Ok(())
85}
86
87fn write_secure_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
88 let body = serde_json::to_string_pretty(value)?;
89 write_secure_text(path, &format!("{body}\n"))
90}
91
92fn read_json_if_exists<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Option<T>> {
93 let raw = match fs::read_to_string(path) {
94 Ok(raw) => raw,
95 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
96 Err(source) => {
97 return Err(CoreError::Io {
98 path: path.to_path_buf(),
99 source,
100 });
101 }
102 };
103 let parsed = serde_json::from_str::<T>(&raw).map_err(|source| CoreError::JsonParse {
104 path: path.to_path_buf(),
105 source,
106 })?;
107 Ok(Some(parsed))
108}
109
110fn now_iso() -> String {
111 chrono::Utc::now().to_rfc3339()
112}
113
114pub fn openclaw_agent_name_path(config_dir: &Path) -> PathBuf {
116 config_dir.join(OPENCLAW_AGENT_FILE_NAME)
117}
118
119pub fn openclaw_relay_runtime_path(config_dir: &Path) -> PathBuf {
121 config_dir.join(OPENCLAW_RELAY_RUNTIME_FILE_NAME)
122}
123
124pub fn openclaw_connectors_path(config_dir: &Path) -> PathBuf {
126 config_dir.join(OPENCLAW_CONNECTORS_FILE_NAME)
127}
128
129pub fn read_selected_openclaw_agent(config_dir: &Path) -> Result<Option<String>> {
131 let path = openclaw_agent_name_path(config_dir);
132 let value = match fs::read_to_string(&path) {
133 Ok(value) => value,
134 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
135 Err(source) => return Err(CoreError::Io { path, source }),
136 };
137 let selected = value.trim().to_string();
138 if selected.is_empty() {
139 return Ok(None);
140 }
141 Ok(Some(selected))
142}
143
144pub fn write_selected_openclaw_agent(config_dir: &Path, agent_name: &str) -> Result<PathBuf> {
146 let selected = parse_non_empty(agent_name, "agentName")?;
147 let path = openclaw_agent_name_path(config_dir);
148 write_secure_text(&path, &format!("{selected}\n"))?;
149 Ok(path)
150}
151
152pub fn load_relay_runtime_config(config_dir: &Path) -> Result<Option<OpenclawRelayRuntimeConfig>> {
154 read_json_if_exists::<OpenclawRelayRuntimeConfig>(&openclaw_relay_runtime_path(config_dir))
155}
156
157pub fn save_relay_runtime_config(
159 config_dir: &Path,
160 config: OpenclawRelayRuntimeConfig,
161) -> Result<PathBuf> {
162 let normalized = OpenclawRelayRuntimeConfig {
163 openclaw_base_url: normalize_http_url(&config.openclaw_base_url, "openclawBaseUrl")?,
164 openclaw_hook_token: config
165 .openclaw_hook_token
166 .map(|value| value.trim().to_string())
167 .filter(|value| !value.is_empty()),
168 relay_transform_peers_path: config
169 .relay_transform_peers_path
170 .map(|value| value.trim().to_string())
171 .filter(|value| !value.is_empty()),
172 updated_at: config.updated_at.or_else(|| Some(now_iso())),
173 };
174 let path = openclaw_relay_runtime_path(config_dir);
175 write_secure_json(&path, &normalized)?;
176 Ok(path)
177}
178
179pub fn resolve_openclaw_base_url(config_dir: &Path, option_value: Option<&str>) -> Result<String> {
181 if let Some(value) = option_value
182 .map(str::trim)
183 .filter(|value| !value.is_empty())
184 {
185 return normalize_http_url(value, "openclawBaseUrl");
186 }
187 if let Ok(value) = std::env::var("OPENCLAW_BASE_URL") {
188 let value = value.trim();
189 if !value.is_empty() {
190 return normalize_http_url(value, "openclawBaseUrl");
191 }
192 }
193 if let Some(runtime) = load_relay_runtime_config(config_dir)? {
194 return normalize_http_url(&runtime.openclaw_base_url, "openclawBaseUrl");
195 }
196 Ok(OPENCLAW_DEFAULT_BASE_URL.to_string())
197}
198
199pub fn resolve_openclaw_hook_token(
201 config_dir: &Path,
202 option_value: Option<&str>,
203) -> Result<Option<String>> {
204 if let Some(value) = option_value
205 .map(str::trim)
206 .filter(|value| !value.is_empty())
207 {
208 return Ok(Some(value.to_string()));
209 }
210 if let Ok(value) = std::env::var("OPENCLAW_HOOK_TOKEN") {
211 let value = value.trim();
212 if !value.is_empty() {
213 return Ok(Some(value.to_string()));
214 }
215 }
216 Ok(load_relay_runtime_config(config_dir)?
217 .and_then(|config| config.openclaw_hook_token)
218 .map(|value| value.trim().to_string())
219 .filter(|value| !value.is_empty()))
220}
221
222pub fn load_connector_assignments(config_dir: &Path) -> Result<OpenclawConnectorsConfig> {
224 Ok(
225 read_json_if_exists::<OpenclawConnectorsConfig>(&openclaw_connectors_path(config_dir))?
226 .unwrap_or_default(),
227 )
228}
229
230pub fn save_connector_assignment(
232 config_dir: &Path,
233 agent_name: &str,
234 connector_base_url: &str,
235) -> Result<PathBuf> {
236 let agent_name = parse_non_empty(agent_name, "agentName")?;
237 let connector_base_url = normalize_http_url(connector_base_url, "connectorBaseUrl")?;
238 let mut assignments = load_connector_assignments(config_dir)?;
239 assignments.agents.insert(
240 agent_name,
241 OpenclawConnectorAssignment {
242 connector_base_url,
243 updated_at: now_iso(),
244 },
245 );
246 let path = openclaw_connectors_path(config_dir);
247 write_secure_json(&path, &assignments)?;
248 Ok(path)
249}
250
251pub fn resolve_connector_base_url(
253 config_dir: &Path,
254 agent_name: Option<&str>,
255 override_base_url: Option<&str>,
256) -> Result<Option<String>> {
257 if let Some(value) = override_base_url
258 .map(str::trim)
259 .filter(|value| !value.is_empty())
260 {
261 return Ok(Some(normalize_http_url(value, "connectorBaseUrl")?));
262 }
263 if let Ok(value) = std::env::var("CLAWDENTITY_CONNECTOR_BASE_URL") {
264 let value = value.trim();
265 if !value.is_empty() {
266 return Ok(Some(normalize_http_url(value, "connectorBaseUrl")?));
267 }
268 }
269 let Some(agent_name) = agent_name.map(str::trim).filter(|value| !value.is_empty()) else {
270 return Ok(None);
271 };
272 let assignments = load_connector_assignments(config_dir)?;
273 Ok(assignments
274 .agents
275 .get(agent_name)
276 .map(|entry| entry.connector_base_url.clone()))
277}
278
279#[cfg(test)]
280mod tests {
281 use tempfile::TempDir;
282
283 use super::{
284 OPENCLAW_DEFAULT_BASE_URL, OpenclawRelayRuntimeConfig, load_connector_assignments,
285 load_relay_runtime_config, read_selected_openclaw_agent, resolve_openclaw_base_url,
286 save_connector_assignment, save_relay_runtime_config, write_selected_openclaw_agent,
287 };
288
289 #[test]
290 fn selected_agent_round_trip() {
291 let temp = TempDir::new().expect("temp dir");
292 let _ = write_selected_openclaw_agent(temp.path(), "alpha").expect("write");
293 let selected = read_selected_openclaw_agent(temp.path()).expect("read");
294 assert_eq!(selected.as_deref(), Some("alpha"));
295 }
296
297 #[test]
298 fn relay_runtime_config_round_trip() {
299 let temp = TempDir::new().expect("temp dir");
300 let _ = save_relay_runtime_config(
301 temp.path(),
302 OpenclawRelayRuntimeConfig {
303 openclaw_base_url: "http://127.0.0.1:18789".to_string(),
304 openclaw_hook_token: Some("hook-token".to_string()),
305 relay_transform_peers_path: None,
306 updated_at: None,
307 },
308 )
309 .expect("save");
310 let loaded = load_relay_runtime_config(temp.path())
311 .expect("load")
312 .expect("config");
313 assert_eq!(loaded.openclaw_base_url, "http://127.0.0.1:18789/");
314 assert_eq!(loaded.openclaw_hook_token.as_deref(), Some("hook-token"));
315 }
316
317 #[test]
318 fn openclaw_base_url_defaults_when_runtime_config_is_missing() {
319 let temp = TempDir::new().expect("temp dir");
320 let resolved = resolve_openclaw_base_url(temp.path(), None).expect("base url");
321 assert_eq!(resolved, OPENCLAW_DEFAULT_BASE_URL);
322 }
323
324 #[test]
325 fn connector_assignments_round_trip() {
326 let temp = TempDir::new().expect("temp dir");
327 let _ = save_connector_assignment(temp.path(), "alpha", "http://127.0.0.1:19400")
328 .expect("save");
329 let assignments = load_connector_assignments(temp.path()).expect("load");
330 assert_eq!(assignments.agents.len(), 1);
331 assert_eq!(
332 assignments
333 .agents
334 .get("alpha")
335 .map(|entry| entry.connector_base_url.as_str()),
336 Some("http://127.0.0.1:19400/")
337 );
338 }
339}