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}