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