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_CONFIG_FILE_NAME: &str = "openclaw.json";
14pub const OPENCLAW_DEFAULT_BASE_URL: &str = "http://127.0.0.1:18789";
15pub const DEFAULT_CONNECTOR_PORT: u16 = 19400;
16
17const FILE_MODE: u32 = 0o600;
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct OpenclawRelayRuntimeConfig {
22 pub openclaw_base_url: String,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub openclaw_hook_token: Option<String>,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub relay_transform_peers_path: Option<String>,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub updated_at: Option<String>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct OpenclawConnectorAssignment {
34 pub connector_base_url: String,
35 pub updated_at: String,
36}
37
38#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
39pub struct OpenclawConnectorsConfig {
40 pub agents: BTreeMap<String, OpenclawConnectorAssignment>,
41}
42
43fn parse_non_empty(value: &str, field: &str) -> Result<String> {
44 let trimmed = value.trim();
45 if trimmed.is_empty() {
46 return Err(CoreError::InvalidInput(format!("{field} is required")));
47 }
48 Ok(trimmed.to_string())
49}
50
51fn normalize_http_url(value: &str, field: &'static str) -> Result<String> {
52 let parsed = url::Url::parse(value.trim()).map_err(|_| CoreError::InvalidUrl {
53 context: field,
54 value: value.to_string(),
55 })?;
56 if parsed.scheme() != "http" && parsed.scheme() != "https" {
57 return Err(CoreError::InvalidUrl {
58 context: field,
59 value: value.to_string(),
60 });
61 }
62 Ok(parsed.to_string())
63}
64
65fn write_secure_text(path: &Path, content: &str) -> Result<()> {
66 if let Some(parent) = path.parent() {
67 fs::create_dir_all(parent).map_err(|source| CoreError::Io {
68 path: parent.to_path_buf(),
69 source,
70 })?;
71 }
72 fs::write(path, content).map_err(|source| CoreError::Io {
73 path: path.to_path_buf(),
74 source,
75 })?;
76 #[cfg(unix)]
77 {
78 use std::os::unix::fs::PermissionsExt;
79 fs::set_permissions(path, fs::Permissions::from_mode(FILE_MODE)).map_err(|source| {
80 CoreError::Io {
81 path: path.to_path_buf(),
82 source,
83 }
84 })?;
85 }
86 Ok(())
87}
88
89fn write_secure_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
90 let body = serde_json::to_string_pretty(value)?;
91 write_secure_text(path, &format!("{body}\n"))
92}
93
94fn read_json_if_exists<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Option<T>> {
95 let raw = match fs::read_to_string(path) {
96 Ok(raw) => raw,
97 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
98 Err(source) => {
99 return Err(CoreError::Io {
100 path: path.to_path_buf(),
101 source,
102 });
103 }
104 };
105 let parsed = serde_json::from_str::<T>(&raw).map_err(|source| CoreError::JsonParse {
106 path: path.to_path_buf(),
107 source,
108 })?;
109 Ok(Some(parsed))
110}
111
112fn now_iso() -> String {
113 chrono::Utc::now().to_rfc3339()
114}
115
116fn env_first_non_empty(keys: &[&str]) -> Option<String> {
117 keys.iter().find_map(|key| {
118 std::env::var(key).ok().and_then(|value| {
119 let trimmed = value.trim();
120 if trimmed.is_empty() {
121 None
122 } else {
123 Some(trimmed.to_string())
124 }
125 })
126 })
127}
128
129fn resolve_fallback_home_dir(home_dir: Option<&Path>) -> Result<PathBuf> {
130 if let Some(home_dir) = home_dir {
131 return Ok(home_dir.to_path_buf());
132 }
133 dirs::home_dir().ok_or(CoreError::HomeDirectoryUnavailable)
134}
135
136fn uses_direct_openclaw_profile(home_dir: &Path) -> bool {
137 home_dir.ends_with(".openclaw")
138 || home_dir.join(OPENCLAW_CONFIG_FILE_NAME).is_file()
139 || home_dir.join("hooks").is_dir()
140 || home_dir.join("skills").is_dir()
141 || home_dir.join("devices").is_dir()
142}
143
144pub(super) fn explicit_openclaw_dir(home_dir: &Path) -> PathBuf {
145 if uses_direct_openclaw_profile(home_dir) {
146 home_dir.to_path_buf()
147 } else {
148 home_dir.join(".openclaw")
149 }
150}
151
152pub(super) fn explicit_openclaw_config_path(home_dir: &Path) -> PathBuf {
153 explicit_openclaw_dir(home_dir).join(OPENCLAW_CONFIG_FILE_NAME)
154}
155
156pub fn resolve_openclaw_dir(
158 home_dir: Option<&Path>,
159 override_dir: Option<&Path>,
160) -> Result<PathBuf> {
161 if let Some(path) = override_dir {
162 return Ok(path.to_path_buf());
163 }
164
165 if let Some(home_dir) = home_dir {
166 return Ok(explicit_openclaw_dir(home_dir));
167 }
168
169 if let Some(path) = env_first_non_empty(&["OPENCLAW_STATE_DIR", "CLAWDBOT_STATE_DIR"]) {
170 return Ok(PathBuf::from(path));
171 }
172
173 if let Some(path) = env_first_non_empty(&["OPENCLAW_CONFIG_PATH", "CLAWDBOT_CONFIG_PATH"]) {
174 let path = PathBuf::from(path);
175 return Ok(path.parent().map(Path::to_path_buf).unwrap_or(path));
176 }
177
178 if let Some(path) = env_first_non_empty(&["OPENCLAW_HOME"]) {
179 return Ok(PathBuf::from(path).join(".openclaw"));
180 }
181
182 Ok(resolve_fallback_home_dir(home_dir)?.join(".openclaw"))
183}
184
185pub fn resolve_openclaw_config_path(
187 home_dir: Option<&Path>,
188 override_dir: Option<&Path>,
189) -> Result<PathBuf> {
190 if let Some(home_dir) = home_dir {
191 return Ok(explicit_openclaw_config_path(home_dir));
192 }
193
194 if let Some(path) = env_first_non_empty(&["OPENCLAW_CONFIG_PATH", "CLAWDBOT_CONFIG_PATH"]) {
195 return Ok(PathBuf::from(path));
196 }
197
198 Ok(resolve_openclaw_dir(home_dir, override_dir)?.join(OPENCLAW_CONFIG_FILE_NAME))
199}
200
201pub fn openclaw_agent_name_path(config_dir: &Path) -> PathBuf {
203 config_dir.join(OPENCLAW_AGENT_FILE_NAME)
204}
205
206pub fn openclaw_relay_runtime_path(config_dir: &Path) -> PathBuf {
208 config_dir.join(OPENCLAW_RELAY_RUNTIME_FILE_NAME)
209}
210
211pub fn openclaw_connectors_path(config_dir: &Path) -> PathBuf {
213 config_dir.join(OPENCLAW_CONNECTORS_FILE_NAME)
214}
215
216pub fn read_selected_openclaw_agent(config_dir: &Path) -> Result<Option<String>> {
218 let path = openclaw_agent_name_path(config_dir);
219 let value = match fs::read_to_string(&path) {
220 Ok(value) => value,
221 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
222 Err(source) => return Err(CoreError::Io { path, source }),
223 };
224 let selected = value.trim().to_string();
225 if selected.is_empty() {
226 return Ok(None);
227 }
228 Ok(Some(selected))
229}
230
231pub fn write_selected_openclaw_agent(config_dir: &Path, agent_name: &str) -> Result<PathBuf> {
233 let selected = parse_non_empty(agent_name, "agentName")?;
234 let path = openclaw_agent_name_path(config_dir);
235 write_secure_text(&path, &format!("{selected}\n"))?;
236 Ok(path)
237}
238
239pub fn load_relay_runtime_config(config_dir: &Path) -> Result<Option<OpenclawRelayRuntimeConfig>> {
241 read_json_if_exists::<OpenclawRelayRuntimeConfig>(&openclaw_relay_runtime_path(config_dir))
242}
243
244pub fn save_relay_runtime_config(
246 config_dir: &Path,
247 config: OpenclawRelayRuntimeConfig,
248) -> Result<PathBuf> {
249 let normalized = OpenclawRelayRuntimeConfig {
250 openclaw_base_url: normalize_http_url(&config.openclaw_base_url, "openclawBaseUrl")?,
251 openclaw_hook_token: config
252 .openclaw_hook_token
253 .map(|value| value.trim().to_string())
254 .filter(|value| !value.is_empty()),
255 relay_transform_peers_path: config
256 .relay_transform_peers_path
257 .map(|value| value.trim().to_string())
258 .filter(|value| !value.is_empty()),
259 updated_at: config.updated_at.or_else(|| Some(now_iso())),
260 };
261 let path = openclaw_relay_runtime_path(config_dir);
262 write_secure_json(&path, &normalized)?;
263 Ok(path)
264}
265
266pub fn resolve_openclaw_base_url(config_dir: &Path, option_value: Option<&str>) -> Result<String> {
268 if let Some(value) = option_value
269 .map(str::trim)
270 .filter(|value| !value.is_empty())
271 {
272 return normalize_http_url(value, "openclawBaseUrl");
273 }
274 if let Ok(value) = std::env::var("OPENCLAW_BASE_URL") {
275 let value = value.trim();
276 if !value.is_empty() {
277 return normalize_http_url(value, "openclawBaseUrl");
278 }
279 }
280 if let Some(runtime) = load_relay_runtime_config(config_dir)? {
281 return normalize_http_url(&runtime.openclaw_base_url, "openclawBaseUrl");
282 }
283 Ok(OPENCLAW_DEFAULT_BASE_URL.to_string())
284}
285
286pub fn resolve_openclaw_hook_token(
288 config_dir: &Path,
289 option_value: Option<&str>,
290) -> Result<Option<String>> {
291 if let Some(value) = option_value
292 .map(str::trim)
293 .filter(|value| !value.is_empty())
294 {
295 return Ok(Some(value.to_string()));
296 }
297 if let Ok(value) = std::env::var("OPENCLAW_HOOK_TOKEN") {
298 let value = value.trim();
299 if !value.is_empty() {
300 return Ok(Some(value.to_string()));
301 }
302 }
303 Ok(load_relay_runtime_config(config_dir)?
304 .and_then(|config| config.openclaw_hook_token)
305 .map(|value| value.trim().to_string())
306 .filter(|value| !value.is_empty()))
307}
308
309pub fn load_connector_assignments(config_dir: &Path) -> Result<OpenclawConnectorsConfig> {
311 Ok(
312 read_json_if_exists::<OpenclawConnectorsConfig>(&openclaw_connectors_path(config_dir))?
313 .unwrap_or_default(),
314 )
315}
316
317pub fn save_connector_assignment(
319 config_dir: &Path,
320 agent_name: &str,
321 connector_base_url: &str,
322) -> Result<PathBuf> {
323 let agent_name = parse_non_empty(agent_name, "agentName")?;
324 let connector_base_url = normalize_http_url(connector_base_url, "connectorBaseUrl")?;
325 let mut assignments = load_connector_assignments(config_dir)?;
326 assignments.agents.insert(
327 agent_name,
328 OpenclawConnectorAssignment {
329 connector_base_url,
330 updated_at: now_iso(),
331 },
332 );
333 let path = openclaw_connectors_path(config_dir);
334 write_secure_json(&path, &assignments)?;
335 Ok(path)
336}
337
338pub fn connector_port_from_base_url(connector_base_url: &str) -> Option<u16> {
340 let parsed = url::Url::parse(connector_base_url.trim()).ok()?;
341 if let Some(port) = parsed.port() {
342 return Some(port);
343 }
344 match parsed.scheme() {
345 "https" => Some(443),
346 "http" => Some(80),
347 _ => None,
348 }
349}
350
351pub fn build_connector_base_url(host: &str, port: u16) -> String {
353 format!("http://{host}:{port}")
354}
355
356fn allocate_connector_port(assignments: &OpenclawConnectorsConfig, agent_name: &str) -> u16 {
357 if let Some(existing) = assignments.agents.get(agent_name) {
358 if let Some(port) = connector_port_from_base_url(&existing.connector_base_url) {
359 return port;
360 }
361 }
362
363 let mut used_ports = assignments
364 .agents
365 .values()
366 .filter_map(|entry| connector_port_from_base_url(&entry.connector_base_url))
367 .collect::<Vec<_>>();
368 used_ports.sort_unstable();
369 used_ports.dedup();
370
371 let mut candidate = DEFAULT_CONNECTOR_PORT;
372 while used_ports.binary_search(&candidate).is_ok() {
373 candidate += 1;
374 }
375 candidate
376}
377
378pub fn suggest_connector_base_url(config_dir: &Path, agent_name: &str) -> Result<String> {
380 let assignments = load_connector_assignments(config_dir)?;
381 let port = allocate_connector_port(&assignments, agent_name);
382 Ok(build_connector_base_url("127.0.0.1", port))
383}
384
385pub fn resolve_connector_base_url(
387 config_dir: &Path,
388 agent_name: Option<&str>,
389 override_base_url: Option<&str>,
390) -> Result<Option<String>> {
391 if let Some(value) = override_base_url
392 .map(str::trim)
393 .filter(|value| !value.is_empty())
394 {
395 return Ok(Some(normalize_http_url(value, "connectorBaseUrl")?));
396 }
397 if let Ok(value) = std::env::var("CLAWDENTITY_CONNECTOR_BASE_URL") {
398 let value = value.trim();
399 if !value.is_empty() {
400 return Ok(Some(normalize_http_url(value, "connectorBaseUrl")?));
401 }
402 }
403 let Some(agent_name) = agent_name.map(str::trim).filter(|value| !value.is_empty()) else {
404 return Ok(None);
405 };
406 let assignments = load_connector_assignments(config_dir)?;
407 Ok(assignments
408 .agents
409 .get(agent_name)
410 .map(|entry| entry.connector_base_url.clone()))
411}
412
413#[cfg(test)]
414mod tests {
415 use tempfile::TempDir;
416
417 use super::{
418 OPENCLAW_DEFAULT_BASE_URL, OpenclawRelayRuntimeConfig, build_connector_base_url,
419 connector_port_from_base_url, load_connector_assignments, load_relay_runtime_config,
420 read_selected_openclaw_agent, resolve_openclaw_base_url, resolve_openclaw_config_path,
421 resolve_openclaw_dir, save_connector_assignment, save_relay_runtime_config,
422 suggest_connector_base_url, write_selected_openclaw_agent,
423 };
424
425 #[test]
426 fn selected_agent_round_trip() {
427 let temp = TempDir::new().expect("temp dir");
428 let _ = write_selected_openclaw_agent(temp.path(), "alpha").expect("write");
429 let selected = read_selected_openclaw_agent(temp.path()).expect("read");
430 assert_eq!(selected.as_deref(), Some("alpha"));
431 }
432
433 #[test]
434 fn relay_runtime_config_round_trip() {
435 let temp = TempDir::new().expect("temp dir");
436 let _ = save_relay_runtime_config(
437 temp.path(),
438 OpenclawRelayRuntimeConfig {
439 openclaw_base_url: "http://127.0.0.1:18789".to_string(),
440 openclaw_hook_token: Some("hook-token".to_string()),
441 relay_transform_peers_path: None,
442 updated_at: None,
443 },
444 )
445 .expect("save");
446 let loaded = load_relay_runtime_config(temp.path())
447 .expect("load")
448 .expect("config");
449 assert_eq!(loaded.openclaw_base_url, "http://127.0.0.1:18789/");
450 assert_eq!(loaded.openclaw_hook_token.as_deref(), Some("hook-token"));
451 }
452
453 #[test]
454 fn openclaw_base_url_defaults_when_runtime_config_is_missing() {
455 let temp = TempDir::new().expect("temp dir");
456 let resolved = resolve_openclaw_base_url(temp.path(), None).expect("base url");
457 assert_eq!(resolved, OPENCLAW_DEFAULT_BASE_URL);
458 }
459
460 #[test]
461 fn connector_assignments_round_trip() {
462 let temp = TempDir::new().expect("temp dir");
463 let _ = save_connector_assignment(temp.path(), "alpha", "http://127.0.0.1:19400")
464 .expect("save");
465 let assignments = load_connector_assignments(temp.path()).expect("load");
466 assert_eq!(assignments.agents.len(), 1);
467 assert_eq!(
468 assignments
469 .agents
470 .get("alpha")
471 .map(|entry| entry.connector_base_url.as_str()),
472 Some("http://127.0.0.1:19400/")
473 );
474 }
475
476 #[test]
477 fn connector_port_helpers_round_trip() {
478 assert_eq!(
479 connector_port_from_base_url("http://127.0.0.1:19400"),
480 Some(19400)
481 );
482 assert_eq!(
483 build_connector_base_url("127.0.0.1", 19401),
484 "http://127.0.0.1:19401"
485 );
486 }
487
488 #[test]
489 fn connector_suggestion_uses_next_available_port() {
490 let temp = TempDir::new().expect("temp dir");
491 let _ = save_connector_assignment(temp.path(), "alpha", "http://127.0.0.1:19400")
492 .expect("save alpha");
493 let suggested = suggest_connector_base_url(temp.path(), "beta").expect("suggest");
494 assert_eq!(suggested, "http://127.0.0.1:19401");
495 }
496
497 #[test]
498 fn openclaw_dir_respects_legacy_env_aliases() {
499 let temp = TempDir::new().expect("temp dir");
500 let state_dir = temp.path().join("legacy-state");
501 let config_path = state_dir.join("clawdbot.custom.json");
502 std::fs::create_dir_all(&state_dir).expect("state dir");
503
504 unsafe {
505 std::env::set_var("CLAWDBOT_STATE_DIR", &state_dir);
506 std::env::set_var("CLAWDBOT_CONFIG_PATH", &config_path);
507 }
508
509 let resolved_dir = resolve_openclaw_dir(None, None).expect("dir");
510 let resolved_config = resolve_openclaw_config_path(None, None).expect("config");
511
512 unsafe {
513 std::env::remove_var("CLAWDBOT_STATE_DIR");
514 std::env::remove_var("CLAWDBOT_CONFIG_PATH");
515 }
516
517 assert_eq!(resolved_dir, state_dir);
518 assert_eq!(resolved_config, config_path);
519 }
520
521 #[test]
522 fn explicit_home_dir_beats_legacy_env_aliases() {
523 let temp = TempDir::new().expect("temp dir");
524 let state_dir = temp.path().join("legacy-state");
525 let config_path = state_dir.join("clawdbot.custom.json");
526 std::fs::create_dir_all(&state_dir).expect("state dir");
527
528 unsafe {
529 std::env::set_var("CLAWDBOT_STATE_DIR", &state_dir);
530 std::env::set_var("CLAWDBOT_CONFIG_PATH", &config_path);
531 }
532
533 let resolved_dir = resolve_openclaw_dir(Some(temp.path()), None).expect("dir");
534 let resolved_config =
535 resolve_openclaw_config_path(Some(temp.path()), None).expect("config");
536
537 unsafe {
538 std::env::remove_var("CLAWDBOT_STATE_DIR");
539 std::env::remove_var("CLAWDBOT_CONFIG_PATH");
540 }
541
542 assert_eq!(resolved_dir, temp.path().join(".openclaw"));
543 assert_eq!(
544 resolved_config,
545 temp.path()
546 .join(".openclaw")
547 .join(super::OPENCLAW_CONFIG_FILE_NAME)
548 );
549 }
550
551 #[test]
552 fn explicit_home_dir_uses_direct_profile_root_when_openclaw_files_exist() {
553 let temp = TempDir::new().expect("temp dir");
554 std::fs::write(temp.path().join(super::OPENCLAW_CONFIG_FILE_NAME), "{}\n").expect("config");
555
556 let resolved_dir = resolve_openclaw_dir(Some(temp.path()), None).expect("dir");
557 let resolved_config =
558 resolve_openclaw_config_path(Some(temp.path()), None).expect("config");
559
560 assert_eq!(resolved_dir, temp.path());
561 assert_eq!(
562 resolved_config,
563 temp.path().join(super::OPENCLAW_CONFIG_FILE_NAME)
564 );
565 }
566}