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}