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}