1pub mod nanobot;
2pub mod nanoclaw;
3pub mod openclaw;
4pub mod picoclaw;
5
6pub use nanobot::NanobotProvider;
7pub use nanoclaw::NanoclawProvider;
8pub use openclaw::OpenclawProvider;
9pub use picoclaw::PicoclawProvider;
10
11use std::collections::HashMap;
12use std::env;
13use std::fs;
14use std::io::ErrorKind;
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18use serde_json::{Map, Value};
19
20use crate::config::{ConfigPathOptions, get_config_dir};
21use crate::error::{CoreError, Result};
22use crate::http::blocking_client;
23
24pub trait PlatformProvider {
25 fn name(&self) -> &str;
27
28 fn display_name(&self) -> &str;
30
31 fn detect(&self) -> DetectionResult;
33
34 fn format_inbound(&self, message: &InboundMessage) -> InboundRequest;
36
37 fn default_webhook_port(&self) -> u16;
39
40 fn default_webhook_host(&self) -> &str {
42 "127.0.0.1"
43 }
44
45 fn config_path(&self) -> Option<PathBuf>;
47
48 fn install(&self, opts: &InstallOptions) -> Result<InstallResult>;
50
51 fn verify(&self) -> Result<VerifyResult>;
53
54 fn doctor(&self, opts: &ProviderDoctorOptions) -> Result<ProviderDoctorResult>;
56
57 fn setup(&self, opts: &ProviderSetupOptions) -> Result<ProviderSetupResult>;
59
60 fn relay_test(&self, opts: &ProviderRelayTestOptions) -> Result<ProviderRelayTestResult>;
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct DetectionResult {
66 pub detected: bool,
67 pub confidence: f32,
68 pub evidence: Vec<String>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct InboundMessage {
73 pub sender_did: String,
74 pub recipient_did: String,
75 pub content: String,
76 pub request_id: Option<String>,
77 pub metadata: HashMap<String, String>,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct InboundRequest {
82 pub headers: HashMap<String, String>,
83 pub body: Value,
84}
85
86#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
87pub struct InstallOptions {
88 pub home_dir: Option<PathBuf>,
89 pub webhook_port: Option<u16>,
90 pub webhook_host: Option<String>,
91 pub webhook_token: Option<String>,
92 pub connector_url: Option<String>,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct InstallResult {
97 pub platform: String,
98 pub config_updated: bool,
99 pub service_installed: bool,
100 pub notes: Vec<String>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct VerifyResult {
105 pub healthy: bool,
106 pub checks: Vec<(String, bool, String)>,
107}
108
109#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct ProviderDoctorOptions {
112 pub home_dir: Option<PathBuf>,
113 pub platform_state_dir: Option<PathBuf>,
114 pub selected_agent: Option<String>,
115 pub peer_alias: Option<String>,
116 pub connector_base_url: Option<String>,
117 pub include_connector_runtime_check: bool,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "lowercase")]
122pub enum ProviderDoctorCheckStatus {
123 Pass,
124 Fail,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "lowercase")]
129pub enum ProviderDoctorStatus {
130 Healthy,
131 Unhealthy,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "camelCase")]
136pub struct ProviderDoctorCheck {
137 pub id: String,
138 pub label: String,
139 pub status: ProviderDoctorCheckStatus,
140 pub message: String,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub remediation_hint: Option<String>,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 pub details: Option<Value>,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct ProviderDoctorResult {
150 pub platform: String,
151 pub status: ProviderDoctorStatus,
152 pub checks: Vec<ProviderDoctorCheck>,
153}
154
155#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct ProviderSetupOptions {
158 pub home_dir: Option<PathBuf>,
159 pub agent_name: Option<String>,
160 pub platform_base_url: Option<String>,
161 pub webhook_host: Option<String>,
162 pub webhook_port: Option<u16>,
163 pub webhook_token: Option<String>,
164 pub connector_base_url: Option<String>,
165 pub connector_url: Option<String>,
166 pub relay_transform_peers_path: Option<String>,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "camelCase")]
171pub struct ProviderSetupResult {
172 pub platform: String,
173 pub notes: Vec<String>,
174 pub updated_paths: Vec<String>,
175}
176
177#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct ProviderRelayTestOptions {
180 pub home_dir: Option<PathBuf>,
181 pub platform_state_dir: Option<PathBuf>,
182 pub peer_alias: Option<String>,
183 pub platform_base_url: Option<String>,
184 pub webhook_token: Option<String>,
185 pub connector_base_url: Option<String>,
186 pub message: Option<String>,
187 pub session_id: Option<String>,
188 pub skip_preflight: bool,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(rename_all = "lowercase")]
193pub enum ProviderRelayTestStatus {
194 Success,
195 Failure,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(rename_all = "camelCase")]
200pub struct ProviderRelayTestResult {
201 pub platform: String,
202 pub status: ProviderRelayTestStatus,
203 pub checked_at: String,
204 pub endpoint: String,
205 #[serde(skip_serializing_if = "Option::is_none")]
206 pub peer_alias: Option<String>,
207 #[serde(skip_serializing_if = "Option::is_none")]
208 pub http_status: Option<u16>,
209 pub message: String,
210 #[serde(skip_serializing_if = "Option::is_none")]
211 pub remediation_hint: Option<String>,
212 #[serde(skip_serializing_if = "Option::is_none")]
213 pub preflight: Option<ProviderDoctorResult>,
214 #[serde(skip_serializing_if = "Option::is_none")]
215 pub details: Option<Value>,
216}
217
218pub fn all_providers() -> Vec<Box<dyn PlatformProvider>> {
220 vec![
221 Box::new(OpenclawProvider::default()),
222 Box::new(PicoclawProvider::default()),
223 Box::new(NanobotProvider::default()),
224 Box::new(NanoclawProvider::default()),
225 ]
226}
227
228pub fn detect_platform() -> Option<Box<dyn PlatformProvider>> {
230 let mut selected: Option<(f32, Box<dyn PlatformProvider>)> = None;
231
232 for provider in all_providers() {
233 let detection = provider.detect();
234 if !detection.detected {
235 continue;
236 }
237
238 if let Some((confidence, _)) = selected.as_ref()
239 && detection.confidence <= *confidence
240 {
241 continue;
242 }
243
244 selected = Some((detection.confidence, provider));
245 }
246
247 selected.map(|(_, provider)| provider)
248}
249
250pub fn get_provider(name: &str) -> Option<Box<dyn PlatformProvider>> {
252 let normalized = name.trim();
253 if normalized.is_empty() {
254 return None;
255 }
256
257 all_providers()
258 .into_iter()
259 .find(|provider| provider.name().eq_ignore_ascii_case(normalized))
260}
261
262pub(crate) fn resolve_home_dir(home_override: Option<&Path>) -> Result<PathBuf> {
263 if let Some(home_dir) = home_override {
264 return Ok(home_dir.to_path_buf());
265 }
266 dirs::home_dir().ok_or(CoreError::HomeDirectoryUnavailable)
267}
268
269pub(crate) fn resolve_home_dir_with_fallback(
270 install_override: Option<&Path>,
271 provider_override: Option<&Path>,
272) -> Result<PathBuf> {
273 if let Some(home_dir) = install_override {
274 return Ok(home_dir.to_path_buf());
275 }
276
277 resolve_home_dir(provider_override)
278}
279
280pub(crate) fn command_exists(command: &str, path_override: Option<&[PathBuf]>) -> bool {
281 if command.trim().is_empty() {
282 return false;
283 }
284
285 if let Some(paths) = path_override {
286 return paths
287 .iter()
288 .any(|path| command_exists_in_directory(path, command));
289 }
290
291 match env::var_os("PATH") {
292 Some(paths) => {
293 env::split_paths(&paths).any(|path| command_exists_in_directory(&path, command))
294 }
295 None => false,
296 }
297}
298
299fn command_exists_in_directory(path: &Path, command: &str) -> bool {
300 #[cfg(windows)]
301 {
302 if Path::new(command).extension().is_some() {
303 return path.join(command).is_file();
304 }
305
306 if let Some(pathext) = env::var_os("PATHEXT") {
307 for ext in
308 env::split_paths(&pathext).filter_map(|entry| entry.to_str().map(str::to_string))
309 {
310 let normalized = ext.trim_start_matches('.');
311 let candidate = path.join(format!("{command}.{normalized}"));
312 if candidate.is_file() {
313 return true;
314 }
315 }
316 }
317
318 path.join(command).is_file()
319 }
320
321 #[cfg(not(windows))]
322 {
323 path.join(command).is_file()
324 }
325}
326
327pub(crate) fn default_webhook_url(host: &str, port: u16, webhook_path: &str) -> Result<String> {
328 let host = host.trim();
329 if host.is_empty() {
330 return Err(CoreError::InvalidInput(
331 "webhook host cannot be empty".to_string(),
332 ));
333 }
334
335 let base_url = format!("http://{host}:{port}");
336 join_url_path(&base_url, webhook_path, "webhookHost")
337}
338
339pub(crate) fn join_url_path(base_url: &str, path: &str, context: &'static str) -> Result<String> {
340 let trimmed_base = base_url.trim();
341 if trimmed_base.is_empty() {
342 return Err(CoreError::InvalidInput(format!(
343 "{context} cannot be empty"
344 )));
345 }
346
347 let normalized_base = if trimmed_base.ends_with('/') {
348 trimmed_base.to_string()
349 } else {
350 format!("{trimmed_base}/")
351 };
352
353 let url = url::Url::parse(&normalized_base).map_err(|_| CoreError::InvalidUrl {
354 context,
355 value: trimmed_base.to_string(),
356 })?;
357
358 let normalized_path = path.trim().trim_start_matches('/');
359 let joined = url
360 .join(normalized_path)
361 .map_err(|_| CoreError::InvalidUrl {
362 context,
363 value: path.to_string(),
364 })?;
365
366 Ok(joined.to_string())
367}
368
369pub(crate) fn health_check(host: &str, port: u16) -> Result<(bool, String)> {
370 let url = default_webhook_url(host, port, "/health")?;
371
372 let response = blocking_client()?
373 .get(&url)
374 .header("accept", "application/json")
375 .send();
376
377 match response {
378 Ok(response) => {
379 if response.status().is_success() {
380 Ok((
381 true,
382 format!("health endpoint responded with HTTP {}", response.status()),
383 ))
384 } else {
385 Ok((
386 false,
387 format!("health endpoint returned HTTP {}", response.status()),
388 ))
389 }
390 }
391 Err(error) => Ok((false, format!("health endpoint request failed: {error}"))),
392 }
393}
394
395pub(crate) fn read_json_or_default(path: &Path) -> Result<Value> {
396 let raw = match fs::read_to_string(path) {
397 Ok(raw) => raw,
398 Err(error) if error.kind() == ErrorKind::NotFound => {
399 return Ok(Value::Object(Map::new()));
400 }
401 Err(source) => {
402 return Err(CoreError::Io {
403 path: path.to_path_buf(),
404 source,
405 });
406 }
407 };
408
409 if raw.trim().is_empty() {
410 return Ok(Value::Object(Map::new()));
411 }
412
413 serde_json::from_str::<Value>(&raw).map_err(|source| CoreError::JsonParse {
414 path: path.to_path_buf(),
415 source,
416 })
417}
418
419pub(crate) fn write_json(path: &Path, value: &Value) -> Result<()> {
420 if let Some(parent) = path.parent() {
421 fs::create_dir_all(parent).map_err(|source| CoreError::Io {
422 path: parent.to_path_buf(),
423 source,
424 })?;
425 }
426
427 let body = serde_json::to_string_pretty(value)?;
428 fs::write(path, format!("{body}\n")).map_err(|source| CoreError::Io {
429 path: path.to_path_buf(),
430 source,
431 })
432}
433
434pub(crate) fn read_text(path: &Path) -> Result<Option<String>> {
435 match fs::read_to_string(path) {
436 Ok(contents) => Ok(Some(contents)),
437 Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
438 Err(source) => Err(CoreError::Io {
439 path: path.to_path_buf(),
440 source,
441 }),
442 }
443}
444
445pub(crate) fn write_text(path: &Path, contents: &str) -> Result<()> {
446 if let Some(parent) = path.parent() {
447 fs::create_dir_all(parent).map_err(|source| CoreError::Io {
448 path: parent.to_path_buf(),
449 source,
450 })?;
451 }
452
453 fs::write(path, contents).map_err(|source| CoreError::Io {
454 path: path.to_path_buf(),
455 source,
456 })
457}
458
459pub(crate) fn ensure_json_object_path<'a>(
460 root: &'a mut Value,
461 path: &[&str],
462) -> Result<&'a mut Map<String, Value>> {
463 if !root.is_object() {
464 *root = Value::Object(Map::new());
465 }
466
467 let mut current = root;
468 for segment in path {
469 if !current.is_object() {
470 *current = Value::Object(Map::new());
471 }
472
473 let object = current
474 .as_object_mut()
475 .ok_or_else(|| CoreError::InvalidInput("json value must be an object".to_string()))?;
476
477 current = object
478 .entry((*segment).to_string())
479 .or_insert_with(|| Value::Object(Map::new()));
480 }
481
482 if !current.is_object() {
483 *current = Value::Object(Map::new());
484 }
485
486 current
487 .as_object_mut()
488 .ok_or_else(|| CoreError::InvalidInput("json value must be an object".to_string()))
489}
490
491pub(crate) fn upsert_env_var(contents: &str, key: &str, value: &str) -> String {
492 let mut updated = false;
493 let mut lines = Vec::new();
494
495 for line in contents.lines() {
496 if let Some((line_key, _)) = line.split_once('=')
497 && line_key.trim() == key
498 {
499 lines.push(format!("{key}={value}"));
500 updated = true;
501 continue;
502 }
503
504 lines.push(line.to_string());
505 }
506
507 if !updated {
508 if !contents.trim().is_empty() {
509 lines.push(String::new());
510 }
511 lines.push(format!("{key}={value}"));
512 }
513
514 let mut output = lines.join("\n");
515 if !output.ends_with('\n') {
516 output.push('\n');
517 }
518 output
519}
520
521pub(crate) fn upsert_marked_block(contents: &str, start: &str, end: &str, block: &str) -> String {
522 if let Some(start_idx) = contents.find(start)
523 && let Some(end_rel_idx) = contents[start_idx..].find(end)
524 {
525 let end_idx = start_idx + end_rel_idx + end.len();
526
527 let prefix = contents[..start_idx].trim_end_matches('\n');
528 let suffix = contents[end_idx..].trim_start_matches('\n');
529
530 let mut output = String::new();
531 if !prefix.is_empty() {
532 output.push_str(prefix);
533 output.push('\n');
534 }
535 output.push_str(block.trim_end_matches('\n'));
536 output.push('\n');
537 if !suffix.is_empty() {
538 output.push_str(suffix);
539 if !output.ends_with('\n') {
540 output.push('\n');
541 }
542 }
543 return output;
544 }
545
546 let mut output = contents.trim_end_matches('\n').to_string();
547 if !output.is_empty() {
548 output.push('\n');
549 }
550 output.push_str(block.trim_end_matches('\n'));
551 output.push('\n');
552 output
553}
554
555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
556#[serde(rename_all = "camelCase")]
557pub(crate) struct ProviderRelayRuntimeConfig {
558 pub webhook_endpoint: String,
559 #[serde(skip_serializing_if = "Option::is_none")]
560 pub connector_base_url: Option<String>,
561 #[serde(skip_serializing_if = "Option::is_none")]
562 pub webhook_token: Option<String>,
563 #[serde(skip_serializing_if = "Option::is_none")]
564 pub platform_base_url: Option<String>,
565 #[serde(skip_serializing_if = "Option::is_none")]
566 pub relay_transform_peers_path: Option<String>,
567 pub updated_at: String,
568}
569
570pub(crate) fn now_iso() -> String {
571 chrono::Utc::now().to_rfc3339()
572}
573
574pub(crate) fn resolve_state_dir(home_dir: Option<PathBuf>) -> Result<PathBuf> {
575 let options = ConfigPathOptions {
576 home_dir,
577 registry_url_hint: None,
578 };
579 get_config_dir(&options)
580}
581
582pub(crate) fn provider_agent_marker_path(state_dir: &Path, provider: &str) -> PathBuf {
583 state_dir.join(format!("{provider}-agent-name"))
584}
585
586pub(crate) fn provider_runtime_path(state_dir: &Path, provider: &str) -> PathBuf {
587 state_dir.join(format!("{provider}-relay.json"))
588}
589
590pub(crate) fn write_provider_agent_marker(
591 state_dir: &Path,
592 provider: &str,
593 agent_name: &str,
594) -> Result<PathBuf> {
595 let agent_name = agent_name.trim();
596 if agent_name.is_empty() {
597 return Err(CoreError::InvalidInput(
598 "agent name cannot be empty".to_string(),
599 ));
600 }
601 let path = provider_agent_marker_path(state_dir, provider);
602 write_text(&path, &format!("{agent_name}\n"))?;
603 Ok(path)
604}
605
606pub(crate) fn read_provider_agent_marker(
607 state_dir: &Path,
608 provider: &str,
609) -> Result<Option<String>> {
610 let path = provider_agent_marker_path(state_dir, provider);
611 let value = read_text(&path)?;
612 Ok(value.and_then(|value| {
613 let trimmed = value.trim().to_string();
614 if trimmed.is_empty() {
615 None
616 } else {
617 Some(trimmed)
618 }
619 }))
620}
621
622pub(crate) fn save_provider_runtime_config(
623 state_dir: &Path,
624 provider: &str,
625 config: ProviderRelayRuntimeConfig,
626) -> Result<PathBuf> {
627 let path = provider_runtime_path(state_dir, provider);
628 let mut value = serde_json::to_value(&config)?;
629 if !value.is_object() {
630 value = Value::Object(Map::new());
631 }
632 write_json(&path, &value)?;
633 Ok(path)
634}
635
636pub(crate) fn load_provider_runtime_config(
637 state_dir: &Path,
638 provider: &str,
639) -> Result<Option<ProviderRelayRuntimeConfig>> {
640 let path = provider_runtime_path(state_dir, provider);
641 let value = match read_text(&path)? {
642 Some(raw) => {
643 if raw.trim().is_empty() {
644 return Ok(None);
645 }
646 serde_json::from_str::<ProviderRelayRuntimeConfig>(&raw).map_err(|source| {
647 CoreError::JsonParse {
648 path: path.clone(),
649 source,
650 }
651 })?
652 }
653 None => return Ok(None),
654 };
655 Ok(Some(value))
656}
657
658pub(crate) fn push_doctor_check(
659 checks: &mut Vec<ProviderDoctorCheck>,
660 id: impl Into<String>,
661 label: impl Into<String>,
662 status: ProviderDoctorCheckStatus,
663 message: impl Into<String>,
664 remediation_hint: Option<String>,
665 details: Option<Value>,
666) {
667 checks.push(ProviderDoctorCheck {
668 id: id.into(),
669 label: label.into(),
670 status,
671 message: message.into(),
672 remediation_hint,
673 details,
674 });
675}
676
677pub(crate) fn doctor_status_from_checks(checks: &[ProviderDoctorCheck]) -> ProviderDoctorStatus {
678 if checks
679 .iter()
680 .any(|check| check.status == ProviderDoctorCheckStatus::Fail)
681 {
682 ProviderDoctorStatus::Unhealthy
683 } else {
684 ProviderDoctorStatus::Healthy
685 }
686}
687
688pub(crate) fn check_connector_runtime(connector_base_url: &str) -> Result<(bool, String)> {
689 let status_url = join_url_path(connector_base_url, "/v1/status", "connectorBaseUrl")?;
690 let response = blocking_client()?
691 .get(&status_url)
692 .header("accept", "application/json")
693 .send();
694
695 let response = match response {
696 Ok(response) => response,
697 Err(error) => {
698 return Ok((false, format!("connector status request failed: {error}")));
699 }
700 };
701
702 if !response.status().is_success() {
703 return Ok((
704 false,
705 format!("connector status returned HTTP {}", response.status()),
706 ));
707 }
708
709 let payload: Value = response
710 .json()
711 .map_err(|error| CoreError::Http(error.to_string()))?;
712 let connected = payload
713 .get("websocket")
714 .and_then(|value| value.get("connected"))
715 .and_then(Value::as_bool)
716 .unwrap_or(false);
717 if connected {
718 Ok((true, "connector websocket is connected".to_string()))
719 } else {
720 Ok((false, "connector websocket is disconnected".to_string()))
721 }
722}
723
724#[cfg(test)]
725mod tests {
726 use super::{all_providers, get_provider};
727
728 #[test]
729 fn provider_registry_has_expected_platforms() {
730 let names = all_providers()
731 .into_iter()
732 .map(|provider| provider.name().to_string())
733 .collect::<Vec<_>>();
734
735 assert_eq!(names, vec!["openclaw", "picoclaw", "nanobot", "nanoclaw"]);
736 }
737
738 #[test]
739 fn get_provider_matches_name_case_insensitively() {
740 assert_eq!(
741 get_provider("PicoClaw").map(|provider| provider.name().to_string()),
742 Some("picoclaw".to_string())
743 );
744 }
745}