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_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
114/// TODO(clawdentity): document `openclaw_agent_name_path`.
115pub fn openclaw_agent_name_path(config_dir: &Path) -> PathBuf {
116    config_dir.join(OPENCLAW_AGENT_FILE_NAME)
117}
118
119/// TODO(clawdentity): document `openclaw_relay_runtime_path`.
120pub fn openclaw_relay_runtime_path(config_dir: &Path) -> PathBuf {
121    config_dir.join(OPENCLAW_RELAY_RUNTIME_FILE_NAME)
122}
123
124/// TODO(clawdentity): document `openclaw_connectors_path`.
125pub fn openclaw_connectors_path(config_dir: &Path) -> PathBuf {
126    config_dir.join(OPENCLAW_CONNECTORS_FILE_NAME)
127}
128
129/// TODO(clawdentity): document `read_selected_openclaw_agent`.
130pub 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
144/// TODO(clawdentity): document `write_selected_openclaw_agent`.
145pub 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
152/// TODO(clawdentity): document `load_relay_runtime_config`.
153pub 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
157/// TODO(clawdentity): document `save_relay_runtime_config`.
158pub 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
179/// TODO(clawdentity): document `resolve_openclaw_base_url`.
180pub 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
199/// TODO(clawdentity): document `resolve_openclaw_hook_token`.
200pub 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
222/// TODO(clawdentity): document `load_connector_assignments`.
223pub 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
230/// TODO(clawdentity): document `save_connector_assignment`.
231pub 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
251/// TODO(clawdentity): document `resolve_connector_base_url`.
252pub 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}