Skip to main content

clawdentity_core/providers/openclaw/
doctor.rs

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("Run `openclaw setup <agentName>` or pass `--connector-base-url`."),
182            None,
183        );
184        push_check(
185            checks,
186            "state.connectorInboundInbox",
187            "Connector inbound inbox",
188            DoctorCheckStatus::Fail,
189            "cannot validate connector inbox without connector assignment",
190            Some("Run `openclaw setup <agentName>` or pass `--connector-base-url`."),
191            None,
192        );
193        push_check(
194            checks,
195            "state.openclawHookHealth",
196            "OpenClaw hook health",
197            DoctorCheckStatus::Fail,
198            "cannot validate OpenClaw hook health without connector runtime",
199            Some("Run `openclaw setup <agentName>` and restart connector runtime."),
200            None,
201        );
202        return Ok(());
203    };
204
205    let status_url = get_status_url(&base_url)?;
206    let response = blocking_client()?
207        .get(&status_url)
208        .header("accept", "application/json")
209        .send();
210
211    let response = match response {
212        Ok(response) => response,
213        Err(error) => {
214            push_check(
215                checks,
216                "state.connectorRuntime",
217                "Connector runtime",
218                DoctorCheckStatus::Fail,
219                format!("connector status request failed: {error}"),
220                Some("Ensure connector runtime is running and reachable."),
221                Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
222            );
223            push_check(
224                checks,
225                "state.connectorInboundInbox",
226                "Connector inbound inbox",
227                DoctorCheckStatus::Fail,
228                "cannot read connector inbound inbox status",
229                Some("Start connector runtime and retry."),
230                Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
231            );
232            push_check(
233                checks,
234                "state.openclawHookHealth",
235                "OpenClaw hook health",
236                DoctorCheckStatus::Fail,
237                "cannot read connector OpenClaw hook status",
238                Some("Restart connector runtime and OpenClaw."),
239                Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
240            );
241            return Ok(());
242        }
243    };
244
245    if !response.status().is_success() {
246        let status = response.status().as_u16();
247        push_check(
248            checks,
249            "state.connectorRuntime",
250            "Connector runtime",
251            DoctorCheckStatus::Fail,
252            format!("connector status returned HTTP {status}"),
253            Some("Ensure connector runtime is running and reachable."),
254            Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
255        );
256        push_check(
257            checks,
258            "state.connectorInboundInbox",
259            "Connector inbound inbox",
260            DoctorCheckStatus::Fail,
261            "cannot read connector inbound inbox status",
262            Some("Start connector runtime and retry."),
263            Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
264        );
265        push_check(
266            checks,
267            "state.openclawHookHealth",
268            "OpenClaw hook health",
269            DoctorCheckStatus::Fail,
270            "cannot read connector OpenClaw hook status",
271            Some("Restart connector runtime and OpenClaw."),
272            Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
273        );
274        return Ok(());
275    }
276
277    let payload: Value = response
278        .json()
279        .map_err(|error| CoreError::Http(error.to_string()))?;
280    let websocket_connected = payload
281        .get("websocket")
282        .and_then(|value| value.get("connected"))
283        .and_then(Value::as_bool)
284        .unwrap_or(false);
285    let inbound_pending = payload
286        .get("inbound")
287        .and_then(|value| value.get("pending"))
288        .and_then(Value::as_i64)
289        .unwrap_or(0);
290    let inbound_dead_letter = payload
291        .get("inbound")
292        .and_then(|value| value.get("deadLetter"))
293        .and_then(Value::as_i64)
294        .unwrap_or(0);
295    let hook_last_attempt_status = payload
296        .get("inbound")
297        .and_then(|value| value.get("openclawHook"))
298        .and_then(|value| value.get("lastAttemptStatus"))
299        .and_then(Value::as_str);
300
301    if websocket_connected {
302        push_check(
303            checks,
304            "state.connectorRuntime",
305            "Connector runtime",
306            DoctorCheckStatus::Pass,
307            "connector websocket is connected",
308            None,
309            Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
310        );
311        push_check(
312            checks,
313            "state.connectorInboundInbox",
314            "Connector inbound inbox",
315            DoctorCheckStatus::Pass,
316            format!(
317                "pending={} deadLetter={}",
318                inbound_pending, inbound_dead_letter
319            ),
320            None,
321            Some(
322                serde_json::json!({ "pendingCount": inbound_pending, "deadLetterCount": inbound_dead_letter }),
323            ),
324        );
325        let hook_failed = hook_last_attempt_status == Some("failed");
326        push_check(
327            checks,
328            "state.openclawHookHealth",
329            "OpenClaw hook health",
330            if hook_failed && inbound_pending > 0 {
331                DoctorCheckStatus::Fail
332            } else {
333                DoctorCheckStatus::Pass
334            },
335            if hook_failed && inbound_pending > 0 {
336                "connector reports failed OpenClaw hook replay with pending inbox backlog"
337            } else {
338                "OpenClaw hook replay is healthy"
339            },
340            if hook_failed && inbound_pending > 0 {
341                Some("Restart OpenClaw and connector runtime, then replay pending inbox messages.")
342            } else {
343                None
344            },
345            None,
346        );
347    } else {
348        push_check(
349            checks,
350            "state.connectorRuntime",
351            "Connector runtime",
352            DoctorCheckStatus::Fail,
353            "connector websocket is disconnected",
354            Some("Run `connector start <agentName>` or `connector service install <agentName>`."),
355            Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
356        );
357        push_check(
358            checks,
359            "state.connectorInboundInbox",
360            "Connector inbound inbox",
361            DoctorCheckStatus::Fail,
362            "connector websocket is disconnected; inbox status may be stale",
363            Some("Start connector runtime and retry."),
364            Some(
365                serde_json::json!({ "pendingCount": inbound_pending, "deadLetterCount": inbound_dead_letter }),
366            ),
367        );
368        push_check(
369            checks,
370            "state.openclawHookHealth",
371            "OpenClaw hook health",
372            DoctorCheckStatus::Fail,
373            "connector websocket is disconnected; hook replay is unavailable",
374            Some("Restart connector runtime and OpenClaw."),
375            None,
376        );
377    }
378
379    Ok(())
380}
381
382/// TODO(clawdentity): document `run_openclaw_doctor`.
383#[allow(clippy::too_many_lines)]
384pub fn run_openclaw_doctor(
385    config_dir: &Path,
386    store: &SqliteStore,
387    options: OpenclawDoctorOptions,
388) -> Result<OpenclawDoctorResult> {
389    let openclaw_dir =
390        resolve_openclaw_dir(options.home_dir.as_deref(), options.openclaw_dir.as_deref())?;
391    let mut checks = Vec::<OpenclawDoctorCheck>::new();
392
393    let selected_agent = options
394        .selected_agent
395        .as_deref()
396        .map(str::trim)
397        .filter(|value| !value.is_empty())
398        .map(ToOwned::to_owned)
399        .or(read_selected_openclaw_agent(config_dir)?);
400
401    if let Some(agent_name) = selected_agent.clone() {
402        push_check(
403            &mut checks,
404            "state.selectedAgent",
405            "Selected agent",
406            DoctorCheckStatus::Pass,
407            format!("selected agent is `{agent_name}`"),
408            None,
409            Some(serde_json::json!({
410                "path": openclaw_agent_name_path(config_dir),
411                "agentName": agent_name
412            })),
413        );
414    } else {
415        push_check(
416            &mut checks,
417            "state.selectedAgent",
418            "Selected agent",
419            DoctorCheckStatus::Fail,
420            "selected agent marker is missing",
421            Some("Run `openclaw setup <agentName>` to persist selected agent."),
422            Some(serde_json::json!({ "path": openclaw_agent_name_path(config_dir) })),
423        );
424    }
425
426    if let Some(agent_name) = selected_agent.as_deref() {
427        let agent_dir = config_dir.join(AGENTS_DIR).join(agent_name);
428        let ait_path = agent_dir.join(AIT_FILE_NAME);
429        let secret_path = agent_dir.join(SECRET_KEY_FILE_NAME);
430        let credentials_ok = read_non_empty_file(&ait_path).unwrap_or(false)
431            && read_non_empty_file(&secret_path).unwrap_or(false);
432        if credentials_ok {
433            push_check(
434                &mut checks,
435                "state.credentials",
436                "Agent credentials",
437                DoctorCheckStatus::Pass,
438                "local agent credentials are present",
439                None,
440                Some(serde_json::json!({
441                    "agentDir": agent_dir,
442                    "ait": ait_path,
443                    "secretKey": secret_path
444                })),
445            );
446        } else {
447            push_check(
448                &mut checks,
449                "state.credentials",
450                "Agent credentials",
451                DoctorCheckStatus::Fail,
452                "local agent credentials are missing or unreadable",
453                Some("Run `agent create <agentName>` and retry setup."),
454                Some(serde_json::json!({
455                    "agentDir": agent_dir,
456                    "ait": ait_path,
457                    "secretKey": secret_path
458                })),
459            );
460        }
461    } else {
462        push_check(
463            &mut checks,
464            "state.credentials",
465            "Agent credentials",
466            DoctorCheckStatus::Fail,
467            "cannot validate credentials without selected agent",
468            Some("Run `openclaw setup <agentName>` first."),
469            None,
470        );
471    }
472
473    match load_peers_config(store) {
474        Ok(peers) => {
475            if peers.peers.is_empty() {
476                push_check(
477                    &mut checks,
478                    "state.peers",
479                    "Paired peers",
480                    DoctorCheckStatus::Fail,
481                    "no paired peers found",
482                    Some("Run `pair start`/`pair confirm` before relay checks."),
483                    None,
484                );
485            } else if let Some(peer_alias) = options.peer_alias.as_deref().map(str::trim) {
486                if peers.peers.contains_key(peer_alias) {
487                    push_check(
488                        &mut checks,
489                        "state.peers",
490                        "Paired peers",
491                        DoctorCheckStatus::Pass,
492                        format!("peer alias `{peer_alias}` is configured"),
493                        None,
494                        Some(serde_json::json!({ "peerCount": peers.peers.len() })),
495                    );
496                } else {
497                    push_check(
498                        &mut checks,
499                        "state.peers",
500                        "Paired peers",
501                        DoctorCheckStatus::Fail,
502                        format!("peer alias `{peer_alias}` is not configured"),
503                        Some("Choose an existing peer alias from peers.json."),
504                        Some(
505                            serde_json::json!({ "peerAliases": peers.peers.keys().collect::<Vec<_>>() }),
506                        ),
507                    );
508                }
509            } else {
510                push_check(
511                    &mut checks,
512                    "state.peers",
513                    "Paired peers",
514                    DoctorCheckStatus::Pass,
515                    format!("{} paired peer(s) configured", peers.peers.len()),
516                    None,
517                    Some(serde_json::json!({ "peerCount": peers.peers.len() })),
518                );
519            }
520        }
521        Err(error) => {
522            push_check(
523                &mut checks,
524                "state.peers",
525                "Paired peers",
526                DoctorCheckStatus::Fail,
527                format!("unable to load peers: {error}"),
528                Some("Repair local state and retry pairing."),
529                None,
530            );
531        }
532    }
533
534    let transform_path = openclaw_dir.join(RELAY_TRANSFORM_MODULE_RELATIVE_PATH);
535    if transform_path.exists() {
536        push_check(
537            &mut checks,
538            "state.transformMapping",
539            "Relay transform mapping",
540            DoctorCheckStatus::Pass,
541            "relay transform module is present",
542            None,
543            Some(serde_json::json!({ "transformPath": transform_path })),
544        );
545    } else {
546        push_check(
547            &mut checks,
548            "state.transformMapping",
549            "Relay transform mapping",
550            DoctorCheckStatus::Fail,
551            "relay transform module is missing",
552            Some("Install OpenClaw relay skill or run setup to restore mapping."),
553            Some(serde_json::json!({ "transformPath": transform_path })),
554        );
555    }
556
557    let runtime_config = load_relay_runtime_config(config_dir)?;
558    let hook_token = resolve_openclaw_hook_token(config_dir, None)?;
559    if hook_token.is_some() {
560        push_check(
561            &mut checks,
562            "state.hookToken",
563            "OpenClaw hook token",
564            DoctorCheckStatus::Pass,
565            "hook token is configured",
566            None,
567            runtime_config.map(|config| serde_json::to_value(config).unwrap_or(Value::Null)),
568        );
569    } else {
570        push_check(
571            &mut checks,
572            "state.hookToken",
573            "OpenClaw hook token",
574            DoctorCheckStatus::Fail,
575            "hook token is missing",
576            Some("Run `openclaw setup <agentName>` to persist runtime hook token."),
577            None,
578        );
579    }
580
581    let pending_path = openclaw_dir.join(OPENCLAW_PENDING_DEVICES_RELATIVE_PATH);
582    let pending_count = parse_pending_approvals_count(&pending_path)?;
583    if pending_count == 0 {
584        push_check(
585            &mut checks,
586            "state.gatewayPairing",
587            "OpenClaw gateway pairing",
588            DoctorCheckStatus::Pass,
589            "no pending OpenClaw device approvals",
590            None,
591            Some(serde_json::json!({ "pendingPath": pending_path, "pendingCount": 0 })),
592        );
593    } else {
594        push_check(
595            &mut checks,
596            "state.gatewayPairing",
597            "OpenClaw gateway pairing",
598            DoctorCheckStatus::Fail,
599            format!("{pending_count} pending OpenClaw device approval(s)"),
600            Some("Approve pending devices in OpenClaw before relay diagnostics."),
601            Some(serde_json::json!({ "pendingPath": pending_path, "pendingCount": pending_count })),
602        );
603    }
604
605    if options.include_connector_runtime_check {
606        run_connector_checks(
607            &mut checks,
608            config_dir,
609            selected_agent.as_deref(),
610            options.connector_base_url.as_deref(),
611        )?;
612    } else {
613        push_check(
614            &mut checks,
615            "state.connectorRuntime",
616            "Connector runtime",
617            DoctorCheckStatus::Pass,
618            "connector runtime check skipped by caller",
619            None,
620            Some(serde_json::json!({ "defaultConnectorBaseUrl": OPENCLAW_DEFAULT_BASE_URL })),
621        );
622    }
623
624    let status = if checks
625        .iter()
626        .any(|check| check.status == DoctorCheckStatus::Fail)
627    {
628        DoctorStatus::Unhealthy
629    } else {
630        DoctorStatus::Healthy
631    };
632
633    Ok(OpenclawDoctorResult { status, checks })
634}
635
636#[cfg(test)]
637mod tests {
638    use tempfile::TempDir;
639    use wiremock::matchers::{method, path};
640    use wiremock::{Mock, MockServer, ResponseTemplate};
641
642    use super::super::setup::{
643        OpenclawRelayRuntimeConfig, save_connector_assignment, save_relay_runtime_config,
644        write_selected_openclaw_agent,
645    };
646    use crate::db::SqliteStore;
647    use crate::peers::{PersistPeerInput, persist_peer};
648
649    use super::{DoctorStatus, OpenclawDoctorOptions, run_openclaw_doctor};
650
651    #[tokio::test]
652    async fn doctor_reports_healthy_when_runtime_is_ready() {
653        let temp = TempDir::new().expect("temp dir");
654        let config_dir = temp.path().join("state");
655        std::fs::create_dir_all(config_dir.join("agents/alpha")).expect("agent dir");
656        std::fs::write(config_dir.join("agents/alpha/ait.jwt"), "token").expect("ait");
657        std::fs::write(config_dir.join("agents/alpha/secret.key"), "secret").expect("secret");
658        write_selected_openclaw_agent(&config_dir, "alpha").expect("selected");
659        save_relay_runtime_config(
660            &config_dir,
661            OpenclawRelayRuntimeConfig {
662                openclaw_base_url: "http://127.0.0.1:18789".to_string(),
663                openclaw_hook_token: Some("token".to_string()),
664                relay_transform_peers_path: None,
665                updated_at: None,
666            },
667        )
668        .expect("runtime config");
669
670        let openclaw_dir = temp.path().join("openclaw");
671        std::fs::create_dir_all(openclaw_dir.join("hooks/transforms")).expect("transform dir");
672        std::fs::write(
673            openclaw_dir.join("hooks/transforms/relay-to-peer.mjs"),
674            "export default {}",
675        )
676        .expect("transform");
677        std::fs::create_dir_all(openclaw_dir.join("devices")).expect("devices dir");
678        std::fs::write(openclaw_dir.join("devices/pending.json"), "[]").expect("pending");
679
680        let store = SqliteStore::open_path(temp.path().join("db.sqlite3")).expect("db");
681        let _ = persist_peer(
682            &store,
683            PersistPeerInput {
684                alias: Some("peer-alpha".to_string()),
685                did: "did:cdi:registry.clawdentity.com:agent:01HF7YAT00W6W7CM7N3W5FDXT4"
686                    .to_string(),
687                proxy_url: "https://proxy.example/hooks/agent".to_string(),
688                agent_name: Some("alpha".to_string()),
689                human_name: Some("alice".to_string()),
690            },
691        )
692        .expect("peer");
693
694        let server = MockServer::start().await;
695        Mock::given(method("GET"))
696            .and(path("/v1/status"))
697            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
698                "websocket": { "connected": true },
699                "inbound": { "pending": 0, "deadLetter": 0 }
700            })))
701            .mount(&server)
702            .await;
703
704        save_connector_assignment(&config_dir, "alpha", &server.uri()).expect("assignment");
705        let doctor_config_dir = config_dir.clone();
706        let doctor_store = store.clone();
707        let result = tokio::task::spawn_blocking(move || {
708            run_openclaw_doctor(
709                &doctor_config_dir,
710                &doctor_store,
711                OpenclawDoctorOptions {
712                    openclaw_dir: Some(openclaw_dir),
713                    include_connector_runtime_check: true,
714                    ..OpenclawDoctorOptions::default()
715                },
716            )
717        })
718        .await
719        .expect("join")
720        .expect("doctor");
721        assert_eq!(result.status, DoctorStatus::Healthy);
722    }
723
724    #[test]
725    fn doctor_fails_when_selected_agent_marker_is_missing() {
726        let temp = TempDir::new().expect("temp dir");
727        let config_dir = temp.path().join("state");
728        std::fs::create_dir_all(&config_dir).expect("state dir");
729        let store = SqliteStore::open_path(temp.path().join("db.sqlite3")).expect("db");
730        let result = run_openclaw_doctor(
731            &config_dir,
732            &store,
733            OpenclawDoctorOptions {
734                include_connector_runtime_check: false,
735                ..OpenclawDoctorOptions::default()
736            },
737        )
738        .expect("doctor");
739        assert_eq!(result.status, DoctorStatus::Unhealthy);
740        assert!(
741            result
742                .checks
743                .iter()
744                .any(|check| check.id == "state.selectedAgent"
745                    && check.status == super::DoctorCheckStatus::Fail)
746        );
747    }
748}