Skip to main content

clawdentity_core/providers/openclaw/
mod.rs

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