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}