Skip to main content

clawdentity_core/providers/openclaw/
relay_test.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use super::doctor::{
6    DoctorStatus, OpenclawDoctorOptions, OpenclawDoctorResult, run_openclaw_doctor,
7};
8use super::setup::{
9    read_selected_openclaw_agent, resolve_connector_base_url, resolve_openclaw_base_url,
10    resolve_openclaw_hook_token,
11};
12use crate::db::SqliteStore;
13use crate::error::{CoreError, Result};
14use crate::http::blocking_client;
15use crate::peers::load_peers_config;
16
17const OPENCLAW_SEND_TO_PEER_PATH: &str = "/hooks/send-to-peer";
18const STATUS_PATH: &str = "/v1/status";
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum RelayCheckStatus {
23    Success,
24    Failure,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct OpenclawRelayTestResult {
30    pub status: RelayCheckStatus,
31    pub checked_at: String,
32    pub peer_alias: String,
33    pub endpoint: String,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub http_status: Option<u16>,
36    pub message: String,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub remediation_hint: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub preflight: Option<OpenclawDoctorResult>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct OpenclawRelayWebsocketTestResult {
46    pub status: RelayCheckStatus,
47    pub checked_at: String,
48    pub peer_alias: String,
49    pub connector_status_url: String,
50    pub message: String,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub remediation_hint: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub preflight: Option<OpenclawDoctorResult>,
55}
56
57#[derive(Debug, Clone, Default)]
58pub struct OpenclawRelayTestOptions {
59    pub home_dir: Option<PathBuf>,
60    pub openclaw_dir: Option<PathBuf>,
61    pub peer_alias: Option<String>,
62    pub openclaw_base_url: Option<String>,
63    pub hook_token: Option<String>,
64    pub message: Option<String>,
65    pub session_id: Option<String>,
66    pub skip_preflight: bool,
67}
68
69#[derive(Debug, Clone, Default)]
70pub struct OpenclawRelayWebsocketTestOptions {
71    pub home_dir: Option<PathBuf>,
72    pub openclaw_dir: Option<PathBuf>,
73    pub peer_alias: Option<String>,
74    pub connector_base_url: Option<String>,
75    pub skip_preflight: bool,
76}
77
78fn now_iso() -> String {
79    chrono::Utc::now().to_rfc3339()
80}
81
82fn resolve_peer_alias(store: &SqliteStore, peer_alias: Option<&str>) -> Result<String> {
83    let peers = load_peers_config(store)?;
84    if peers.peers.is_empty() {
85        return Err(CoreError::InvalidInput(
86            "no paired peers found; complete pairing first".to_string(),
87        ));
88    }
89
90    if let Some(peer_alias) = peer_alias.map(str::trim).filter(|value| !value.is_empty()) {
91        if peers.peers.contains_key(peer_alias) {
92            return Ok(peer_alias.to_string());
93        }
94        return Err(CoreError::InvalidInput(format!(
95            "peer alias `{peer_alias}` is not configured"
96        )));
97    }
98
99    if peers.peers.len() == 1 {
100        return Ok(peers.peers.keys().next().cloned().unwrap_or_default());
101    }
102
103    Err(CoreError::InvalidInput(
104        "multiple peers are configured; pass a peer alias".to_string(),
105    ))
106}
107
108fn join_url(base_url: &str, path: &str, context: &'static str) -> Result<String> {
109    let normalized = if base_url.ends_with('/') {
110        base_url.to_string()
111    } else {
112        format!("{base_url}/")
113    };
114    let joined = url::Url::parse(&normalized)
115        .map_err(|_| CoreError::InvalidUrl {
116            context,
117            value: base_url.to_string(),
118        })?
119        .join(path.trim_start_matches('/'))
120        .map_err(|_| CoreError::InvalidUrl {
121            context,
122            value: base_url.to_string(),
123        })?;
124    Ok(joined.to_string())
125}
126
127fn map_probe_failure(status: u16) -> (&'static str, &'static str) {
128    match status {
129        401 | 403 => (
130            "OpenClaw hook token was rejected",
131            "Provide a valid hook token with --hook-token or OPENCLAW_HOOK_TOKEN.",
132        ),
133        404 => (
134            "OpenClaw send-to-peer hook is unavailable",
135            "Run `clawdentity install --for openclaw` and `clawdentity provider setup --for openclaw --agent-name <agentName>` to install hook mapping.",
136        ),
137        500 => (
138            "Relay probe failed inside local relay pipeline",
139            "Verify peer pairing and restart OpenClaw + connector runtime.",
140        ),
141        _ => (
142            "Relay probe failed",
143            "Check OpenClaw and connector logs for request failure details.",
144        ),
145    }
146}
147
148fn run_preflight(
149    config_dir: &Path,
150    store: &SqliteStore,
151    home_dir: Option<PathBuf>,
152    openclaw_dir: Option<PathBuf>,
153) -> Result<OpenclawDoctorResult> {
154    run_openclaw_doctor(
155        config_dir,
156        store,
157        OpenclawDoctorOptions {
158            home_dir,
159            openclaw_dir,
160            include_connector_runtime_check: false,
161            ..OpenclawDoctorOptions::default()
162        },
163    )
164}
165
166pub fn run_openclaw_relay_test(
167    config_dir: &Path,
168    store: &SqliteStore,
169    options: OpenclawRelayTestOptions,
170) -> Result<OpenclawRelayTestResult> {
171    let checked_at = now_iso();
172    let peer_alias = resolve_peer_alias(store, options.peer_alias.as_deref())?;
173    let preflight = if options.skip_preflight {
174        None
175    } else {
176        Some(run_preflight(
177            config_dir,
178            store,
179            options.home_dir.clone(),
180            options.openclaw_dir.clone(),
181        )?)
182    };
183    if preflight.as_ref().map(|result| &result.status) == Some(&DoctorStatus::Unhealthy) {
184        return Ok(OpenclawRelayTestResult {
185            status: RelayCheckStatus::Failure,
186            checked_at,
187            peer_alias,
188            endpoint: OPENCLAW_SEND_TO_PEER_PATH.to_string(),
189            http_status: None,
190            message: "Preflight checks failed".to_string(),
191            remediation_hint: Some(
192                "Run `clawdentity provider doctor --for openclaw` and resolve failed checks."
193                    .to_string(),
194            ),
195            preflight,
196        });
197    }
198
199    let openclaw_base_url =
200        resolve_openclaw_base_url(config_dir, options.openclaw_base_url.as_deref())?;
201    let endpoint = join_url(
202        &openclaw_base_url,
203        OPENCLAW_SEND_TO_PEER_PATH,
204        "openclawBaseUrl",
205    )?;
206    let hook_token = resolve_openclaw_hook_token(config_dir, options.hook_token.as_deref())?;
207    let session_id = options
208        .session_id
209        .as_deref()
210        .map(str::trim)
211        .filter(|value| !value.is_empty())
212        .map(ToOwned::to_owned)
213        .unwrap_or_else(|| format!("clawdentity-probe-{}", chrono::Utc::now().timestamp()));
214    let message = options
215        .message
216        .as_deref()
217        .map(str::trim)
218        .filter(|value| !value.is_empty())
219        .map(ToOwned::to_owned)
220        .unwrap_or_else(|| "clawdentity relay probe".to_string());
221
222    let mut request = blocking_client()?
223        .post(&endpoint)
224        .header("content-type", "application/json")
225        .json(&serde_json::json!({
226            "peer": peer_alias,
227            "sessionId": session_id,
228            "message": message,
229        }));
230    if let Some(token) = hook_token {
231        request = request.header("x-openclaw-token", token);
232    }
233    let response = request
234        .send()
235        .map_err(|error| CoreError::Http(error.to_string()))?;
236
237    if response.status().is_success() {
238        return Ok(OpenclawRelayTestResult {
239            status: RelayCheckStatus::Success,
240            checked_at,
241            peer_alias: resolve_peer_alias(store, options.peer_alias.as_deref())?,
242            endpoint,
243            http_status: Some(response.status().as_u16()),
244            message: "Relay probe accepted".to_string(),
245            remediation_hint: None,
246            preflight,
247        });
248    }
249
250    let status = response.status().as_u16();
251    let (message, remediation_hint) = map_probe_failure(status);
252    Ok(OpenclawRelayTestResult {
253        status: RelayCheckStatus::Failure,
254        checked_at,
255        peer_alias: resolve_peer_alias(store, options.peer_alias.as_deref())?,
256        endpoint,
257        http_status: Some(status),
258        message: message.to_string(),
259        remediation_hint: Some(remediation_hint.to_string()),
260        preflight,
261    })
262}
263
264pub fn run_openclaw_relay_websocket_test(
265    config_dir: &Path,
266    store: &SqliteStore,
267    options: OpenclawRelayWebsocketTestOptions,
268) -> Result<OpenclawRelayWebsocketTestResult> {
269    let checked_at = now_iso();
270    let peer_alias = resolve_peer_alias(store, options.peer_alias.as_deref())?;
271    let preflight = if options.skip_preflight {
272        None
273    } else {
274        Some(run_preflight(
275            config_dir,
276            store,
277            options.home_dir.clone(),
278            options.openclaw_dir.clone(),
279        )?)
280    };
281    if preflight.as_ref().map(|result| &result.status) == Some(&DoctorStatus::Unhealthy) {
282        return Ok(OpenclawRelayWebsocketTestResult {
283            status: RelayCheckStatus::Failure,
284            checked_at,
285            peer_alias,
286            connector_status_url: STATUS_PATH.to_string(),
287            message: "Preflight checks failed".to_string(),
288            remediation_hint: Some(
289                "Run `clawdentity provider doctor --for openclaw` and resolve failed checks."
290                    .to_string(),
291            ),
292            preflight,
293        });
294    }
295
296    let selected_agent = read_selected_openclaw_agent(config_dir)?;
297    let connector_base_url = resolve_connector_base_url(
298        config_dir,
299        selected_agent.as_deref(),
300        options.connector_base_url.as_deref(),
301    )?
302    .ok_or_else(|| {
303        CoreError::InvalidInput(
304            "connector base URL is not configured; run clawdentity provider setup --for openclaw first"
305                .to_string(),
306        )
307    })?;
308    let connector_status_url = join_url(&connector_base_url, STATUS_PATH, "connectorBaseUrl")?;
309    let response = blocking_client()?
310        .get(&connector_status_url)
311        .header("accept", "application/json")
312        .send()
313        .map_err(|error| CoreError::Http(error.to_string()))?;
314
315    if !response.status().is_success() {
316        return Ok(OpenclawRelayWebsocketTestResult {
317            status: RelayCheckStatus::Failure,
318            checked_at,
319            peer_alias,
320            connector_status_url,
321            message: format!(
322                "Connector status endpoint returned HTTP {}",
323                response.status()
324            ),
325            remediation_hint: Some("Start connector runtime and retry websocket test.".to_string()),
326            preflight,
327        });
328    }
329
330    let payload: serde_json::Value = response
331        .json()
332        .map_err(|error| CoreError::Http(error.to_string()))?;
333    let connected = payload
334        .get("websocket")
335        .and_then(|value| value.get("connected"))
336        .and_then(serde_json::Value::as_bool)
337        .unwrap_or(false);
338    if connected {
339        return Ok(OpenclawRelayWebsocketTestResult {
340            status: RelayCheckStatus::Success,
341            checked_at,
342            peer_alias,
343            connector_status_url,
344            message: "Connector websocket is connected for paired relay".to_string(),
345            remediation_hint: None,
346            preflight,
347        });
348    }
349
350    Ok(OpenclawRelayWebsocketTestResult {
351        status: RelayCheckStatus::Failure,
352        checked_at,
353        peer_alias,
354        connector_status_url,
355        message: "Connector websocket is disconnected".to_string(),
356        remediation_hint: Some(
357            "Run `connector start <agentName>` or reinstall connector service.".to_string(),
358        ),
359        preflight,
360    })
361}
362
363#[cfg(test)]
364mod tests {
365    use tempfile::TempDir;
366    use wiremock::matchers::{method, path};
367    use wiremock::{Mock, MockServer, ResponseTemplate};
368
369    use crate::db::SqliteStore;
370    use crate::peers::{PersistPeerInput, persist_peer};
371
372    use super::{
373        OpenclawRelayTestOptions, OpenclawRelayWebsocketTestOptions, RelayCheckStatus,
374        run_openclaw_relay_test, run_openclaw_relay_websocket_test,
375    };
376
377    fn seed_peer(store: &SqliteStore) {
378        let _ = persist_peer(
379            store,
380            PersistPeerInput {
381                alias: Some("peer-alpha".to_string()),
382                did: "did:cdi:registry.clawdentity.com:agent:01HF7YAT00W6W7CM7N3W5FDXT4"
383                    .to_string(),
384                proxy_url: "https://proxy.example/hooks/agent".to_string(),
385                agent_name: Some("alpha".to_string()),
386                human_name: Some("alice".to_string()),
387            },
388        )
389        .expect("peer");
390    }
391
392    #[tokio::test]
393    async fn relay_test_returns_success_for_accepted_probe() {
394        let server = MockServer::start().await;
395        Mock::given(method("POST"))
396            .and(path("/hooks/send-to-peer"))
397            .respond_with(ResponseTemplate::new(202))
398            .mount(&server)
399            .await;
400
401        let temp = TempDir::new().expect("temp dir");
402        let config_dir = temp.path().join("state");
403        std::fs::create_dir_all(&config_dir).expect("state dir");
404        let store = SqliteStore::open_path(temp.path().join("db.sqlite3")).expect("db");
405        seed_peer(&store);
406
407        let relay_config_dir = config_dir.clone();
408        let relay_store = store.clone();
409        let result = tokio::task::spawn_blocking(move || {
410            run_openclaw_relay_test(
411                &relay_config_dir,
412                &relay_store,
413                OpenclawRelayTestOptions {
414                    peer_alias: Some("peer-alpha".to_string()),
415                    openclaw_base_url: Some(server.uri()),
416                    skip_preflight: true,
417                    ..OpenclawRelayTestOptions::default()
418                },
419            )
420        })
421        .await
422        .expect("join")
423        .expect("relay test");
424        assert_eq!(result.status, RelayCheckStatus::Success);
425    }
426
427    #[tokio::test]
428    async fn relay_websocket_test_reports_connected_status() {
429        let server = MockServer::start().await;
430        Mock::given(method("GET"))
431            .and(path("/v1/status"))
432            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
433                "websocket": { "connected": true }
434            })))
435            .mount(&server)
436            .await;
437
438        let temp = TempDir::new().expect("temp dir");
439        let config_dir = temp.path().join("state");
440        std::fs::create_dir_all(&config_dir).expect("state dir");
441        let store = SqliteStore::open_path(temp.path().join("db.sqlite3")).expect("db");
442        seed_peer(&store);
443
444        let ws_config_dir = config_dir.clone();
445        let ws_store = store.clone();
446        let result = tokio::task::spawn_blocking(move || {
447            run_openclaw_relay_websocket_test(
448                &ws_config_dir,
449                &ws_store,
450                OpenclawRelayWebsocketTestOptions {
451                    peer_alias: Some("peer-alpha".to_string()),
452                    connector_base_url: Some(server.uri()),
453                    skip_preflight: true,
454                    ..OpenclawRelayWebsocketTestOptions::default()
455                },
456            )
457        })
458        .await
459        .expect("join")
460        .expect("ws test");
461        assert_eq!(result.status, RelayCheckStatus::Success);
462    }
463}