Skip to main content

clawdentity_core/providers/
nanobot.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde_json::json;
5
6use crate::error::Result;
7use crate::http::blocking_client;
8use crate::provider::{
9    DetectionResult, InboundMessage, InboundRequest, InstallOptions, InstallResult,
10    PlatformProvider, ProviderDoctorCheckStatus, ProviderDoctorOptions, ProviderDoctorResult,
11    ProviderRelayRuntimeConfig, ProviderRelayTestOptions, ProviderRelayTestResult,
12    ProviderRelayTestStatus, ProviderSetupOptions, ProviderSetupResult, VerifyResult,
13    check_connector_runtime, command_exists, default_webhook_url, doctor_status_from_checks,
14    ensure_json_object_path, health_check, join_url_path, load_provider_runtime_config, now_iso,
15    push_doctor_check, read_json_or_default, read_provider_agent_marker, read_text,
16    resolve_home_dir_with_fallback, resolve_state_dir, save_provider_runtime_config,
17    upsert_marked_block, write_json, write_provider_agent_marker, write_text,
18};
19
20const PROVIDER_NAME: &str = "nanobot";
21const PROVIDER_DISPLAY_NAME: &str = "NanoBot";
22const NANOBOT_DIR_NAME: &str = ".nanobot";
23const NANOBOT_CONFIG_YAML_FILE_NAME: &str = "config.yaml";
24const NANOBOT_CONFIG_JSON_FILE_NAME: &str = "config.json";
25const NANOBOT_BINARY: &str = "nanobot";
26const NANOBOT_WEBHOOK_PATH: &str = "/v1/inbound";
27const NANOBOT_MARKER_START: &str = "# >>> clawdentity nanobot webhook >>>";
28const NANOBOT_MARKER_END: &str = "# <<< clawdentity nanobot webhook <<<";
29
30#[derive(Debug, Clone, Default)]
31pub struct NanobotProvider {
32    home_dir_override: Option<PathBuf>,
33    path_override: Option<Vec<PathBuf>>,
34}
35
36impl NanobotProvider {
37    fn resolve_home_dir(&self) -> Option<PathBuf> {
38        self.home_dir_override.clone().or_else(dirs::home_dir)
39    }
40
41    fn config_path_from_home(home_dir: &Path) -> PathBuf {
42        let dir = home_dir.join(NANOBOT_DIR_NAME);
43        let yaml_path = dir.join(NANOBOT_CONFIG_YAML_FILE_NAME);
44        if yaml_path.exists() {
45            return yaml_path;
46        }
47
48        let json_path = dir.join(NANOBOT_CONFIG_JSON_FILE_NAME);
49        if json_path.exists() {
50            return json_path;
51        }
52
53        yaml_path
54    }
55
56    fn install_home_dir(&self, opts: &InstallOptions) -> Result<PathBuf> {
57        resolve_home_dir_with_fallback(opts.home_dir.as_deref(), self.home_dir_override.as_deref())
58    }
59
60    fn resolve_webhook_url(&self, opts: &InstallOptions) -> Result<String> {
61        if let Some(connector_url) = opts
62            .connector_url
63            .as_deref()
64            .map(str::trim)
65            .filter(|value| !value.is_empty())
66        {
67            return join_url_path(connector_url, NANOBOT_WEBHOOK_PATH, "connectorUrl");
68        }
69
70        let host = opts
71            .webhook_host
72            .as_deref()
73            .unwrap_or(self.default_webhook_host());
74        let port = opts.webhook_port.unwrap_or(self.default_webhook_port());
75        default_webhook_url(host, port, NANOBOT_WEBHOOK_PATH)
76    }
77
78    #[cfg(test)]
79    fn with_test_context(home_dir: PathBuf, path_override: Vec<PathBuf>) -> Self {
80        Self {
81            home_dir_override: Some(home_dir),
82            path_override: Some(path_override),
83        }
84    }
85}
86
87impl PlatformProvider for NanobotProvider {
88    fn name(&self) -> &str {
89        PROVIDER_NAME
90    }
91
92    fn display_name(&self) -> &str {
93        PROVIDER_DISPLAY_NAME
94    }
95
96    fn detect(&self) -> DetectionResult {
97        let mut evidence = Vec::new();
98        let mut confidence: f32 = 0.0;
99
100        if let Some(home_dir) = self.resolve_home_dir() {
101            let nanobot_dir = home_dir.join(NANOBOT_DIR_NAME);
102            if nanobot_dir.is_dir() {
103                evidence.push(format!("found {}/", nanobot_dir.display()));
104                confidence += 0.6;
105            }
106
107            let yaml_path = nanobot_dir.join(NANOBOT_CONFIG_YAML_FILE_NAME);
108            if yaml_path.is_file() {
109                evidence.push(format!("found {}", yaml_path.display()));
110                confidence += 0.05;
111            }
112
113            let json_path = nanobot_dir.join(NANOBOT_CONFIG_JSON_FILE_NAME);
114            if json_path.is_file() {
115                evidence.push(format!("found {}", json_path.display()));
116                confidence += 0.05;
117            }
118        }
119
120        if command_exists(NANOBOT_BINARY, self.path_override.as_deref()) {
121            evidence.push("nanobot binary in PATH".to_string());
122            confidence += 0.3;
123        }
124
125        DetectionResult {
126            detected: confidence > 0.0,
127            confidence: confidence.min(1.0),
128            evidence,
129        }
130    }
131
132    fn format_inbound(&self, message: &InboundMessage) -> InboundRequest {
133        InboundRequest {
134            headers: HashMap::new(),
135            body: json!({
136                "userId": message.sender_did,
137                "content": message.content,
138            }),
139        }
140    }
141
142    fn default_webhook_port(&self) -> u16 {
143        18794
144    }
145
146    fn config_path(&self) -> Option<PathBuf> {
147        self.resolve_home_dir()
148            .map(|home_dir| Self::config_path_from_home(&home_dir))
149    }
150
151    #[allow(clippy::too_many_lines)]
152    fn install(&self, opts: &InstallOptions) -> Result<InstallResult> {
153        let home_dir = self.install_home_dir(opts)?;
154        let config_path = Self::config_path_from_home(&home_dir);
155        let webhook_url = self.resolve_webhook_url(opts)?;
156
157        let config_extension = config_path
158            .extension()
159            .and_then(|value| value.to_str())
160            .unwrap_or_default();
161
162        if config_extension.eq_ignore_ascii_case("json") {
163            let mut config = read_json_or_default(&config_path)?;
164            let clawdentity = ensure_json_object_path(&mut config, &["clawdentity"])?;
165            clawdentity.insert("provider".to_string(), json!(PROVIDER_NAME));
166            clawdentity.insert(
167                "webhook".to_string(),
168                json!({
169                    "enabled": true,
170                    "url": webhook_url,
171                    "host": opts
172                        .webhook_host
173                        .as_deref()
174                        .unwrap_or(self.default_webhook_host()),
175                    "port": opts.webhook_port.unwrap_or(self.default_webhook_port()),
176                    "token": opts.webhook_token,
177                    "connectorUrl": opts.connector_url,
178                }),
179            );
180            write_json(&config_path, &config)?;
181        } else {
182            let existing = read_text(&config_path)?.unwrap_or_default();
183            let block = format!(
184                "{NANOBOT_MARKER_START}\nclawdentity:\n  provider: {PROVIDER_NAME}\n  webhook:\n    enabled: true\n    url: \"{webhook_url}\"\n    host: \"{}\"\n    port: {}\n{}{}\n{NANOBOT_MARKER_END}\n",
185                opts.webhook_host
186                    .as_deref()
187                    .unwrap_or(self.default_webhook_host()),
188                opts.webhook_port.unwrap_or(self.default_webhook_port()),
189                opts.webhook_token
190                    .as_deref()
191                    .map(|token| format!("    token: \"{token}\"\n"))
192                    .unwrap_or_default(),
193                opts.connector_url
194                    .as_deref()
195                    .map(|connector_url| { format!("    connectorUrl: \"{connector_url}\"\n") })
196                    .unwrap_or_default()
197            );
198            let merged =
199                upsert_marked_block(&existing, NANOBOT_MARKER_START, NANOBOT_MARKER_END, &block);
200            write_text(&config_path, &merged)?;
201        }
202
203        Ok(InstallResult {
204            platform: self.name().to_string(),
205            config_updated: true,
206            service_installed: false,
207            notes: vec![format!("updated {}", config_path.display())],
208        })
209    }
210
211    fn verify(&self) -> Result<VerifyResult> {
212        let (healthy, detail) =
213            health_check(self.default_webhook_host(), self.default_webhook_port())?;
214        Ok(VerifyResult {
215            healthy,
216            checks: vec![("health".to_string(), healthy, detail)],
217        })
218    }
219
220    #[allow(clippy::too_many_lines)]
221    fn doctor(&self, opts: &ProviderDoctorOptions) -> Result<ProviderDoctorResult> {
222        let mut checks = Vec::new();
223        let state_dir =
224            resolve_state_dir(opts.home_dir.clone().or(self.home_dir_override.clone()))?;
225
226        let config_path = self.config_path();
227        if let Some(config_path) = config_path {
228            if config_path.exists() {
229                push_doctor_check(
230                    &mut checks,
231                    "config.exists",
232                    "Config file",
233                    ProviderDoctorCheckStatus::Pass,
234                    format!("found {}", config_path.display()),
235                    None,
236                    None,
237                );
238            } else {
239                push_doctor_check(
240                    &mut checks,
241                    "config.exists",
242                    "Config file",
243                    ProviderDoctorCheckStatus::Fail,
244                    format!("missing {}", config_path.display()),
245                    Some("Run `clawdentity provider setup --for nanobot`.".to_string()),
246                    None,
247                );
248            }
249        }
250
251        let binary_found = command_exists(NANOBOT_BINARY, self.path_override.as_deref());
252        push_doctor_check(
253            &mut checks,
254            "binary.path",
255            "Provider binary",
256            if binary_found {
257                ProviderDoctorCheckStatus::Pass
258            } else {
259                ProviderDoctorCheckStatus::Fail
260            },
261            if binary_found {
262                "nanobot binary found in PATH".to_string()
263            } else {
264                "nanobot binary not found in PATH".to_string()
265            },
266            if binary_found {
267                None
268            } else {
269                Some("Install NanoBot and ensure `nanobot` is in PATH.".to_string())
270            },
271            None,
272        );
273
274        let (webhook_ok, webhook_detail) =
275            health_check(self.default_webhook_host(), self.default_webhook_port())?;
276        push_doctor_check(
277            &mut checks,
278            "webhook.health",
279            "Webhook endpoint",
280            if webhook_ok {
281                ProviderDoctorCheckStatus::Pass
282            } else {
283                ProviderDoctorCheckStatus::Fail
284            },
285            webhook_detail,
286            if webhook_ok {
287                None
288            } else {
289                Some("Start local webhook runtime and verify configured port.".to_string())
290            },
291            None,
292        );
293
294        let runtime = load_provider_runtime_config(&state_dir, self.name())?;
295        match read_provider_agent_marker(&state_dir, self.name())? {
296            Some(agent_name) => push_doctor_check(
297                &mut checks,
298                "state.selectedAgent",
299                "Selected agent",
300                ProviderDoctorCheckStatus::Pass,
301                format!("selected agent is `{agent_name}`"),
302                None,
303                None,
304            ),
305            None => push_doctor_check(
306                &mut checks,
307                "state.selectedAgent",
308                "Selected agent",
309                ProviderDoctorCheckStatus::Fail,
310                "selected agent marker is missing".to_string(),
311                Some("Run provider setup and choose an agent name.".to_string()),
312                None,
313            ),
314        }
315        let connector_base_url = opts.connector_base_url.clone().or_else(|| {
316            runtime
317                .as_ref()
318                .and_then(|cfg| cfg.connector_base_url.clone())
319        });
320        if opts.include_connector_runtime_check {
321            if let Some(connector_base_url) = connector_base_url {
322                let (connected, detail) = check_connector_runtime(&connector_base_url)?;
323                push_doctor_check(
324                    &mut checks,
325                    "connector.runtime",
326                    "Connector runtime",
327                    if connected {
328                        ProviderDoctorCheckStatus::Pass
329                    } else {
330                        ProviderDoctorCheckStatus::Fail
331                    },
332                    detail,
333                    if connected {
334                        None
335                    } else {
336                        Some("Start connector runtime and retry provider doctor.".to_string())
337                    },
338                    Some(serde_json::json!({ "connectorBaseUrl": connector_base_url })),
339                );
340            } else {
341                push_doctor_check(
342                    &mut checks,
343                    "connector.runtime",
344                    "Connector runtime",
345                    ProviderDoctorCheckStatus::Fail,
346                    "connector base URL is not configured".to_string(),
347                    Some("Run setup with `--connector-base-url` or pass it to doctor.".to_string()),
348                    None,
349                );
350            }
351        }
352
353        Ok(ProviderDoctorResult {
354            platform: self.name().to_string(),
355            status: doctor_status_from_checks(&checks),
356            checks,
357        })
358    }
359
360    fn setup(&self, opts: &ProviderSetupOptions) -> Result<ProviderSetupResult> {
361        let install_options = InstallOptions {
362            home_dir: opts.home_dir.clone(),
363            webhook_port: opts.webhook_port,
364            webhook_host: opts.webhook_host.clone(),
365            webhook_token: opts.webhook_token.clone(),
366            connector_url: opts
367                .connector_url
368                .clone()
369                .or_else(|| opts.connector_base_url.clone()),
370        };
371        let install_result = self.install(&install_options)?;
372        let state_dir =
373            resolve_state_dir(opts.home_dir.clone().or(self.home_dir_override.clone()))?;
374        let agent_name = opts
375            .agent_name
376            .as_deref()
377            .map(str::trim)
378            .filter(|value| !value.is_empty())
379            .unwrap_or("default");
380        let marker_path = write_provider_agent_marker(&state_dir, self.name(), agent_name)?;
381        let webhook_endpoint = self.resolve_webhook_url(&install_options)?;
382        let runtime_path = save_provider_runtime_config(
383            &state_dir,
384            self.name(),
385            ProviderRelayRuntimeConfig {
386                webhook_endpoint,
387                connector_base_url: opts.connector_base_url.clone(),
388                webhook_token: opts.webhook_token.clone(),
389                platform_base_url: opts.platform_base_url.clone(),
390                relay_transform_peers_path: opts.relay_transform_peers_path.clone(),
391                updated_at: now_iso(),
392            },
393        )?;
394
395        let mut notes = install_result.notes;
396        notes.push(format!("saved selected agent marker `{agent_name}`"));
397        notes.push("saved provider relay runtime".to_string());
398        Ok(ProviderSetupResult {
399            platform: self.name().to_string(),
400            notes,
401            updated_paths: vec![
402                marker_path.display().to_string(),
403                runtime_path.display().to_string(),
404            ],
405        })
406    }
407
408    #[allow(clippy::too_many_lines)]
409    fn relay_test(&self, opts: &ProviderRelayTestOptions) -> Result<ProviderRelayTestResult> {
410        let checked_at = now_iso();
411        let state_dir =
412            resolve_state_dir(opts.home_dir.clone().or(self.home_dir_override.clone()))?;
413        let runtime = load_provider_runtime_config(&state_dir, self.name())?;
414
415        let preflight = if opts.skip_preflight {
416            None
417        } else {
418            Some(self.doctor(&ProviderDoctorOptions {
419                home_dir: opts.home_dir.clone(),
420                platform_state_dir: opts.platform_state_dir.clone(),
421                selected_agent: None,
422                peer_alias: opts.peer_alias.clone(),
423                connector_base_url: opts.connector_base_url.clone(),
424                include_connector_runtime_check: true,
425            })?)
426        };
427        if preflight
428            .as_ref()
429            .map(|result| result.status == crate::provider::ProviderDoctorStatus::Unhealthy)
430            .unwrap_or(false)
431        {
432            return Ok(ProviderRelayTestResult {
433                platform: self.name().to_string(),
434                status: ProviderRelayTestStatus::Failure,
435                checked_at,
436                endpoint: runtime
437                    .as_ref()
438                    .map(|cfg| cfg.webhook_endpoint.clone())
439                    .unwrap_or_else(|| "unknown".to_string()),
440                peer_alias: opts.peer_alias.clone(),
441                http_status: None,
442                message: "Preflight checks failed".to_string(),
443                remediation_hint: Some(
444                    "Run provider doctor and resolve failed checks.".to_string(),
445                ),
446                preflight,
447                details: None,
448            });
449        }
450
451        let endpoint = if let Some(runtime) = runtime {
452            runtime.webhook_endpoint
453        } else {
454            self.resolve_webhook_url(&InstallOptions {
455                home_dir: opts.home_dir.clone(),
456                webhook_port: None,
457                webhook_host: None,
458                webhook_token: None,
459                connector_url: opts.connector_base_url.clone(),
460            })?
461        };
462
463        let message = opts
464            .message
465            .as_deref()
466            .map(str::trim)
467            .filter(|value| !value.is_empty())
468            .unwrap_or("clawdentity relay probe");
469        let session_id = opts
470            .session_id
471            .as_deref()
472            .map(str::trim)
473            .filter(|value| !value.is_empty())
474            .map(ToOwned::to_owned)
475            .unwrap_or_else(|| {
476                format!(
477                    "clawdentity-nanobot-probe-{}",
478                    chrono::Utc::now().timestamp()
479                )
480            });
481
482        let mut request = blocking_client()?
483            .post(&endpoint)
484            .header("content-type", "application/json")
485            .json(&serde_json::json!({
486                "provider": self.name(),
487                "sessionId": session_id,
488                "message": message,
489                "peer": opts.peer_alias,
490            }));
491        if let Some(token) = opts
492            .webhook_token
493            .as_deref()
494            .map(str::trim)
495            .filter(|value| !value.is_empty())
496        {
497            request = request.header("x-clawdentity-token", token);
498        }
499
500        let response = match request.send() {
501            Ok(response) => response,
502            Err(error) => {
503                return Ok(ProviderRelayTestResult {
504                    platform: self.name().to_string(),
505                    status: ProviderRelayTestStatus::Failure,
506                    checked_at,
507                    endpoint,
508                    peer_alias: opts.peer_alias.clone(),
509                    http_status: None,
510                    message: format!("relay probe request failed: {error}"),
511                    remediation_hint: Some(
512                        "Verify webhook endpoint is running and reachable from this machine."
513                            .to_string(),
514                    ),
515                    preflight,
516                    details: None,
517                });
518            }
519        };
520
521        let status = response.status().as_u16();
522        if response.status().is_success() {
523            Ok(ProviderRelayTestResult {
524                platform: self.name().to_string(),
525                status: ProviderRelayTestStatus::Success,
526                checked_at,
527                endpoint,
528                peer_alias: opts.peer_alias.clone(),
529                http_status: Some(status),
530                message: "relay probe accepted".to_string(),
531                remediation_hint: None,
532                preflight,
533                details: None,
534            })
535        } else {
536            Ok(ProviderRelayTestResult {
537                platform: self.name().to_string(),
538                status: ProviderRelayTestStatus::Failure,
539                checked_at,
540                endpoint,
541                peer_alias: opts.peer_alias.clone(),
542                http_status: Some(status),
543                message: format!("relay probe returned HTTP {status}"),
544                remediation_hint: Some(
545                    "Check provider webhook configuration and connector runtime.".to_string(),
546                ),
547                preflight,
548                details: None,
549            })
550        }
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use std::collections::HashMap;
557
558    use tempfile::TempDir;
559
560    use crate::provider::{InboundMessage, PlatformProvider};
561
562    use super::{NANOBOT_CONFIG_YAML_FILE_NAME, NANOBOT_DIR_NAME, NanobotProvider};
563
564    #[test]
565    fn detection_checks_home_and_path() {
566        let home = TempDir::new().expect("temp home");
567        let nanobot_dir = home.path().join(NANOBOT_DIR_NAME);
568        std::fs::create_dir_all(&nanobot_dir).expect("nanobot dir");
569        std::fs::write(nanobot_dir.join(NANOBOT_CONFIG_YAML_FILE_NAME), "{}\n").expect("config");
570
571        let bin_dir = TempDir::new().expect("temp bin");
572        std::fs::write(bin_dir.path().join("nanobot"), "#!/bin/sh\n").expect("binary");
573
574        let provider = NanobotProvider::with_test_context(
575            home.path().to_path_buf(),
576            vec![bin_dir.path().to_path_buf()],
577        );
578        let detection = provider.detect();
579
580        assert!(detection.detected);
581        assert!(detection.confidence > 0.8);
582        assert!(
583            detection
584                .evidence
585                .iter()
586                .any(|entry| entry.contains("nanobot binary in PATH"))
587        );
588    }
589
590    #[test]
591    fn format_inbound_uses_body_payload_shape() {
592        let provider = NanobotProvider::default();
593
594        let request = provider.format_inbound(&InboundMessage {
595            sender_did: "did:cdi:registry.clawdentity.com:agent:01HF7YAT00W6W7CM7N3W5FDXTB"
596                .to_string(),
597            recipient_did: "did:cdi:registry.clawdentity.com:agent:01HF7YAT00W6W7CM7N3W5FDXTC"
598                .to_string(),
599            content: "hello".to_string(),
600            request_id: Some("req-123".to_string()),
601            metadata: HashMap::new(),
602        });
603
604        assert!(request.headers.is_empty());
605        assert_eq!(
606            request.body.get("userId").and_then(|value| value.as_str()),
607            Some("did:cdi:registry.clawdentity.com:agent:01HF7YAT00W6W7CM7N3W5FDXTB")
608        );
609        assert_eq!(
610            request.body.get("content").and_then(|value| value.as_str()),
611            Some("hello")
612        );
613    }
614
615    #[test]
616    fn config_path_defaults_to_nanobot_yaml() {
617        let home = TempDir::new().expect("temp home");
618        let provider = NanobotProvider::with_test_context(home.path().to_path_buf(), Vec::new());
619
620        assert_eq!(
621            provider.config_path(),
622            Some(
623                home.path()
624                    .join(NANOBOT_DIR_NAME)
625                    .join(NANOBOT_CONFIG_YAML_FILE_NAME)
626            )
627        );
628    }
629}