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(
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/// TODO(clawdentity): document `run_openclaw_doctor`.
389#[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}