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