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 `openclaw setup <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("Run `openclaw doctor` and resolve failed checks.".to_string()),
192            preflight,
193        });
194    }
195
196    let openclaw_base_url =
197        resolve_openclaw_base_url(config_dir, options.openclaw_base_url.as_deref())?;
198    let endpoint = join_url(
199        &openclaw_base_url,
200        OPENCLAW_SEND_TO_PEER_PATH,
201        "openclawBaseUrl",
202    )?;
203    let hook_token = resolve_openclaw_hook_token(config_dir, options.hook_token.as_deref())?;
204    let session_id = options
205        .session_id
206        .as_deref()
207        .map(str::trim)
208        .filter(|value| !value.is_empty())
209        .map(ToOwned::to_owned)
210        .unwrap_or_else(|| format!("clawdentity-probe-{}", chrono::Utc::now().timestamp()));
211    let message = options
212        .message
213        .as_deref()
214        .map(str::trim)
215        .filter(|value| !value.is_empty())
216        .map(ToOwned::to_owned)
217        .unwrap_or_else(|| "clawdentity relay probe".to_string());
218
219    let mut request = blocking_client()?
220        .post(&endpoint)
221        .header("content-type", "application/json")
222        .json(&serde_json::json!({
223            "peer": peer_alias,
224            "sessionId": session_id,
225            "message": message,
226        }));
227    if let Some(token) = hook_token {
228        request = request.header("x-openclaw-token", token);
229    }
230    let response = request
231        .send()
232        .map_err(|error| CoreError::Http(error.to_string()))?;
233
234    if response.status().is_success() {
235        return Ok(OpenclawRelayTestResult {
236            status: RelayCheckStatus::Success,
237            checked_at,
238            peer_alias: resolve_peer_alias(store, options.peer_alias.as_deref())?,
239            endpoint,
240            http_status: Some(response.status().as_u16()),
241            message: "Relay probe accepted".to_string(),
242            remediation_hint: None,
243            preflight,
244        });
245    }
246
247    let status = response.status().as_u16();
248    let (message, remediation_hint) = map_probe_failure(status);
249    Ok(OpenclawRelayTestResult {
250        status: RelayCheckStatus::Failure,
251        checked_at,
252        peer_alias: resolve_peer_alias(store, options.peer_alias.as_deref())?,
253        endpoint,
254        http_status: Some(status),
255        message: message.to_string(),
256        remediation_hint: Some(remediation_hint.to_string()),
257        preflight,
258    })
259}
260
261pub fn run_openclaw_relay_websocket_test(
262    config_dir: &Path,
263    store: &SqliteStore,
264    options: OpenclawRelayWebsocketTestOptions,
265) -> Result<OpenclawRelayWebsocketTestResult> {
266    let checked_at = now_iso();
267    let peer_alias = resolve_peer_alias(store, options.peer_alias.as_deref())?;
268    let preflight = if options.skip_preflight {
269        None
270    } else {
271        Some(run_preflight(
272            config_dir,
273            store,
274            options.home_dir.clone(),
275            options.openclaw_dir.clone(),
276        )?)
277    };
278    if preflight.as_ref().map(|result| &result.status) == Some(&DoctorStatus::Unhealthy) {
279        return Ok(OpenclawRelayWebsocketTestResult {
280            status: RelayCheckStatus::Failure,
281            checked_at,
282            peer_alias,
283            connector_status_url: STATUS_PATH.to_string(),
284            message: "Preflight checks failed".to_string(),
285            remediation_hint: Some("Run `openclaw doctor` and resolve failed checks.".to_string()),
286            preflight,
287        });
288    }
289
290    let selected_agent = read_selected_openclaw_agent(config_dir)?;
291    let connector_base_url = resolve_connector_base_url(
292        config_dir,
293        selected_agent.as_deref(),
294        options.connector_base_url.as_deref(),
295    )?
296    .ok_or_else(|| {
297        CoreError::InvalidInput(
298            "connector base URL is not configured; run openclaw setup first".to_string(),
299        )
300    })?;
301    let connector_status_url = join_url(&connector_base_url, STATUS_PATH, "connectorBaseUrl")?;
302    let response = blocking_client()?
303        .get(&connector_status_url)
304        .header("accept", "application/json")
305        .send()
306        .map_err(|error| CoreError::Http(error.to_string()))?;
307
308    if !response.status().is_success() {
309        return Ok(OpenclawRelayWebsocketTestResult {
310            status: RelayCheckStatus::Failure,
311            checked_at,
312            peer_alias,
313            connector_status_url,
314            message: format!(
315                "Connector status endpoint returned HTTP {}",
316                response.status()
317            ),
318            remediation_hint: Some("Start connector runtime and retry websocket test.".to_string()),
319            preflight,
320        });
321    }
322
323    let payload: serde_json::Value = response
324        .json()
325        .map_err(|error| CoreError::Http(error.to_string()))?;
326    let connected = payload
327        .get("websocket")
328        .and_then(|value| value.get("connected"))
329        .and_then(serde_json::Value::as_bool)
330        .unwrap_or(false);
331    if connected {
332        return Ok(OpenclawRelayWebsocketTestResult {
333            status: RelayCheckStatus::Success,
334            checked_at,
335            peer_alias,
336            connector_status_url,
337            message: "Connector websocket is connected for paired relay".to_string(),
338            remediation_hint: None,
339            preflight,
340        });
341    }
342
343    Ok(OpenclawRelayWebsocketTestResult {
344        status: RelayCheckStatus::Failure,
345        checked_at,
346        peer_alias,
347        connector_status_url,
348        message: "Connector websocket is disconnected".to_string(),
349        remediation_hint: Some(
350            "Run `connector start <agentName>` or reinstall connector service.".to_string(),
351        ),
352        preflight,
353    })
354}
355
356#[cfg(test)]
357mod tests {
358    use tempfile::TempDir;
359    use wiremock::matchers::{method, path};
360    use wiremock::{Mock, MockServer, ResponseTemplate};
361
362    use crate::db::SqliteStore;
363    use crate::peers::{PersistPeerInput, persist_peer};
364
365    use super::{
366        OpenclawRelayTestOptions, OpenclawRelayWebsocketTestOptions, RelayCheckStatus,
367        run_openclaw_relay_test, run_openclaw_relay_websocket_test,
368    };
369
370    fn seed_peer(store: &SqliteStore) {
371        let _ = persist_peer(
372            store,
373            PersistPeerInput {
374                alias: Some("peer-alpha".to_string()),
375                did: "did:cdi:registry.clawdentity.com:agent:01HF7YAT00W6W7CM7N3W5FDXT4"
376                    .to_string(),
377                proxy_url: "https://proxy.example/hooks/agent".to_string(),
378                agent_name: Some("alpha".to_string()),
379                human_name: Some("alice".to_string()),
380            },
381        )
382        .expect("peer");
383    }
384
385    #[tokio::test]
386    async fn relay_test_returns_success_for_accepted_probe() {
387        let server = MockServer::start().await;
388        Mock::given(method("POST"))
389            .and(path("/hooks/send-to-peer"))
390            .respond_with(ResponseTemplate::new(202))
391            .mount(&server)
392            .await;
393
394        let temp = TempDir::new().expect("temp dir");
395        let config_dir = temp.path().join("state");
396        std::fs::create_dir_all(&config_dir).expect("state dir");
397        let store = SqliteStore::open_path(temp.path().join("db.sqlite3")).expect("db");
398        seed_peer(&store);
399
400        let relay_config_dir = config_dir.clone();
401        let relay_store = store.clone();
402        let result = tokio::task::spawn_blocking(move || {
403            run_openclaw_relay_test(
404                &relay_config_dir,
405                &relay_store,
406                OpenclawRelayTestOptions {
407                    peer_alias: Some("peer-alpha".to_string()),
408                    openclaw_base_url: Some(server.uri()),
409                    skip_preflight: true,
410                    ..OpenclawRelayTestOptions::default()
411                },
412            )
413        })
414        .await
415        .expect("join")
416        .expect("relay test");
417        assert_eq!(result.status, RelayCheckStatus::Success);
418    }
419
420    #[tokio::test]
421    async fn relay_websocket_test_reports_connected_status() {
422        let server = MockServer::start().await;
423        Mock::given(method("GET"))
424            .and(path("/v1/status"))
425            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
426                "websocket": { "connected": true }
427            })))
428            .mount(&server)
429            .await;
430
431        let temp = TempDir::new().expect("temp dir");
432        let config_dir = temp.path().join("state");
433        std::fs::create_dir_all(&config_dir).expect("state dir");
434        let store = SqliteStore::open_path(temp.path().join("db.sqlite3")).expect("db");
435        seed_peer(&store);
436
437        let ws_config_dir = config_dir.clone();
438        let ws_store = store.clone();
439        let result = tokio::task::spawn_blocking(move || {
440            run_openclaw_relay_websocket_test(
441                &ws_config_dir,
442                &ws_store,
443                OpenclawRelayWebsocketTestOptions {
444                    peer_alias: Some("peer-alpha".to_string()),
445                    connector_base_url: Some(server.uri()),
446                    skip_preflight: true,
447                    ..OpenclawRelayWebsocketTestOptions::default()
448                },
449            )
450        })
451        .await
452        .expect("join")
453        .expect("ws test");
454        assert_eq!(result.status, RelayCheckStatus::Success);
455    }
456}