Skip to main content

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