Skip to main content

clawdentity_core/providers/openclaw/
mod.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde_json::json;
5
6pub use self::doctor::{
7    DoctorCheckStatus, DoctorStatus, OpenclawDoctorCheck, OpenclawDoctorOptions,
8    OpenclawDoctorResult, run_openclaw_doctor,
9};
10pub use self::relay_test::{
11    OpenclawRelayTestOptions, OpenclawRelayTestResult, OpenclawRelayWebsocketTestOptions,
12    OpenclawRelayWebsocketTestResult, RelayCheckStatus, run_openclaw_relay_test,
13    run_openclaw_relay_websocket_test,
14};
15pub use self::setup::{
16    OPENCLAW_AGENT_FILE_NAME, OPENCLAW_CONNECTORS_FILE_NAME, OPENCLAW_DEFAULT_BASE_URL,
17    OPENCLAW_RELAY_RUNTIME_FILE_NAME, OpenclawConnectorAssignment, OpenclawConnectorsConfig,
18    OpenclawRelayRuntimeConfig, load_connector_assignments, load_relay_runtime_config,
19    openclaw_agent_name_path, openclaw_connectors_path, openclaw_relay_runtime_path,
20    read_selected_openclaw_agent, resolve_connector_base_url, resolve_openclaw_base_url,
21    resolve_openclaw_hook_token, save_connector_assignment, save_relay_runtime_config,
22    write_selected_openclaw_agent,
23};
24use crate::config::{ConfigPathOptions, get_config_dir};
25use crate::db::SqliteStore;
26use crate::error::Result;
27use crate::provider::{
28    DetectionResult, InboundMessage, InboundRequest, InstallOptions, InstallResult,
29    PlatformProvider, ProviderDoctorCheckStatus, ProviderDoctorOptions, ProviderDoctorResult,
30    ProviderDoctorStatus, ProviderRelayTestOptions, ProviderRelayTestResult,
31    ProviderRelayTestStatus, ProviderSetupOptions, ProviderSetupResult, VerifyResult,
32    command_exists, default_webhook_url, ensure_json_object_path, join_url_path, now_iso,
33    read_json_or_default, resolve_home_dir_with_fallback, write_json,
34};
35
36const PROVIDER_NAME: &str = "openclaw";
37const PROVIDER_DISPLAY_NAME: &str = "OpenClaw";
38const OPENCLAW_DIR_NAME: &str = ".openclaw";
39const OPENCLAW_CONFIG_FILE_NAME: &str = "openclaw.json";
40const OPENCLAW_BINARY: &str = "openclaw";
41const OPENCLAW_WEBHOOK_PATH: &str = "/hooks/agent";
42
43#[derive(Debug, Clone, Default)]
44pub struct OpenclawProvider {
45    home_dir_override: Option<PathBuf>,
46    path_override: Option<Vec<PathBuf>>,
47}
48
49impl OpenclawProvider {
50    fn resolve_home_dir(&self) -> Option<PathBuf> {
51        self.home_dir_override.clone().or_else(dirs::home_dir)
52    }
53
54    fn openclaw_config_path_from_home(home_dir: &Path) -> PathBuf {
55        home_dir
56            .join(OPENCLAW_DIR_NAME)
57            .join(OPENCLAW_CONFIG_FILE_NAME)
58    }
59
60    fn install_home_dir(&self, opts: &InstallOptions) -> Result<PathBuf> {
61        resolve_home_dir_with_fallback(opts.home_dir.as_deref(), self.home_dir_override.as_deref())
62    }
63
64    fn resolve_webhook_url(&self, opts: &InstallOptions) -> Result<String> {
65        if let Some(connector_url) = opts
66            .connector_url
67            .as_deref()
68            .map(str::trim)
69            .filter(|value| !value.is_empty())
70        {
71            return join_url_path(connector_url, OPENCLAW_WEBHOOK_PATH, "connectorUrl");
72        }
73
74        let host = opts
75            .webhook_host
76            .as_deref()
77            .unwrap_or(self.default_webhook_host());
78        let port = opts.webhook_port.unwrap_or(self.default_webhook_port());
79        default_webhook_url(host, port, OPENCLAW_WEBHOOK_PATH)
80    }
81
82    #[cfg(test)]
83    fn with_test_context(home_dir: PathBuf, path_override: Vec<PathBuf>) -> Self {
84        Self {
85            home_dir_override: Some(home_dir),
86            path_override: Some(path_override),
87        }
88    }
89}
90
91impl PlatformProvider for OpenclawProvider {
92    fn name(&self) -> &str {
93        PROVIDER_NAME
94    }
95
96    fn display_name(&self) -> &str {
97        PROVIDER_DISPLAY_NAME
98    }
99
100    fn detect(&self) -> DetectionResult {
101        let mut evidence = Vec::new();
102        let mut confidence: f32 = 0.0;
103
104        if let Some(home_dir) = self.resolve_home_dir() {
105            let openclaw_dir = home_dir.join(OPENCLAW_DIR_NAME);
106            if openclaw_dir.is_dir() {
107                evidence.push(format!("found {}/", openclaw_dir.display()));
108                confidence += 0.65;
109            }
110
111            let config_path = openclaw_dir.join(OPENCLAW_CONFIG_FILE_NAME);
112            if config_path.is_file() {
113                evidence.push(format!("found {}", config_path.display()));
114                confidence += 0.1;
115            }
116        }
117
118        if command_exists(OPENCLAW_BINARY, self.path_override.as_deref()) {
119            evidence.push("openclaw binary in PATH".to_string());
120            confidence += 0.35;
121        }
122
123        DetectionResult {
124            detected: confidence > 0.0,
125            confidence: confidence.min(1.0),
126            evidence,
127        }
128    }
129
130    fn format_inbound(&self, message: &InboundMessage) -> InboundRequest {
131        let mut headers = HashMap::new();
132        headers.insert(
133            "x-webhook-sender-id".to_string(),
134            message.sender_did.clone(),
135        );
136        headers.insert(
137            "x-webhook-recipient-id".to_string(),
138            message.recipient_did.clone(),
139        );
140        headers.insert(
141            "x-webhook-target-path".to_string(),
142            OPENCLAW_WEBHOOK_PATH.to_string(),
143        );
144        if let Some(request_id) = message
145            .request_id
146            .as_deref()
147            .map(str::trim)
148            .filter(|value| !value.is_empty())
149        {
150            headers.insert("x-webhook-request-id".to_string(), request_id.to_string());
151        }
152
153        InboundRequest {
154            headers,
155            body: json!({
156                "content": message.content,
157                "senderDid": message.sender_did,
158                "recipientDid": message.recipient_did,
159                "requestId": message.request_id,
160                "metadata": message.metadata,
161                "path": OPENCLAW_WEBHOOK_PATH,
162            }),
163        }
164    }
165
166    fn default_webhook_port(&self) -> u16 {
167        3001
168    }
169
170    fn config_path(&self) -> Option<PathBuf> {
171        self.resolve_home_dir()
172            .map(|home_dir| Self::openclaw_config_path_from_home(&home_dir))
173    }
174
175    #[allow(clippy::too_many_lines)]
176    fn install(&self, opts: &InstallOptions) -> Result<InstallResult> {
177        let home_dir = self.install_home_dir(opts)?;
178        let config_path = Self::openclaw_config_path_from_home(&home_dir);
179
180        let state_options = ConfigPathOptions {
181            home_dir: Some(home_dir.clone()),
182            registry_url_hint: None,
183        };
184        let state_dir = get_config_dir(&state_options)?;
185        let base_url = resolve_openclaw_base_url(&state_dir, None)?;
186
187        let existing_runtime = load_relay_runtime_config(&state_dir)?;
188        let relay_transform_peers_path = existing_runtime
189            .as_ref()
190            .and_then(|config| config.relay_transform_peers_path.clone());
191
192        let webhook_token = resolve_openclaw_hook_token(&state_dir, opts.webhook_token.as_deref())?;
193        let webhook_url = self.resolve_webhook_url(opts)?;
194
195        let mut config = read_json_or_default(&config_path)?;
196
197        {
198            let clawdentity = ensure_json_object_path(&mut config, &["clawdentity"])?;
199            clawdentity.insert("provider".to_string(), json!(PROVIDER_NAME));
200            clawdentity.insert(
201                "webhook".to_string(),
202                json!({
203                    "enabled": true,
204                    "url": webhook_url,
205                    "host": opts
206                        .webhook_host
207                        .as_deref()
208                        .unwrap_or(self.default_webhook_host()),
209                    "port": opts.webhook_port.unwrap_or(self.default_webhook_port()),
210                    "path": OPENCLAW_WEBHOOK_PATH,
211                    "token": webhook_token,
212                    "connectorUrl": opts.connector_url,
213                }),
214            );
215        }
216
217        {
218            let hooks = ensure_json_object_path(&mut config, &["hooks"])?;
219            hooks.insert(
220                "agent".to_string(),
221                json!({
222                    "url": self.resolve_webhook_url(opts)?,
223                    "token": resolve_openclaw_hook_token(&state_dir, opts.webhook_token.as_deref())?,
224                }),
225            );
226        }
227
228        write_json(&config_path, &config)?;
229
230        let runtime_path = save_relay_runtime_config(
231            &state_dir,
232            OpenclawRelayRuntimeConfig {
233                openclaw_base_url: base_url,
234                openclaw_hook_token: resolve_openclaw_hook_token(
235                    &state_dir,
236                    opts.webhook_token.as_deref(),
237                )?,
238                relay_transform_peers_path,
239                updated_at: None,
240            },
241        )?;
242
243        Ok(InstallResult {
244            platform: self.name().to_string(),
245            config_updated: true,
246            service_installed: false,
247            notes: vec![
248                format!("updated {}", config_path.display()),
249                format!("updated {}", runtime_path.display()),
250                format!("configured webhook path {OPENCLAW_WEBHOOK_PATH}"),
251            ],
252        })
253    }
254
255    fn verify(&self) -> Result<VerifyResult> {
256        let state_options = ConfigPathOptions {
257            home_dir: self.home_dir_override.clone(),
258            registry_url_hint: None,
259        };
260        let state_dir = get_config_dir(&state_options)?;
261        let store = SqliteStore::open(&state_options)?;
262
263        let doctor = run_openclaw_doctor(
264            &state_dir,
265            &store,
266            OpenclawDoctorOptions {
267                home_dir: self.home_dir_override.clone(),
268                include_connector_runtime_check: true,
269                ..OpenclawDoctorOptions::default()
270            },
271        )?;
272
273        let checks = doctor
274            .checks
275            .into_iter()
276            .map(|check| {
277                let passed = check.status == DoctorCheckStatus::Pass;
278                let detail = if let Some(remediation_hint) = check.remediation_hint {
279                    format!("{} | fix: {remediation_hint}", check.message)
280                } else {
281                    check.message
282                };
283                (check.id, passed, detail)
284            })
285            .collect();
286
287        Ok(VerifyResult {
288            healthy: doctor.status == DoctorStatus::Healthy,
289            checks,
290        })
291    }
292
293    fn doctor(&self, opts: &ProviderDoctorOptions) -> Result<ProviderDoctorResult> {
294        let state_options = ConfigPathOptions {
295            home_dir: opts.home_dir.clone().or(self.home_dir_override.clone()),
296            registry_url_hint: None,
297        };
298        let state_dir = get_config_dir(&state_options)?;
299        let store = SqliteStore::open(&state_options)?;
300
301        let doctor = run_openclaw_doctor(
302            &state_dir,
303            &store,
304            OpenclawDoctorOptions {
305                home_dir: opts.home_dir.clone().or(self.home_dir_override.clone()),
306                openclaw_dir: opts.platform_state_dir.clone(),
307                selected_agent: opts.selected_agent.clone(),
308                peer_alias: opts.peer_alias.clone(),
309                connector_base_url: opts.connector_base_url.clone(),
310                include_connector_runtime_check: opts.include_connector_runtime_check,
311            },
312        )?;
313
314        let checks = doctor
315            .checks
316            .into_iter()
317            .map(|check| crate::provider::ProviderDoctorCheck {
318                id: check.id,
319                label: check.label,
320                status: if check.status == DoctorCheckStatus::Pass {
321                    ProviderDoctorCheckStatus::Pass
322                } else {
323                    ProviderDoctorCheckStatus::Fail
324                },
325                message: check.message,
326                remediation_hint: check.remediation_hint,
327                details: check.details,
328            })
329            .collect();
330
331        Ok(ProviderDoctorResult {
332            platform: self.name().to_string(),
333            status: if doctor.status == DoctorStatus::Healthy {
334                ProviderDoctorStatus::Healthy
335            } else {
336                ProviderDoctorStatus::Unhealthy
337            },
338            checks,
339        })
340    }
341
342    #[allow(clippy::too_many_lines)]
343    fn setup(&self, opts: &ProviderSetupOptions) -> Result<ProviderSetupResult> {
344        let state_options = ConfigPathOptions {
345            home_dir: opts.home_dir.clone().or(self.home_dir_override.clone()),
346            registry_url_hint: None,
347        };
348        let config_dir = get_config_dir(&state_options)?;
349        let agent_name = opts
350            .agent_name
351            .as_deref()
352            .map(str::trim)
353            .filter(|value| !value.is_empty())
354            .ok_or_else(|| {
355                crate::error::CoreError::InvalidInput("agent name is required".to_string())
356            })?;
357
358        let marker_path = write_selected_openclaw_agent(&config_dir, agent_name)?;
359        let resolved_base_url =
360            resolve_openclaw_base_url(&config_dir, opts.platform_base_url.as_deref())?;
361        let existing_runtime = load_relay_runtime_config(&config_dir)?;
362        let runtime_path = save_relay_runtime_config(
363            &config_dir,
364            OpenclawRelayRuntimeConfig {
365                openclaw_base_url: resolved_base_url,
366                openclaw_hook_token: opts.webhook_token.clone().or_else(|| {
367                    existing_runtime
368                        .as_ref()
369                        .and_then(|cfg| cfg.openclaw_hook_token.clone())
370                }),
371                relay_transform_peers_path: opts.relay_transform_peers_path.clone().or_else(|| {
372                    existing_runtime
373                        .as_ref()
374                        .and_then(|cfg| cfg.relay_transform_peers_path.clone())
375                }),
376                updated_at: Some(now_iso()),
377            },
378        )?;
379
380        let connector_assignment_path = if let Some(base_url) = opts.connector_base_url.as_deref() {
381            Some(save_connector_assignment(
382                &config_dir,
383                agent_name,
384                base_url,
385            )?)
386        } else {
387            None
388        };
389
390        let install_result = self.install(&InstallOptions {
391            home_dir: opts.home_dir.clone().or(self.home_dir_override.clone()),
392            webhook_port: opts.webhook_port,
393            webhook_host: opts.webhook_host.clone(),
394            webhook_token: opts.webhook_token.clone(),
395            connector_url: opts
396                .connector_url
397                .clone()
398                .or(opts.connector_base_url.clone()),
399        })?;
400
401        let mut updated_paths = vec![
402            marker_path.display().to_string(),
403            runtime_path.display().to_string(),
404        ];
405        if let Some(path) = connector_assignment_path {
406            updated_paths.push(path.display().to_string());
407        }
408        let mut notes = install_result.notes;
409        notes.push(format!("selected agent marker saved for `{agent_name}`"));
410        Ok(ProviderSetupResult {
411            platform: self.name().to_string(),
412            notes,
413            updated_paths,
414        })
415    }
416
417    #[allow(clippy::too_many_lines)]
418    fn relay_test(&self, opts: &ProviderRelayTestOptions) -> Result<ProviderRelayTestResult> {
419        let state_options = ConfigPathOptions {
420            home_dir: opts.home_dir.clone().or(self.home_dir_override.clone()),
421            registry_url_hint: None,
422        };
423        let config_dir = get_config_dir(&state_options)?;
424        let store = SqliteStore::open(&state_options)?;
425        let result = run_openclaw_relay_test(
426            &config_dir,
427            &store,
428            OpenclawRelayTestOptions {
429                home_dir: opts.home_dir.clone().or(self.home_dir_override.clone()),
430                openclaw_dir: opts.platform_state_dir.clone(),
431                peer_alias: opts.peer_alias.clone(),
432                openclaw_base_url: opts.platform_base_url.clone(),
433                hook_token: opts.webhook_token.clone(),
434                message: opts.message.clone(),
435                session_id: opts.session_id.clone(),
436                skip_preflight: opts.skip_preflight,
437            },
438        )?;
439        Ok(ProviderRelayTestResult {
440            platform: self.name().to_string(),
441            status: if result.status == RelayCheckStatus::Success {
442                ProviderRelayTestStatus::Success
443            } else {
444                ProviderRelayTestStatus::Failure
445            },
446            checked_at: result.checked_at,
447            endpoint: result.endpoint,
448            peer_alias: Some(result.peer_alias),
449            http_status: result.http_status,
450            message: result.message,
451            remediation_hint: result.remediation_hint,
452            preflight: result.preflight.map(|preflight| ProviderDoctorResult {
453                platform: self.name().to_string(),
454                status: if preflight.status == DoctorStatus::Healthy {
455                    ProviderDoctorStatus::Healthy
456                } else {
457                    ProviderDoctorStatus::Unhealthy
458                },
459                checks: preflight
460                    .checks
461                    .into_iter()
462                    .map(|check| crate::provider::ProviderDoctorCheck {
463                        id: check.id,
464                        label: check.label,
465                        status: if check.status == DoctorCheckStatus::Pass {
466                            ProviderDoctorCheckStatus::Pass
467                        } else {
468                            ProviderDoctorCheckStatus::Fail
469                        },
470                        message: check.message,
471                        remediation_hint: check.remediation_hint,
472                        details: check.details,
473                    })
474                    .collect(),
475            }),
476            details: None,
477        })
478    }
479}
480
481mod doctor;
482mod relay_test;
483mod setup;
484
485#[cfg(test)]
486mod tests {
487    use std::collections::HashMap;
488
489    use tempfile::TempDir;
490
491    use crate::provider::{InboundMessage, PlatformProvider};
492
493    use super::{OPENCLAW_CONFIG_FILE_NAME, OPENCLAW_DIR_NAME, OpenclawProvider};
494
495    #[test]
496    fn detection_checks_home_and_path_evidence() {
497        let home = TempDir::new().expect("temp home");
498        let openclaw_dir = home.path().join(OPENCLAW_DIR_NAME);
499        std::fs::create_dir_all(&openclaw_dir).expect("openclaw dir");
500        std::fs::write(openclaw_dir.join(OPENCLAW_CONFIG_FILE_NAME), "{}\n").expect("config");
501
502        let bin_dir = TempDir::new().expect("temp bin");
503        std::fs::write(bin_dir.path().join("openclaw"), "#!/bin/sh\n").expect("binary");
504
505        let provider = OpenclawProvider::with_test_context(
506            home.path().to_path_buf(),
507            vec![bin_dir.path().to_path_buf()],
508        );
509        let detection = provider.detect();
510
511        assert!(detection.detected);
512        assert!(detection.confidence > 0.9);
513        assert!(
514            detection
515                .evidence
516                .iter()
517                .any(|entry| entry.contains("openclaw binary in PATH"))
518        );
519    }
520
521    #[test]
522    fn format_inbound_uses_openclaw_webhook_shape() {
523        let provider = OpenclawProvider::default();
524        let mut metadata = HashMap::new();
525        metadata.insert("thread".to_string(), "relay".to_string());
526
527        let request = provider.format_inbound(&InboundMessage {
528            sender_did: "did:cdi:registry.clawdentity.com:agent:01HF7YAT00W6W7CM7N3W5FDXTB"
529                .to_string(),
530            recipient_did: "did:cdi:registry.clawdentity.com:agent:01HF7YAT00W6W7CM7N3W5FDXTC"
531                .to_string(),
532            content: "hello".to_string(),
533            request_id: Some("req-123".to_string()),
534            metadata,
535        });
536
537        assert_eq!(
538            request
539                .headers
540                .get("x-webhook-sender-id")
541                .map(String::as_str),
542            Some("did:cdi:registry.clawdentity.com:agent:01HF7YAT00W6W7CM7N3W5FDXTB")
543        );
544        assert_eq!(
545            request.body.get("content").and_then(|value| value.as_str()),
546            Some("hello")
547        );
548        assert_eq!(
549            request.body.get("path").and_then(|value| value.as_str()),
550            Some("/hooks/agent")
551        );
552    }
553
554    #[test]
555    fn config_path_points_to_openclaw_json() {
556        let home = TempDir::new().expect("temp home");
557        let provider = OpenclawProvider::with_test_context(home.path().to_path_buf(), Vec::new());
558
559        assert_eq!(
560            provider.config_path(),
561            Some(
562                home.path()
563                    .join(OPENCLAW_DIR_NAME)
564                    .join(OPENCLAW_CONFIG_FILE_NAME)
565            )
566        );
567    }
568}