Skip to main content

clawdentity_core/providers/
mod.rs

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    /// Provider name (e.g., "openclaw", "picoclaw", "nanobot", "nanoclaw")
26    fn name(&self) -> &str;
27
28    /// Human-readable display name
29    fn display_name(&self) -> &str;
30
31    /// Detect if this platform is installed on the current system
32    fn detect(&self) -> DetectionResult;
33
34    /// Format an inbound message for this platform's webhook
35    fn format_inbound(&self, message: &InboundMessage) -> InboundRequest;
36
37    /// Get the platform's default webhook port
38    fn default_webhook_port(&self) -> u16;
39
40    /// Get the platform's default webhook host
41    fn default_webhook_host(&self) -> &str {
42        "127.0.0.1"
43    }
44
45    /// Get config file path for this platform
46    fn config_path(&self) -> Option<PathBuf>;
47
48    /// Install/configure the webhook channel for this platform
49    fn install(&self, opts: &InstallOptions) -> Result<InstallResult>;
50
51    /// Verify the installation is working
52    fn verify(&self) -> Result<VerifyResult>;
53
54    /// Run provider-specific diagnostics.
55    fn doctor(&self, opts: &ProviderDoctorOptions) -> Result<ProviderDoctorResult>;
56
57    /// Persist provider-specific relay setup.
58    fn setup(&self, opts: &ProviderSetupOptions) -> Result<ProviderSetupResult>;
59
60    /// Send a provider-specific relay probe to validate webhook delivery.
61    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
218/// TODO(clawdentity): document `all_providers`.
219pub 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
228/// TODO(clawdentity): document `detect_platform`.
229pub 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
250/// TODO(clawdentity): document `get_provider`.
251pub 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}