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, opts: &VerifyOptions) -> 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 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
224/// TODO(clawdentity): document `all_providers`.
225pub 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
234/// TODO(clawdentity): document `detect_platform`.
235pub 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
256/// TODO(clawdentity): document `get_provider`.
257pub 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}