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!(
324                "pending={} deadLetter={}",
325                inbound_pending, inbound_dead_letter
326            ),
327            None,
328            Some(
329                serde_json::json!({ "pendingCount": inbound_pending, "deadLetterCount": inbound_dead_letter }),
330            ),
331        );
332        let hook_failed = hook_last_attempt_status == Some("failed");
333        push_check(
334            checks,
335            "state.openclawHookHealth",
336            "OpenClaw hook health",
337            if hook_failed && inbound_pending > 0 {
338                DoctorCheckStatus::Fail
339            } else {
340                DoctorCheckStatus::Pass
341            },
342            if hook_failed && inbound_pending > 0 {
343                "connector reports failed OpenClaw hook replay with pending inbox backlog"
344            } else {
345                "OpenClaw hook replay is healthy"
346            },
347            if hook_failed && inbound_pending > 0 {
348                Some("Restart OpenClaw and connector runtime, then replay pending inbox messages.")
349            } else {
350                None
351            },
352            None,
353        );
354    } else {
355        push_check(
356            checks,
357            "state.connectorRuntime",
358            "Connector runtime",
359            DoctorCheckStatus::Fail,
360            "connector websocket is disconnected",
361            Some("Run `connector start <agentName>` or `connector service install <agentName>`."),
362            Some(serde_json::json!({ "connectorBaseUrl": base_url, "statusUrl": status_url })),
363        );
364        push_check(
365            checks,
366            "state.connectorInboundInbox",
367            "Connector inbound inbox",
368            DoctorCheckStatus::Fail,
369            "connector websocket is disconnected; inbox status may be stale",
370            Some("Start connector runtime and retry."),
371            Some(
372                serde_json::json!({ "pendingCount": inbound_pending, "deadLetterCount": inbound_dead_letter }),
373            ),
374        );
375        push_check(
376            checks,
377            "state.openclawHookHealth",
378            "OpenClaw hook health",
379            DoctorCheckStatus::Fail,
380            "connector websocket is disconnected; hook replay is unavailable",
381            Some("Restart connector runtime and OpenClaw."),
382            None,
383        );
384    }
385
386    Ok(())
387}
388
389/// TODO(clawdentity): document `run_openclaw_doctor`.
390#[allow(clippy::too_many_lines)]
391pub fn run_openclaw_doctor(
392    config_dir: &Path,
393    store: &SqliteStore,
394    options: OpenclawDoctorOptions,
395) -> Result<OpenclawDoctorResult> {
396    let openclaw_dir =
397        resolve_openclaw_dir(options.home_dir.as_deref(), options.openclaw_dir.as_deref())?;
398    let mut checks = Vec::<OpenclawDoctorCheck>::new();
399
400    let selected_agent = options
401        .selected_agent
402        .as_deref()
403        .map(str::trim)
404        .filter(|value| !value.is_empty())
405        .map(ToOwned::to_owned)
406        .or(read_selected_openclaw_agent(config_dir)?);
407
408    if let Some(agent_name) = selected_agent.clone() {
409        push_check(
410            &mut checks,
411            "state.selectedAgent",
412            "Selected agent",
413            DoctorCheckStatus::Pass,
414            format!("selected agent is `{agent_name}`"),
415            None,
416            Some(serde_json::json!({
417                "path": openclaw_agent_name_path(config_dir),
418                "agentName": agent_name
419            })),
420        );
421    } else {
422        push_check(
423            &mut checks,
424            "state.selectedAgent",
425            "Selected agent",
426            DoctorCheckStatus::Fail,
427            "selected agent marker is missing",
428            Some(
429                "Run `clawdentity provider setup --for openclaw --agent-name <agentName>` to persist selected agent.",
430            ),
431            Some(serde_json::json!({ "path": openclaw_agent_name_path(config_dir) })),
432        );
433    }
434
435    if let Some(agent_name) = selected_agent.as_deref() {
436        let agent_dir = config_dir.join(AGENTS_DIR).join(agent_name);
437        let ait_path = agent_dir.join(AIT_FILE_NAME);
438        let secret_path = agent_dir.join(SECRET_KEY_FILE_NAME);
439        let credentials_ok = read_non_empty_file(&ait_path).unwrap_or(false)
440            && read_non_empty_file(&secret_path).unwrap_or(false);
441        if credentials_ok {
442            push_check(
443                &mut checks,
444                "state.credentials",
445                "Agent credentials",
446                DoctorCheckStatus::Pass,
447                "local agent credentials are present",
448                None,
449                Some(serde_json::json!({
450                    "agentDir": agent_dir,
451                    "ait": ait_path,
452                    "secretKey": secret_path
453                })),
454            );
455        } else {
456            push_check(
457                &mut checks,
458                "state.credentials",
459                "Agent credentials",
460                DoctorCheckStatus::Fail,
461                "local agent credentials are missing or unreadable",
462                Some("Run `agent create <agentName>` and retry setup."),
463                Some(serde_json::json!({
464                    "agentDir": agent_dir,
465                    "ait": ait_path,
466                    "secretKey": secret_path
467                })),
468            );
469        }
470    } else {
471        push_check(
472            &mut checks,
473            "state.credentials",
474            "Agent credentials",
475            DoctorCheckStatus::Fail,
476            "cannot validate credentials without selected agent",
477            Some("Run `clawdentity provider setup --for openclaw --agent-name <agentName>` first."),
478            None,
479        );
480    }
481
482    match load_peers_config(store) {
483        Ok(peers) => {
484            if peers.peers.is_empty() {
485                push_check(
486                    &mut checks,
487                    "state.peers",
488                    "Paired peers",
489                    DoctorCheckStatus::Fail,
490                    "no paired peers found",
491                    Some(
492                        "Complete proxy pairing via `/pair/start` + `/pair/confirm` and persist local peer state before relay checks.",
493                    ),
494                    None,
495                );
496            } else if let Some(peer_alias) = options.peer_alias.as_deref().map(str::trim) {
497                if peers.peers.contains_key(peer_alias) {
498                    push_check(
499                        &mut checks,
500                        "state.peers",
501                        "Paired peers",
502                        DoctorCheckStatus::Pass,
503                        format!("peer alias `{peer_alias}` is configured"),
504                        None,
505                        Some(serde_json::json!({ "peerCount": peers.peers.len() })),
506                    );
507                } else {
508                    push_check(
509                        &mut checks,
510                        "state.peers",
511                        "Paired peers",
512                        DoctorCheckStatus::Fail,
513                        format!("peer alias `{peer_alias}` is not configured"),
514                        Some(
515                            "Choose an existing peer alias from local peer state (or generated peer snapshot).",
516                        ),
517                        Some(
518                            serde_json::json!({ "peerAliases": peers.peers.keys().collect::<Vec<_>>() }),
519                        ),
520                    );
521                }
522            } else {
523                push_check(
524                    &mut checks,
525                    "state.peers",
526                    "Paired peers",
527                    DoctorCheckStatus::Pass,
528                    format!("{} paired peer(s) configured", peers.peers.len()),
529                    None,
530                    Some(serde_json::json!({ "peerCount": peers.peers.len() })),
531                );
532            }
533        }
534        Err(error) => {
535            push_check(
536                &mut checks,
537                "state.peers",
538                "Paired peers",
539                DoctorCheckStatus::Fail,
540                format!("unable to load peers: {error}"),
541                Some("Repair local state and retry pairing."),
542                None,
543            );
544        }
545    }
546
547    let transform_path = openclaw_dir.join(RELAY_TRANSFORM_MODULE_RELATIVE_PATH);
548    if transform_path.exists() {
549        push_check(
550            &mut checks,
551            "state.transformMapping",
552            "Relay transform mapping",
553            DoctorCheckStatus::Pass,
554            "relay transform module is present",
555            None,
556            Some(serde_json::json!({ "transformPath": transform_path })),
557        );
558    } else {
559        push_check(
560            &mut checks,
561            "state.transformMapping",
562            "Relay transform mapping",
563            DoctorCheckStatus::Fail,
564            "relay transform module is missing",
565            Some("Install OpenClaw relay skill or run setup to restore mapping."),
566            Some(serde_json::json!({ "transformPath": transform_path })),
567        );
568    }
569
570    let runtime_config = load_relay_runtime_config(config_dir)?;
571    let hook_token = resolve_openclaw_hook_token(config_dir, None)?;
572    if hook_token.is_some() {
573        push_check(
574            &mut checks,
575            "state.hookToken",
576            "OpenClaw hook token",
577            DoctorCheckStatus::Pass,
578            "hook token is configured",
579            None,
580            runtime_config.map(|config| serde_json::to_value(config).unwrap_or(Value::Null)),
581        );
582    } else {
583        push_check(
584            &mut checks,
585            "state.hookToken",
586            "OpenClaw hook token",
587            DoctorCheckStatus::Fail,
588            "hook token is missing",
589            Some(
590                "Run `clawdentity provider setup --for openclaw --agent-name <agentName>` to persist runtime hook token.",
591            ),
592            None,
593        );
594    }
595
596    let pending_path = openclaw_dir.join(OPENCLAW_PENDING_DEVICES_RELATIVE_PATH);
597    let pending_count = parse_pending_approvals_count(&pending_path)?;
598    if pending_count == 0 {
599        push_check(
600            &mut checks,
601            "state.gatewayPairing",
602            "OpenClaw gateway pairing",
603            DoctorCheckStatus::Pass,
604            "no pending OpenClaw device approvals",
605            None,
606            Some(serde_json::json!({ "pendingPath": pending_path, "pendingCount": 0 })),
607        );
608    } else {
609        push_check(
610            &mut checks,
611            "state.gatewayPairing",
612            "OpenClaw gateway pairing",
613            DoctorCheckStatus::Fail,
614            format!("{pending_count} pending OpenClaw device approval(s)"),
615            Some("Approve pending devices in OpenClaw before relay diagnostics."),
616            Some(serde_json::json!({ "pendingPath": pending_path, "pendingCount": pending_count })),
617        );
618    }
619
620    if options.include_connector_runtime_check {
621        run_connector_checks(
622            &mut checks,
623            config_dir,
624            selected_agent.as_deref(),
625            options.connector_base_url.as_deref(),
626        )?;
627    } else {
628        push_check(
629            &mut checks,
630            "state.connectorRuntime",
631            "Connector runtime",
632            DoctorCheckStatus::Pass,
633            "connector runtime check skipped by caller",
634            None,
635            Some(serde_json::json!({ "defaultConnectorBaseUrl": OPENCLAW_DEFAULT_BASE_URL })),
636        );
637    }
638
639    let status = if checks
640        .iter()
641        .any(|check| check.status == DoctorCheckStatus::Fail)
642    {
643        DoctorStatus::Unhealthy
644    } else {
645        DoctorStatus::Healthy
646    };
647
648    Ok(OpenclawDoctorResult { status, checks })
649}
650
651#[cfg(test)]
652mod tests {
653    use tempfile::TempDir;
654    use wiremock::matchers::{method, path};
655    use wiremock::{Mock, MockServer, ResponseTemplate};
656
657    use super::super::setup::{
658        OpenclawRelayRuntimeConfig, save_connector_assignment, save_relay_runtime_config,
659        write_selected_openclaw_agent,
660    };
661    use crate::db::SqliteStore;
662    use crate::peers::{PersistPeerInput, persist_peer};
663
664    use super::{DoctorStatus, OpenclawDoctorOptions, run_openclaw_doctor};
665
666    #[tokio::test]
667    async fn doctor_reports_healthy_when_runtime_is_ready() {
668        let temp = TempDir::new().expect("temp dir");
669        let config_dir = temp.path().join("state");
670        std::fs::create_dir_all(config_dir.join("agents/alpha")).expect("agent dir");
671        std::fs::write(config_dir.join("agents/alpha/ait.jwt"), "token").expect("ait");
672        std::fs::write(config_dir.join("agents/alpha/secret.key"), "secret").expect("secret");
673        write_selected_openclaw_agent(&config_dir, "alpha").expect("selected");
674        save_relay_runtime_config(
675            &config_dir,
676            OpenclawRelayRuntimeConfig {
677                openclaw_base_url: "http://127.0.0.1:18789".to_string(),
678                openclaw_hook_token: Some("token".to_string()),
679                relay_transform_peers_path: None,
680                updated_at: None,
681            },
682        )
683        .expect("runtime config");
684
685        let openclaw_dir = temp.path().join("openclaw");
686        std::fs::create_dir_all(openclaw_dir.join("hooks/transforms")).expect("transform dir");
687        std::fs::write(
688            openclaw_dir.join("hooks/transforms/relay-to-peer.mjs"),
689            "export default {}",
690        )
691        .expect("transform");
692        std::fs::create_dir_all(openclaw_dir.join("devices")).expect("devices dir");
693        std::fs::write(openclaw_dir.join("devices/pending.json"), "[]").expect("pending");
694
695        let store = SqliteStore::open_path(temp.path().join("db.sqlite3")).expect("db");
696        let _ = persist_peer(
697            &store,
698            PersistPeerInput {
699                alias: Some("peer-alpha".to_string()),
700                did: "did:cdi:registry.clawdentity.com:agent:01HF7YAT00W6W7CM7N3W5FDXT4"
701                    .to_string(),
702                proxy_url: "https://proxy.example/hooks/agent".to_string(),
703                agent_name: Some("alpha".to_string()),
704                human_name: Some("alice".to_string()),
705            },
706        )
707        .expect("peer");
708
709        let server = MockServer::start().await;
710        Mock::given(method("GET"))
711            .and(path("/v1/status"))
712            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
713                "websocket": { "connected": true },
714                "inbound": { "pending": 0, "deadLetter": 0 }
715            })))
716            .mount(&server)
717            .await;
718
719        save_connector_assignment(&config_dir, "alpha", &server.uri()).expect("assignment");
720        let doctor_config_dir = config_dir.clone();
721        let doctor_store = store.clone();
722        let result = tokio::task::spawn_blocking(move || {
723            run_openclaw_doctor(
724                &doctor_config_dir,
725                &doctor_store,
726                OpenclawDoctorOptions {
727                    openclaw_dir: Some(openclaw_dir),
728                    include_connector_runtime_check: true,
729                    ..OpenclawDoctorOptions::default()
730                },
731            )
732        })
733        .await
734        .expect("join")
735        .expect("doctor");
736        assert_eq!(result.status, DoctorStatus::Healthy);
737    }
738
739    #[test]
740    fn doctor_fails_when_selected_agent_marker_is_missing() {
741        let temp = TempDir::new().expect("temp dir");
742        let config_dir = temp.path().join("state");
743        std::fs::create_dir_all(&config_dir).expect("state dir");
744        let store = SqliteStore::open_path(temp.path().join("db.sqlite3")).expect("db");
745        let result = run_openclaw_doctor(
746            &config_dir,
747            &store,
748            OpenclawDoctorOptions {
749                include_connector_runtime_check: false,
750                ..OpenclawDoctorOptions::default()
751            },
752        )
753        .expect("doctor");
754        assert_eq!(result.status, DoctorStatus::Unhealthy);
755        assert!(
756            result
757                .checks
758                .iter()
759                .any(|check| check.id == "state.selectedAgent"
760                    && check.status == super::DoctorCheckStatus::Fail)
761        );
762    }
763}