1use std::fs;
2use std::io::ErrorKind;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use super::setup::{
9 OPENCLAW_DEFAULT_BASE_URL, load_relay_runtime_config, openclaw_agent_name_path,
10 read_selected_openclaw_agent, resolve_connector_base_url, resolve_openclaw_hook_token,
11};
12use crate::constants::{AGENTS_DIR, AIT_FILE_NAME, SECRET_KEY_FILE_NAME};
13use crate::db::SqliteStore;
14use crate::error::{CoreError, Result};
15use crate::http::blocking_client;
16use crate::peers::load_peers_config;
17
18const RELAY_TRANSFORM_MODULE_RELATIVE_PATH: &str = "hooks/transforms/relay-to-peer.mjs";
19const OPENCLAW_PENDING_DEVICES_RELATIVE_PATH: &str = "devices/pending.json";
20const STATUS_PATH: &str = "/v1/status";
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum DoctorCheckStatus {
25 Pass,
26 Fail,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum DoctorStatus {
32 Healthy,
33 Unhealthy,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct OpenclawDoctorCheck {
39 pub id: String,
40 pub label: String,
41 pub status: DoctorCheckStatus,
42 pub message: String,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub remediation_hint: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub details: Option<Value>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct OpenclawDoctorResult {
52 pub status: DoctorStatus,
53 pub checks: Vec<OpenclawDoctorCheck>,
54}
55
56#[derive(Debug, Clone, Default)]
57pub struct OpenclawDoctorOptions {
58 pub home_dir: Option<PathBuf>,
59 pub openclaw_dir: Option<PathBuf>,
60 pub selected_agent: Option<String>,
61 pub peer_alias: Option<String>,
62 pub connector_base_url: Option<String>,
63 pub include_connector_runtime_check: bool,
64}
65
66fn push_check(
67 checks: &mut Vec<OpenclawDoctorCheck>,
68 id: &str,
69 label: &str,
70 status: DoctorCheckStatus,
71 message: impl Into<String>,
72 remediation_hint: Option<&str>,
73 details: Option<Value>,
74) {
75 checks.push(OpenclawDoctorCheck {
76 id: id.to_string(),
77 label: label.to_string(),
78 status,
79 message: message.into(),
80 remediation_hint: remediation_hint.map(ToOwned::to_owned),
81 details,
82 });
83}
84
85fn resolve_openclaw_dir(home_dir: Option<&Path>, override_dir: Option<&Path>) -> Result<PathBuf> {
86 if let Some(path) = override_dir {
87 return Ok(path.to_path_buf());
88 }
89
90 if let Ok(path) = std::env::var("OPENCLAW_STATE_DIR") {
91 let trimmed = path.trim();
92 if !trimmed.is_empty() {
93 return Ok(PathBuf::from(trimmed));
94 }
95 }
96
97 if let Ok(path) = std::env::var("OPENCLAW_CONFIG_PATH") {
98 let trimmed = path.trim();
99 if !trimmed.is_empty() {
100 let path = PathBuf::from(trimmed);
101 return Ok(path.parent().map(Path::to_path_buf).unwrap_or(path));
102 }
103 }
104
105 let home = if let Some(home_dir) = home_dir {
106 home_dir.to_path_buf()
107 } else {
108 dirs::home_dir().ok_or(CoreError::HomeDirectoryUnavailable)?
109 };
110 Ok(home.join(".openclaw"))
111}
112
113fn read_non_empty_file(path: &Path) -> Result<bool> {
114 let content = fs::read_to_string(path).map_err(|source| CoreError::Io {
115 path: path.to_path_buf(),
116 source,
117 })?;
118 Ok(!content.trim().is_empty())
119}
120
121fn get_status_url(base_url: &str) -> Result<String> {
122 let normalized = if base_url.ends_with('/') {
123 base_url.to_string()
124 } else {
125 format!("{base_url}/")
126 };
127 let joined = url::Url::parse(&normalized)
128 .map_err(|_| CoreError::InvalidUrl {
129 context: "connectorBaseUrl",
130 value: base_url.to_string(),
131 })?
132 .join(STATUS_PATH.trim_start_matches('/'))
133 .map_err(|_| CoreError::InvalidUrl {
134 context: "connectorBaseUrl",
135 value: base_url.to_string(),
136 })?;
137 Ok(joined.to_string())
138}
139
140fn parse_pending_approvals_count(path: &Path) -> Result<usize> {
141 let raw = match fs::read_to_string(path) {
142 Ok(raw) => raw,
143 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(0),
144 Err(source) => {
145 return Err(CoreError::Io {
146 path: path.to_path_buf(),
147 source,
148 });
149 }
150 };
151 let payload: Value = serde_json::from_str(&raw).map_err(|source| CoreError::JsonParse {
152 path: path.to_path_buf(),
153 source,
154 })?;
155
156 if let Some(array) = payload.as_array() {
157 return Ok(array.len());
158 }
159 if let Some(array) = payload.get("requests").and_then(|value| value.as_array()) {
160 return Ok(array.len());
161 }
162 Ok(0)
163}
164
165#[allow(clippy::too_many_lines)]
166fn run_connector_checks(
167 checks: &mut Vec<OpenclawDoctorCheck>,
168 config_dir: &Path,
169 selected_agent: Option<&str>,
170 connector_base_url: Option<&str>,
171) -> Result<()> {
172 let resolved_base_url =
173 resolve_connector_base_url(config_dir, selected_agent, connector_base_url)?;
174 let Some(base_url) = resolved_base_url else {
175 push_check(
176 checks,
177 "state.connectorRuntime",
178 "Connector runtime",
179 DoctorCheckStatus::Fail,
180 "connector runtime assignment is missing for selected agent",
181 Some(
182 "Run `clawdentity install --for openclaw` and `clawdentity provider setup --for openclaw --agent-name <agentName>`, or pass `--connector-base-url`.",
183 ),
184 None,
185 );
186 push_check(
187 checks,
188 "state.connectorInboundInbox",
189 "Connector inbound inbox",
190 DoctorCheckStatus::Fail,
191 "cannot validate connector inbox without connector assignment",
192 Some(
193 "Run `clawdentity install --for openclaw` and `clawdentity provider setup --for openclaw --agent-name <agentName>`, or pass `--connector-base-url`.",
194 ),
195 None,
196 );
197 push_check(
198 checks,
199 "state.openclawHookHealth",
200 "OpenClaw hook health",
201 DoctorCheckStatus::Fail,
202 "cannot validate OpenClaw hook health without connector runtime",
203 Some(
204 "Run `clawdentity install --for openclaw` and `clawdentity provider setup --for openclaw --agent-name <agentName>`, then restart connector runtime.",
205 ),
206 None,
207 );
208 return Ok(());
209 };
210
211 let status_url = get_status_url(&base_url)?;
212 let response = blocking_client()?
213 .get(&status_url)
214 .header("accept", "application/json")
215 .send();
216
217 let response = match response {
218 Ok(response) => response,
219 Err(error) => {
220 push_check(
221 checks,
222 "state.connectorRuntime",
223 "Connector runtime",
224 DoctorCheckStatus::Fail,
225 format!("connector status request failed: {error}"),
226 Some("Ensure connector runtime is running and reachable."),
227 Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
228 );
229 push_check(
230 checks,
231 "state.connectorInboundInbox",
232 "Connector inbound inbox",
233 DoctorCheckStatus::Fail,
234 "cannot read connector inbound inbox status",
235 Some("Start connector runtime and retry."),
236 Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
237 );
238 push_check(
239 checks,
240 "state.openclawHookHealth",
241 "OpenClaw hook health",
242 DoctorCheckStatus::Fail,
243 "cannot read connector OpenClaw hook status",
244 Some("Restart connector runtime and OpenClaw."),
245 Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
246 );
247 return Ok(());
248 }
249 };
250
251 if !response.status().is_success() {
252 let status = response.status().as_u16();
253 push_check(
254 checks,
255 "state.connectorRuntime",
256 "Connector runtime",
257 DoctorCheckStatus::Fail,
258 format!("connector status returned HTTP {status}"),
259 Some("Ensure connector runtime is running and reachable."),
260 Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
261 );
262 push_check(
263 checks,
264 "state.connectorInboundInbox",
265 "Connector inbound inbox",
266 DoctorCheckStatus::Fail,
267 "cannot read connector inbound inbox status",
268 Some("Start connector runtime and retry."),
269 Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
270 );
271 push_check(
272 checks,
273 "state.openclawHookHealth",
274 "OpenClaw hook health",
275 DoctorCheckStatus::Fail,
276 "cannot read connector OpenClaw hook status",
277 Some("Restart connector runtime and OpenClaw."),
278 Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
279 );
280 return Ok(());
281 }
282
283 let payload: Value = response
284 .json()
285 .map_err(|error| CoreError::Http(error.to_string()))?;
286 let websocket_connected = payload
287 .get("websocket")
288 .and_then(|value| value.get("connected"))
289 .and_then(Value::as_bool)
290 .unwrap_or(false);
291 let inbound_pending = payload
292 .get("inbound")
293 .and_then(|value| value.get("pending"))
294 .and_then(Value::as_i64)
295 .unwrap_or(0);
296 let inbound_dead_letter = payload
297 .get("inbound")
298 .and_then(|value| value.get("deadLetter"))
299 .and_then(Value::as_i64)
300 .unwrap_or(0);
301 let hook_last_attempt_status = payload
302 .get("inbound")
303 .and_then(|value| value.get("openclawHook"))
304 .and_then(|value| value.get("lastAttemptStatus"))
305 .and_then(Value::as_str);
306
307 if websocket_connected {
308 push_check(
309 checks,
310 "state.connectorRuntime",
311 "Connector runtime",
312 DoctorCheckStatus::Pass,
313 "connector websocket is connected",
314 None,
315 Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
316 );
317 push_check(
318 checks,
319 "state.connectorInboundInbox",
320 "Connector inbound inbox",
321 DoctorCheckStatus::Pass,
322 format!(
323 "pending={} deadLetter={}",
324 inbound_pending, inbound_dead_letter
325 ),
326 None,
327 Some(
328 serde_json::json!({ "pendingCount": inbound_pending, "deadLetterCount": inbound_dead_letter }),
329 ),
330 );
331 let hook_failed = hook_last_attempt_status == Some("failed");
332 push_check(
333 checks,
334 "state.openclawHookHealth",
335 "OpenClaw hook health",
336 if hook_failed && inbound_pending > 0 {
337 DoctorCheckStatus::Fail
338 } else {
339 DoctorCheckStatus::Pass
340 },
341 if hook_failed && inbound_pending > 0 {
342 "connector reports failed OpenClaw hook replay with pending inbox backlog"
343 } else {
344 "OpenClaw hook replay is healthy"
345 },
346 if hook_failed && inbound_pending > 0 {
347 Some("Restart OpenClaw and connector runtime, then replay pending inbox messages.")
348 } else {
349 None
350 },
351 None,
352 );
353 } else {
354 push_check(
355 checks,
356 "state.connectorRuntime",
357 "Connector runtime",
358 DoctorCheckStatus::Fail,
359 "connector websocket is disconnected",
360 Some("Run `connector start <agentName>` or `connector service install <agentName>`."),
361 Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
362 );
363 push_check(
364 checks,
365 "state.connectorInboundInbox",
366 "Connector inbound inbox",
367 DoctorCheckStatus::Fail,
368 "connector websocket is disconnected; inbox status may be stale",
369 Some("Start connector runtime and retry."),
370 Some(
371 serde_json::json!({ "pendingCount": inbound_pending, "deadLetterCount": inbound_dead_letter }),
372 ),
373 );
374 push_check(
375 checks,
376 "state.openclawHookHealth",
377 "OpenClaw hook health",
378 DoctorCheckStatus::Fail,
379 "connector websocket is disconnected; hook replay is unavailable",
380 Some("Restart connector runtime and OpenClaw."),
381 None,
382 );
383 }
384
385 Ok(())
386}
387
388#[allow(clippy::too_many_lines)]
390pub fn run_openclaw_doctor(
391 config_dir: &Path,
392 store: &SqliteStore,
393 options: OpenclawDoctorOptions,
394) -> Result<OpenclawDoctorResult> {
395 let openclaw_dir =
396 resolve_openclaw_dir(options.home_dir.as_deref(), options.openclaw_dir.as_deref())?;
397 let mut checks = Vec::<OpenclawDoctorCheck>::new();
398
399 let selected_agent = options
400 .selected_agent
401 .as_deref()
402 .map(str::trim)
403 .filter(|value| !value.is_empty())
404 .map(ToOwned::to_owned)
405 .or(read_selected_openclaw_agent(config_dir)?);
406
407 if let Some(agent_name) = selected_agent.clone() {
408 push_check(
409 &mut checks,
410 "state.selectedAgent",
411 "Selected agent",
412 DoctorCheckStatus::Pass,
413 format!("selected agent is `{agent_name}`"),
414 None,
415 Some(serde_json::json!({
416 "path": openclaw_agent_name_path(config_dir),
417 "agentName": agent_name
418 })),
419 );
420 } else {
421 push_check(
422 &mut checks,
423 "state.selectedAgent",
424 "Selected agent",
425 DoctorCheckStatus::Fail,
426 "selected agent marker is missing",
427 Some(
428 "Run `clawdentity provider setup --for openclaw --agent-name <agentName>` to persist selected agent.",
429 ),
430 Some(serde_json::json!({ "path": openclaw_agent_name_path(config_dir) })),
431 );
432 }
433
434 if let Some(agent_name) = selected_agent.as_deref() {
435 let agent_dir = config_dir.join(AGENTS_DIR).join(agent_name);
436 let ait_path = agent_dir.join(AIT_FILE_NAME);
437 let secret_path = agent_dir.join(SECRET_KEY_FILE_NAME);
438 let credentials_ok = read_non_empty_file(&ait_path).unwrap_or(false)
439 && read_non_empty_file(&secret_path).unwrap_or(false);
440 if credentials_ok {
441 push_check(
442 &mut checks,
443 "state.credentials",
444 "Agent credentials",
445 DoctorCheckStatus::Pass,
446 "local agent credentials are present",
447 None,
448 Some(serde_json::json!({
449 "agentDir": agent_dir,
450 "ait": ait_path,
451 "secretKey": secret_path
452 })),
453 );
454 } else {
455 push_check(
456 &mut checks,
457 "state.credentials",
458 "Agent credentials",
459 DoctorCheckStatus::Fail,
460 "local agent credentials are missing or unreadable",
461 Some("Run `agent create <agentName>` and retry setup."),
462 Some(serde_json::json!({
463 "agentDir": agent_dir,
464 "ait": ait_path,
465 "secretKey": secret_path
466 })),
467 );
468 }
469 } else {
470 push_check(
471 &mut checks,
472 "state.credentials",
473 "Agent credentials",
474 DoctorCheckStatus::Fail,
475 "cannot validate credentials without selected agent",
476 Some(
477 "Run `clawdentity provider setup --for openclaw --agent-name <agentName>` first.",
478 ),
479 None,
480 );
481 }
482
483 match load_peers_config(store) {
484 Ok(peers) => {
485 if peers.peers.is_empty() {
486 push_check(
487 &mut checks,
488 "state.peers",
489 "Paired peers",
490 DoctorCheckStatus::Fail,
491 "no paired peers found",
492 Some(
493 "Complete proxy pairing via `/pair/start` + `/pair/confirm` and persist local peer state before relay checks.",
494 ),
495 None,
496 );
497 } else if let Some(peer_alias) = options.peer_alias.as_deref().map(str::trim) {
498 if peers.peers.contains_key(peer_alias) {
499 push_check(
500 &mut checks,
501 "state.peers",
502 "Paired peers",
503 DoctorCheckStatus::Pass,
504 format!("peer alias `{peer_alias}` is configured"),
505 None,
506 Some(serde_json::json!({ "peerCount": peers.peers.len() })),
507 );
508 } else {
509 push_check(
510 &mut checks,
511 "state.peers",
512 "Paired peers",
513 DoctorCheckStatus::Fail,
514 format!("peer alias `{peer_alias}` is not configured"),
515 Some(
516 "Choose an existing peer alias from local peer state (or generated peer snapshot).",
517 ),
518 Some(
519 serde_json::json!({ "peerAliases": peers.peers.keys().collect::<Vec<_>>() }),
520 ),
521 );
522 }
523 } else {
524 push_check(
525 &mut checks,
526 "state.peers",
527 "Paired peers",
528 DoctorCheckStatus::Pass,
529 format!("{} paired peer(s) configured", peers.peers.len()),
530 None,
531 Some(serde_json::json!({ "peerCount": peers.peers.len() })),
532 );
533 }
534 }
535 Err(error) => {
536 push_check(
537 &mut checks,
538 "state.peers",
539 "Paired peers",
540 DoctorCheckStatus::Fail,
541 format!("unable to load peers: {error}"),
542 Some("Repair local state and retry pairing."),
543 None,
544 );
545 }
546 }
547
548 let transform_path = openclaw_dir.join(RELAY_TRANSFORM_MODULE_RELATIVE_PATH);
549 if transform_path.exists() {
550 push_check(
551 &mut checks,
552 "state.transformMapping",
553 "Relay transform mapping",
554 DoctorCheckStatus::Pass,
555 "relay transform module is present",
556 None,
557 Some(serde_json::json!({ "transformPath": transform_path })),
558 );
559 } else {
560 push_check(
561 &mut checks,
562 "state.transformMapping",
563 "Relay transform mapping",
564 DoctorCheckStatus::Fail,
565 "relay transform module is missing",
566 Some("Install OpenClaw relay skill or run setup to restore mapping."),
567 Some(serde_json::json!({ "transformPath": transform_path })),
568 );
569 }
570
571 let runtime_config = load_relay_runtime_config(config_dir)?;
572 let hook_token = resolve_openclaw_hook_token(config_dir, None)?;
573 if hook_token.is_some() {
574 push_check(
575 &mut checks,
576 "state.hookToken",
577 "OpenClaw hook token",
578 DoctorCheckStatus::Pass,
579 "hook token is configured",
580 None,
581 runtime_config.map(|config| serde_json::to_value(config).unwrap_or(Value::Null)),
582 );
583 } else {
584 push_check(
585 &mut checks,
586 "state.hookToken",
587 "OpenClaw hook token",
588 DoctorCheckStatus::Fail,
589 "hook token is missing",
590 Some(
591 "Run `clawdentity provider setup --for openclaw --agent-name <agentName>` to persist runtime hook token.",
592 ),
593 None,
594 );
595 }
596
597 let pending_path = openclaw_dir.join(OPENCLAW_PENDING_DEVICES_RELATIVE_PATH);
598 let pending_count = parse_pending_approvals_count(&pending_path)?;
599 if pending_count == 0 {
600 push_check(
601 &mut checks,
602 "state.gatewayPairing",
603 "OpenClaw gateway pairing",
604 DoctorCheckStatus::Pass,
605 "no pending OpenClaw device approvals",
606 None,
607 Some(serde_json::json!({ "pendingPath": pending_path, "pendingCount": 0 })),
608 );
609 } else {
610 push_check(
611 &mut checks,
612 "state.gatewayPairing",
613 "OpenClaw gateway pairing",
614 DoctorCheckStatus::Fail,
615 format!("{pending_count} pending OpenClaw device approval(s)"),
616 Some("Approve pending devices in OpenClaw before relay diagnostics."),
617 Some(serde_json::json!({ "pendingPath": pending_path, "pendingCount": pending_count })),
618 );
619 }
620
621 if options.include_connector_runtime_check {
622 run_connector_checks(
623 &mut checks,
624 config_dir,
625 selected_agent.as_deref(),
626 options.connector_base_url.as_deref(),
627 )?;
628 } else {
629 push_check(
630 &mut checks,
631 "state.connectorRuntime",
632 "Connector runtime",
633 DoctorCheckStatus::Pass,
634 "connector runtime check skipped by caller",
635 None,
636 Some(serde_json::json!({ "defaultConnectorBaseUrl": OPENCLAW_DEFAULT_BASE_URL })),
637 );
638 }
639
640 let status = if checks
641 .iter()
642 .any(|check| check.status == DoctorCheckStatus::Fail)
643 {
644 DoctorStatus::Unhealthy
645 } else {
646 DoctorStatus::Healthy
647 };
648
649 Ok(OpenclawDoctorResult { status, checks })
650}
651
652#[cfg(test)]
653mod tests {
654 use tempfile::TempDir;
655 use wiremock::matchers::{method, path};
656 use wiremock::{Mock, MockServer, ResponseTemplate};
657
658 use super::super::setup::{
659 OpenclawRelayRuntimeConfig, save_connector_assignment, save_relay_runtime_config,
660 write_selected_openclaw_agent,
661 };
662 use crate::db::SqliteStore;
663 use crate::peers::{PersistPeerInput, persist_peer};
664
665 use super::{DoctorStatus, OpenclawDoctorOptions, run_openclaw_doctor};
666
667 #[tokio::test]
668 async fn doctor_reports_healthy_when_runtime_is_ready() {
669 let temp = TempDir::new().expect("temp dir");
670 let config_dir = temp.path().join("state");
671 std::fs::create_dir_all(config_dir.join("agents/alpha")).expect("agent dir");
672 std::fs::write(config_dir.join("agents/alpha/ait.jwt"), "token").expect("ait");
673 std::fs::write(config_dir.join("agents/alpha/secret.key"), "secret").expect("secret");
674 write_selected_openclaw_agent(&config_dir, "alpha").expect("selected");
675 save_relay_runtime_config(
676 &config_dir,
677 OpenclawRelayRuntimeConfig {
678 openclaw_base_url: "http://127.0.0.1:18789".to_string(),
679 openclaw_hook_token: Some("token".to_string()),
680 relay_transform_peers_path: None,
681 updated_at: None,
682 },
683 )
684 .expect("runtime config");
685
686 let openclaw_dir = temp.path().join("openclaw");
687 std::fs::create_dir_all(openclaw_dir.join("hooks/transforms")).expect("transform dir");
688 std::fs::write(
689 openclaw_dir.join("hooks/transforms/relay-to-peer.mjs"),
690 "export default {}",
691 )
692 .expect("transform");
693 std::fs::create_dir_all(openclaw_dir.join("devices")).expect("devices dir");
694 std::fs::write(openclaw_dir.join("devices/pending.json"), "[]").expect("pending");
695
696 let store = SqliteStore::open_path(temp.path().join("db.sqlite3")).expect("db");
697 let _ = persist_peer(
698 &store,
699 PersistPeerInput {
700 alias: Some("peer-alpha".to_string()),
701 did: "did:cdi:registry.clawdentity.com:agent:01HF7YAT00W6W7CM7N3W5FDXT4"
702 .to_string(),
703 proxy_url: "https://proxy.example/hooks/agent".to_string(),
704 agent_name: Some("alpha".to_string()),
705 human_name: Some("alice".to_string()),
706 },
707 )
708 .expect("peer");
709
710 let server = MockServer::start().await;
711 Mock::given(method("GET"))
712 .and(path("/v1/status"))
713 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
714 "websocket": { "connected": true },
715 "inbound": { "pending": 0, "deadLetter": 0 }
716 })))
717 .mount(&server)
718 .await;
719
720 save_connector_assignment(&config_dir, "alpha", &server.uri()).expect("assignment");
721 let doctor_config_dir = config_dir.clone();
722 let doctor_store = store.clone();
723 let result = tokio::task::spawn_blocking(move || {
724 run_openclaw_doctor(
725 &doctor_config_dir,
726 &doctor_store,
727 OpenclawDoctorOptions {
728 openclaw_dir: Some(openclaw_dir),
729 include_connector_runtime_check: true,
730 ..OpenclawDoctorOptions::default()
731 },
732 )
733 })
734 .await
735 .expect("join")
736 .expect("doctor");
737 assert_eq!(result.status, DoctorStatus::Healthy);
738 }
739
740 #[test]
741 fn doctor_fails_when_selected_agent_marker_is_missing() {
742 let temp = TempDir::new().expect("temp dir");
743 let config_dir = temp.path().join("state");
744 std::fs::create_dir_all(&config_dir).expect("state dir");
745 let store = SqliteStore::open_path(temp.path().join("db.sqlite3")).expect("db");
746 let result = run_openclaw_doctor(
747 &config_dir,
748 &store,
749 OpenclawDoctorOptions {
750 include_connector_runtime_check: false,
751 ..OpenclawDoctorOptions::default()
752 },
753 )
754 .expect("doctor");
755 assert_eq!(result.status, DoctorStatus::Unhealthy);
756 assert!(
757 result
758 .checks
759 .iter()
760 .any(|check| check.id == "state.selectedAgent"
761 && check.status == super::DoctorCheckStatus::Fail)
762 );
763 }
764}