Skip to main content

clawdentity_core/providers/openclaw/
setup.rs

1use 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
156/// TODO(clawdentity): document `resolve_openclaw_dir`.
157pub 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
185/// TODO(clawdentity): document `resolve_openclaw_config_path`.
186pub 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
201/// TODO(clawdentity): document `openclaw_agent_name_path`.
202pub fn openclaw_agent_name_path(config_dir: &Path) -> PathBuf {
203    config_dir.join(OPENCLAW_AGENT_FILE_NAME)
204}
205
206/// TODO(clawdentity): document `openclaw_relay_runtime_path`.
207pub fn openclaw_relay_runtime_path(config_dir: &Path) -> PathBuf {
208    config_dir.join(OPENCLAW_RELAY_RUNTIME_FILE_NAME)
209}
210
211/// TODO(clawdentity): document `openclaw_connectors_path`.
212pub fn openclaw_connectors_path(config_dir: &Path) -> PathBuf {
213    config_dir.join(OPENCLAW_CONNECTORS_FILE_NAME)
214}
215
216/// TODO(clawdentity): document `read_selected_openclaw_agent`.
217pub 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
231/// TODO(clawdentity): document `write_selected_openclaw_agent`.
232pub 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
239/// TODO(clawdentity): document `load_relay_runtime_config`.
240pub 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
244/// TODO(clawdentity): document `save_relay_runtime_config`.
245pub 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
266/// TODO(clawdentity): document `resolve_openclaw_base_url`.
267pub 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
286/// TODO(clawdentity): document `resolve_openclaw_hook_token`.
287pub 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
309/// TODO(clawdentity): document `load_connector_assignments`.
310pub 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
317/// TODO(clawdentity): document `save_connector_assignment`.
318pub 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
338/// TODO(clawdentity): document `connector_port_from_base_url`.
339pub 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
351/// TODO(clawdentity): document `build_connector_base_url`.
352pub 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        && let Some(port) = connector_port_from_base_url(&existing.connector_base_url)
359    {
360        return port;
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
378/// TODO(clawdentity): document `suggest_connector_base_url`.
379pub 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
385/// TODO(clawdentity): document `resolve_connector_base_url`.
386pub 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}