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}