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}