1use std::path::{Path, PathBuf};
14
15use serde_json::{json, Map, Value};
16use sha2::{Digest, Sha256};
17
18use crate::cli::blank_is_unset;
19use crate::clock::Clock;
20use crate::compact::redact_text;
21use crate::engine::Engine;
22use crate::error::{ErrorCode, ToolError};
23use crate::host::Host;
24use crate::liveness::{side_liveness, Liveness, Side};
25use crate::openapi::TableSource;
26use crate::pairing::{list_records, read_record, Record};
27use crate::settings::{self, Note, NoteReason, Source};
28use crate::transport::cli::resolve_binary;
29use crate::transport::{parse_cli_version, service_running, Lifecycle, Transport};
30
31pub const CHECK_IDS: [&str; 18] = [
33 "ctl_home_override",
34 "binary",
35 "version_range",
36 "registration",
37 "server_info",
38 "wait_route",
39 "pairing",
40 "lock_source",
41 "env_vs_user_file",
42 "windows_source_old",
43 "launcher_link",
44 "server_url_password",
45 "opencode_password_set",
46 "log",
47 "orphaned_controllers",
48 "orphaned_workers",
49 "settings_notes",
50 "settings_file_hash",
51];
52
53pub struct DoctorCtx<'a> {
54 pub bin_dir: &'a Path,
56 pub running_exe: &'a Path,
58 pub version: &'a str,
60 pub user_home: &'a Path,
62 pub path: Option<String>,
64 pub registration_file: Option<&'a Path>,
66 pub config_file: Option<&'a Path>,
68 pub env: &'a dyn Fn(&str) -> Option<String>,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum Status {
74 Ok,
75 Warn,
76 Fail,
77 Skip,
78}
79
80impl Status {
81 pub fn as_str(self) -> &'static str {
82 match self {
83 Status::Ok => "ok",
84 Status::Warn => "warn",
85 Status::Fail => "fail",
86 Status::Skip => "skip",
87 }
88 }
89}
90
91#[derive(Debug, Clone)]
92pub struct Check {
93 pub id: &'static str,
94 pub status: Status,
95 pub message: String,
96 pub details: Option<Value>,
97}
98
99impl Check {
100 fn new(id: &'static str, status: Status, variant: &str, subs: &[(&str, &str)]) -> Check {
101 Check {
102 id,
103 status,
104 message: message(id, status, variant, subs),
105 details: None,
106 }
107 }
108
109 fn details(mut self, d: Value) -> Check {
110 self.details = Some(d);
111 self
112 }
113
114 pub fn to_json(&self) -> Value {
115 let mut v = json!({"id": self.id, "status": self.status.as_str(), "message": self.message});
116 if let Some(d) = &self.details {
117 v["details"] = d.clone();
118 }
119 v
120 }
121}
122
123const SIDES: &str = "pair <pair>: tui <tui>; controller <controller>; generation <generation> sentinel <sentinel>; session <session> <session_state>";
124
125const MESSAGES: &[(&str, &str, &str, &str)] = &[
128 ("ctl_home_override", "ok", "", "OPENCODE_CTL_HOME is not set"),
129 ("ctl_home_override", "warn", "", "OPENCODE_CTL_HOME is set to <path>: the settings file and the pair files are read from there"),
130 ("binary", "ok", "", "opencode found at <path>"),
131 ("binary", "fail", "", "opencode executable not found: install opencode or set OPENCODE_CTL_BIN"),
132 ("binary", "fail", "missing_explicit", "the configured opencode_bin <path> is not a file"),
133 ("version_range", "skip", "", "binary failed"),
134 ("version_range", "fail", "unparsable", "unparsable version output"),
135 ("version_range", "fail", "below", "<cli> is below 2.0"),
136 ("version_range", "fail", "cli_error", "cannot run opencode --version: <message>"),
137 ("version_range", "warn", "server_outside_range", "server <server> is outside the tested 2.0.x range"),
138 ("version_range", "ok", "cli_only", "server version unknown; CLI <cli>"),
139 ("version_range", "ok", "", "CLI <cli> and server <server> are within the tested 2.0.x range"),
140 ("registration", "skip", "", "no registration file at <path>"),
141 ("registration", "skip", "no_path", "no registration file location is known"),
142 ("registration", "warn", "unreadable", "registration file <path> is not a valid service registration"),
143 ("registration", "fail", "", "registered pid <pid> is dead"),
144 ("registration", "warn", "disabled", "service disabled in <config_file>: every CLI call runs a private standalone server"),
145 ("registration", "ok", "", "registered pid <pid> is alive"),
146 ("server_info", "ok", "", "server.info answered: version <version>, pid <pid>"),
147 ("server_info", "ok", "ok_started", "service was stopped; opencode service start brought it up"),
148 ("server_info", "fail", "fail_start", "service stopped and opencode service start failed: <stderr>"),
149 ("server_info", "fail", "fail_unreachable", "cannot reach <url>: <message>"),
150 ("server_info", "fail", "fail_auth", "server at <url> rejected the credentials: compare `opencode service get password` with OPENCODE_SERVER_PASSWORD"),
151 ("server_info", "fail", "fail_other", "server.info failed (<code>): <message>"),
152 ("wait_route", "skip", "", "server_info failed"),
153 ("wait_route", "ok", "", "experimental.session.wait is in the operation table"),
154 ("wait_route", "warn", "missing", "experimental.session.wait is not in the operation table: session_wait falls back to polling"),
155 ("wait_route", "warn", "unconfirmed", "the live operation table is unavailable, so experimental.session.wait could not be confirmed"),
156 ("pairing", "skip", "", "not paired: OPENCODE_CTL_PAIR is not set"),
157 ("pairing", "fail", "record", "no usable pair record for <pair>"),
158 ("pairing", "fail", "unreadable", "pair record for <pair> is unreadable: <error>"),
159 ("pairing", "fail", "session", SIDES),
160 ("pairing", "warn", "", SIDES),
161 ("pairing", "ok", "", SIDES),
162 ("lock_source", "ok", "", "permissions_locked=<locked> (<locked_source>); permission_policy=<policy> (<policy_source>)"),
163 ("env_vs_user_file", "ok", "", "no protected key's environment value differs from the user-level file"),
164 ("env_vs_user_file", "warn", "", "environment overrides the user-level file for: <keys>"),
165 ("windows_source_old", "skip", "off_windows", "not applicable off Windows"),
166 ("windows_source_old", "skip", "", "no launcher copy at <path>"),
167 ("windows_source_old", "ok", "", "opencode-pair.exe.source is valid and no .old leftover exists"),
168 ("windows_source_old", "warn", "", "launcher copy problems: <problems>"),
169 ("launcher_link", "skip", "", "not offered on this host"),
170 ("launcher_link", "ok", "", "<link> resolves to the running binary"),
171 ("launcher_link", "warn", "missing", "missing: <link> does not exist; run install-launcher"),
172 ("launcher_link", "warn", "stale", "stale: <link> does not match the running binary (version <version>); re-run install-launcher"),
173 ("launcher_link", "warn", "other_host", "other_host: <link> belongs to another host's binary; run install-launcher --force to take it over"),
174 ("launcher_link", "warn", "not_a_link", "not_a_link: <link> is not a link install-launcher made; it is left alone and install-launcher reports LAUNCHER_CONFLICT"),
175 ("server_url_password", "skip", "", "server_url is not set"),
176 ("server_url_password", "ok", "", "server_url is set and OPENCODE_SERVER_PASSWORD is present"),
177 ("server_url_password", "warn", "", "server_url is set (<url>) but OPENCODE_SERVER_PASSWORD is not: the CLI cannot authenticate"),
178 ("opencode_password_set", "ok", "", "OPENCODE_PASSWORD is not set"),
179 ("opencode_password_set", "warn", "", "OPENCODE_PASSWORD is set: the CLI reads it before OPENCODE_SERVER_PASSWORD"),
180 ("log", "skip", "", "no log file in <dir>"),
181 ("log", "ok", "", "newest log <path>: no error line"),
182 ("log", "warn", "", "newest log <path>: last error: <line>"),
183 ("orphaned_controllers", "ok", "", "no pair has a dead controller beside a live TUI"),
184 ("orphaned_controllers", "warn", "", "controller dead while the TUI is alive for pair(s): <ids>"),
185 ("orphaned_workers", "skip", "", "server_info failed"),
186 ("orphaned_workers", "ok", "", "no active session belongs to a pair without a living side"),
187 ("orphaned_workers", "warn", "", "active session(s) whose pair has no living side: <ids>"),
188 ("orphaned_workers", "warn", "api", "cannot list active sessions: <message>"),
189 ("settings_notes", "ok", "", "no settings entry was ignored or clamped"),
190 ("settings_notes", "warn", "", "<count> settings entries ignored or clamped: <list>"),
191 ("settings_file_hash", "skip", "", "no user-level settings file at <path>"),
192 ("settings_file_hash", "ok", "", "<path>: sha256 <hash>, modified <mtime_ms> ms since the epoch"),
193 ("settings_file_hash", "warn", "", "cannot read <path>: <message>"),
194];
195
196fn message(id: &str, status: Status, variant: &str, subs: &[(&str, &str)]) -> String {
197 let status = status.as_str();
198 let template = MESSAGES
199 .iter()
200 .find(|(i, s, v, _)| *i == id && *s == status && *v == variant)
201 .unwrap_or_else(|| panic!("no doctor message for ({id}, {status}, {variant})"))
202 .3;
203 let mut out = template.to_string();
204 for (name, value) in subs {
205 out = out.replace(&format!("<{name}>"), value);
206 }
207 out
208}
209
210fn source_name(s: Source) -> &'static str {
213 match s {
214 Source::Env => "env",
215 Source::UserFile => "user file",
216 Source::WorkspaceFile => "workspace file",
217 Source::Default => "default",
218 }
219}
220
221fn hex(bytes: &[u8]) -> String {
222 bytes.iter().map(|b| format!("{b:02x}")).collect()
223}
224
225fn first_line(s: &str) -> &str {
226 s.lines().next().unwrap_or("")
227}
228
229fn mtime_ms(meta: &std::fs::Metadata) -> Option<i64> {
230 let d = meta
231 .modified()
232 .ok()?
233 .duration_since(std::time::UNIX_EPOCH)
234 .ok()?;
235 Some(d.as_millis() as i64)
236}
237
238pub fn default_registration_file(
241 get: &dyn Fn(&str) -> Option<String>,
242 user_home: &Path,
243) -> PathBuf {
244 let base = blank_is_unset(get("XDG_STATE_HOME"))
245 .map(PathBuf::from)
246 .unwrap_or_else(|| user_home.join(".local").join("state"));
247 base.join("opencode").join("service.json")
248}
249
250pub fn default_config_file(get: &dyn Fn(&str) -> Option<String>, user_home: &Path) -> PathBuf {
253 if let Some(dir) = blank_is_unset(get("OPENCODE_CONFIG_DIR")) {
254 return PathBuf::from(dir).join("service.json");
255 }
256 let base = blank_is_unset(get("XDG_CONFIG_HOME"))
257 .map(PathBuf::from)
258 .unwrap_or_else(|| user_home.join(".config"));
259 base.join("opencode").join("service.json")
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub enum LinkStatus {
266 Fresh,
267 Stale,
268 OtherHost,
269 Missing,
270 NotALink,
271}
272
273impl LinkStatus {
274 fn word(self) -> &'static str {
275 match self {
276 LinkStatus::Fresh => "fresh",
277 LinkStatus::Stale => "stale",
278 LinkStatus::OtherHost => "other_host",
279 LinkStatus::Missing => "missing",
280 LinkStatus::NotALink => "not_a_link",
281 }
282 }
283}
284
285const BINARY_NAMES: [&str; 3] = [
286 "opencode-ctl-cc-mcp",
287 "opencode-ctl-qwen-mcp",
288 "opencode-ctl-agy-mcp",
289];
290
291fn link_name() -> &'static str {
292 if cfg!(windows) {
293 "opencode-pair.exe"
294 } else {
295 "opencode-pair"
296 }
297}
298
299#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct SourceFile {
303 pub path: String,
304 pub version: String,
305}
306
307pub fn parse_source_file(raw: &str) -> Option<SourceFile> {
308 let (mut path, mut version) = (None, None);
309 for line in raw.trim_start_matches('\u{feff}').lines() {
310 match line.split_once('=') {
311 Some(("path", v)) if !v.is_empty() => path = Some(v.to_string()),
312 Some(("version", v)) if !v.is_empty() => version = Some(v.to_string()),
313 _ => {}
314 }
315 }
316 Some(SourceFile {
317 path: path?,
318 version: version?,
319 })
320}
321
322fn stem_of(path: &str) -> String {
324 let name = path.rsplit(['/', '\\']).next().unwrap_or("");
325 let lower = name.to_ascii_lowercase();
326 match lower.strip_suffix(".exe") {
327 Some(_) => name[..name.len() - 4].to_string(),
328 None => name.to_string(),
329 }
330}
331
332fn same_path(a: &str, b: &str) -> bool {
333 if cfg!(windows) {
334 a.eq_ignore_ascii_case(b)
335 } else {
336 a == b
337 }
338}
339
340fn name_status(stem: &str, host: Host) -> Option<LinkStatus> {
342 if stem == host.binary_name() {
343 Some(LinkStatus::Stale)
344 } else if BINARY_NAMES.contains(&stem) {
345 Some(LinkStatus::OtherHost)
346 } else {
347 None
348 }
349}
350
351pub fn windows_link_status(
354 copy_exists: bool,
355 source: Option<&SourceFile>,
356 running_exe: &Path,
357 host: Host,
358 version: &str,
359) -> LinkStatus {
360 if !copy_exists {
361 return LinkStatus::Missing;
362 }
363 let Some(src) = source else {
364 return LinkStatus::NotALink;
365 };
366 if same_path(&src.path, &running_exe.to_string_lossy()) {
367 return if src.version == version {
368 LinkStatus::Fresh
369 } else {
370 LinkStatus::Stale
371 };
372 }
373 name_status(&stem_of(&src.path), host).unwrap_or(LinkStatus::NotALink)
374}
375
376pub fn launcher_link_status(
378 bin_dir: &Path,
379 running_exe: &Path,
380 host: Host,
381 version: &str,
382) -> LinkStatus {
383 let link = bin_dir.join(link_name());
384 #[cfg(windows)]
385 {
386 let source = std::fs::read_to_string(bin_dir.join("opencode-pair.exe.source"))
387 .ok()
388 .and_then(|raw| parse_source_file(&raw));
389 windows_link_status(link.is_file(), source.as_ref(), running_exe, host, version)
390 }
391 #[cfg(not(windows))]
392 {
393 let _ = version;
394 let meta = match std::fs::symlink_metadata(&link) {
395 Ok(m) => m,
396 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return LinkStatus::Missing,
397 Err(_) => return LinkStatus::NotALink,
398 };
399 if !meta.file_type().is_symlink() {
400 return LinkStatus::NotALink;
401 }
402 let Ok(target) = std::fs::read_link(&link) else {
403 return LinkStatus::NotALink;
404 };
405 let target = bin_dir.join(target);
406 let resolves = target == running_exe
407 || matches!(
408 (std::fs::canonicalize(&target), std::fs::canonicalize(running_exe)),
409 (Ok(a), Ok(b)) if a == b
410 );
411 if resolves {
412 return LinkStatus::Fresh;
413 }
414 let stem = target
415 .file_name()
416 .map(|n| n.to_string_lossy().into_owned())
417 .unwrap_or_default();
418 name_status(&stem, host).unwrap_or(LinkStatus::NotALink)
419 }
420}
421
422pub fn windows_source_old_check(bin_dir: &Path, windows: bool) -> Check {
425 const ID: &str = "windows_source_old";
426 if !windows {
427 return Check::new(ID, Status::Skip, "off_windows", &[]);
428 }
429 let copy = bin_dir.join("opencode-pair.exe");
430 if !copy.is_file() {
431 return Check::new(
432 ID,
433 Status::Skip,
434 "",
435 &[("path", ©.display().to_string())],
436 );
437 }
438 let source = bin_dir.join("opencode-pair.exe.source");
439 let old = bin_dir.join("opencode-pair.exe.old");
440 let mut problems = Vec::new();
441 let parsed = std::fs::read_to_string(&source)
442 .ok()
443 .and_then(|raw| parse_source_file(&raw));
444 if parsed.is_none() {
445 problems.push(format!(
446 "{} is missing or unparsable (re-run install-launcher)",
447 source.display()
448 ));
449 }
450 if old.exists() {
451 problems.push(format!(
452 "{} is left over from an in-use replacement (gc deletes it)",
453 old.display()
454 ));
455 }
456 if problems.is_empty() {
457 Check::new(ID, Status::Ok, "", &[])
458 } else {
459 Check::new(ID, Status::Warn, "", &[("problems", &problems.join("; "))])
460 }
461}
462
463fn launcher_link_check<T: Transport, C: Clock>(engine: &Engine<T, C>, ctx: &DoctorCtx) -> Check {
464 const ID: &str = "launcher_link";
465 if !engine.host.offers_launcher() {
466 return Check::new(ID, Status::Skip, "", &[]);
467 }
468 let link = ctx.bin_dir.join(link_name());
469 let status = launcher_link_status(ctx.bin_dir, ctx.running_exe, engine.host, ctx.version);
470 let l = link.display().to_string();
471 let subs = [("link", l.as_str()), ("version", ctx.version)];
472 let (st, variant) = match status {
473 LinkStatus::Fresh => (Status::Ok, ""),
474 LinkStatus::Missing => (Status::Warn, "missing"),
475 LinkStatus::Stale => (Status::Warn, "stale"),
476 LinkStatus::OtherHost => (Status::Warn, "other_host"),
477 LinkStatus::NotALink => (Status::Warn, "not_a_link"),
478 };
479 Check::new(ID, st, variant, &subs).details(json!({"link": status.word(), "path": l}))
480}
481
482fn side_words(side: Side, l: Liveness) -> String {
485 let why = match (l, side) {
486 (Liveness::Alive, Side::Tui) => "the tab is running",
487 (Liveness::Alive, Side::Controller) => "the controller is running",
488 (Liveness::Dead, _) => "the process is gone",
489 (Liveness::Starting, _) => "launched less than 30 s ago, no sentinel yet",
490 (Liveness::Unknown, _) => "the sentinel cannot be verified",
491 (Liveness::None, Side::Tui) => "no tab was requested",
492 (Liveness::None, Side::Controller) => "no controller was recorded",
493 };
494 format!("{} ({why})", l.as_str())
495}
496
497struct ServerState {
500 ok: bool,
501 version: Option<String>,
502}
503
504fn ctl_home_override(ctx: &DoctorCtx) -> Check {
505 match blank_is_unset((ctx.env)("OPENCODE_CTL_HOME")) {
506 Some(p) => Check::new("ctl_home_override", Status::Warn, "", &[("path", &p)]),
507 None => Check::new("ctl_home_override", Status::Ok, "", &[]),
508 }
509}
510
511fn binary_check<T: Transport, C: Clock>(engine: &Engine<T, C>, ctx: &DoctorCtx) -> Check {
512 let explicit = engine.settings.opencode_bin.as_deref();
513 match resolve_binary(explicit, ctx.path.as_deref(), ctx.user_home) {
514 Ok(p) if explicit.is_some() && !p.is_file() => Check::new(
515 "binary",
516 Status::Fail,
517 "missing_explicit",
518 &[("path", &p.display().to_string())],
519 ),
520 Ok(p) => Check::new(
521 "binary",
522 Status::Ok,
523 "",
524 &[("path", &p.display().to_string())],
525 ),
526 Err(_) => Check::new("binary", Status::Fail, "", &[]),
527 }
528}
529
530fn registration_check<T: Transport, C: Clock>(engine: &Engine<T, C>, ctx: &DoctorCtx) -> Check {
531 const ID: &str = "registration";
532 let disabled = ctx
535 .config_file
536 .and_then(|f| std::fs::read_to_string(f).ok())
537 .and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
538 .is_some_and(|v| v.get("disabled") == Some(&Value::Bool(true)));
539 if disabled {
540 let cfg = ctx.config_file.map(|f| f.display().to_string());
541 return Check::new(
542 ID,
543 Status::Warn,
544 "disabled",
545 &[("config_file", cfg.as_deref().unwrap_or(""))],
546 );
547 }
548 let Some(file) = ctx.registration_file else {
549 return Check::new(ID, Status::Skip, "no_path", &[]);
550 };
551 let shown = file.display().to_string();
552 let Ok(raw) = std::fs::read_to_string(file) else {
553 return if file.exists() {
554 Check::new(ID, Status::Warn, "unreadable", &[("path", &shown)])
555 } else {
556 Check::new(ID, Status::Skip, "", &[("path", &shown)])
557 };
558 };
559 let parsed: Option<(String, u64)> = serde_json::from_str::<Value>(&raw).ok().and_then(|v| {
560 let url = v.get("url")?.as_str()?.to_string();
561 let pid = v.get("pid")?.as_u64().filter(|p| *p > 0)?;
562 Some((url, pid))
563 });
564 let Some((url, pid)) = parsed else {
565 return Check::new(ID, Status::Warn, "unreadable", &[("path", &shown)]);
566 };
567 let pid_s = pid.to_string();
568 let alive = u32::try_from(pid).is_ok_and(|p| engine.probe.is_alive(p));
569 if !alive {
570 return Check::new(ID, Status::Fail, "", &[("pid", &pid_s)])
571 .details(json!({"url": redact_text(&url)}));
572 }
573 Check::new(ID, Status::Ok, "", &[("pid", &pid_s)])
574}
575
576async fn server_info_check<T: Transport, C: Clock>(engine: &Engine<T, C>) -> (Check, ServerState) {
577 const ID: &str = "server_info";
578 let down = |c: Check| {
579 (
580 c,
581 ServerState {
582 ok: false,
583 version: None,
584 },
585 )
586 };
587 let other = |e: &ToolError| {
588 Check::new(
589 ID,
590 Status::Fail,
591 "fail_other",
592 &[("code", e.code.as_str()), ("message", &e.message)],
593 )
594 };
595 let managed = engine.settings.server_url.is_none();
596 let mut url = engine.settings.server_url.clone().unwrap_or_default();
597 let mut started = false;
598 if managed {
599 match engine.lifecycle(Lifecycle::Status).await {
600 Err(e) => return down(other(&e)),
601 Ok(out) if service_running(&out) => url = out.trim().to_string(),
602 Ok(_) => match engine.lifecycle(Lifecycle::Start).await {
603 Err(e) => {
604 return down(Check::new(
605 ID,
606 Status::Fail,
607 "fail_start",
608 &[("stderr", first_line(&e.message))],
609 ))
610 }
611 Ok(out) => {
612 started = true;
613 url = out.trim().to_string();
614 }
615 },
616 }
617 }
618 match engine.call_json("server.info", Vec::new(), None).await {
619 Ok((info, _, _)) => {
620 engine.invalidate_if_changed(&info).await;
621 let version = info
622 .get("version")
623 .and_then(Value::as_str)
624 .map(str::to_string);
625 let pid = info.get("pid").and_then(Value::as_u64);
626 let pid_s = pid.map_or("unknown".to_string(), |p| p.to_string());
627 let details = json!({"url": url, "version": version, "pid": pid});
628 let check = if started {
629 Check::new(ID, Status::Ok, "ok_started", &[]).details(
630 json!({"url": url, "version": version, "pid": pid, "started_by_doctor": true}),
631 )
632 } else {
633 Check::new(
634 ID,
635 Status::Ok,
636 "",
637 &[
638 ("version", version.as_deref().unwrap_or("unknown")),
639 ("pid", &pid_s),
640 ],
641 )
642 .details(details)
643 };
644 (check, ServerState { ok: true, version })
645 }
646 Err(e) => down(match e.code {
647 ErrorCode::ServiceDown => Check::new(
648 ID,
649 Status::Fail,
650 "fail_unreachable",
651 &[("url", &url), ("message", &e.message)],
652 ),
653 ErrorCode::AuthFailed => Check::new(ID, Status::Fail, "fail_auth", &[("url", &url)]),
654 _ => other(&e),
655 }),
656 }
657}
658
659async fn version_range_check<T: Transport, C: Clock>(
660 engine: &Engine<T, C>,
661 binary: &Check,
662 server: &ServerState,
663) -> Check {
664 const ID: &str = "version_range";
665 if binary.status != Status::Ok {
666 return Check::new(ID, Status::Skip, "", &[]);
667 }
668 let raw = match engine.cli_version().await {
669 Ok(raw) => raw,
670 Err(e) => return Check::new(ID, Status::Fail, "cli_error", &[("message", &e.message)]),
671 };
672 let major = parse_cli_version(&raw).and_then(|v| {
673 let digits: String = v.chars().take_while(char::is_ascii_digit).collect();
674 digits.parse::<u64>().ok().map(|m| (v, m))
675 });
676 let Some((cli, major)) = major else {
677 return Check::new(ID, Status::Fail, "unparsable", &[]);
678 };
679 if major < 2 {
680 return Check::new(ID, Status::Fail, "below", &[("cli", &cli)]);
681 }
682 match server.version.as_deref().filter(|_| server.ok) {
683 None => Check::new(ID, Status::Ok, "cli_only", &[("cli", &cli)]),
684 Some(sv) => {
685 let bare = sv.strip_prefix('v').unwrap_or(sv);
686 if bare == "2.0" || bare.starts_with("2.0.") {
687 Check::new(ID, Status::Ok, "", &[("cli", &cli), ("server", sv)])
688 } else {
689 Check::new(ID, Status::Warn, "server_outside_range", &[("server", sv)])
690 }
691 }
692 }
693}
694
695async fn wait_route_check<T: Transport, C: Clock>(
696 engine: &Engine<T, C>,
697 server: &ServerState,
698) -> Check {
699 const ID: &str = "wait_route";
700 if !server.ok {
701 return Check::new(ID, Status::Skip, "", &[]);
702 }
703 let (table, source, _) = engine.operation_table().await;
705 if source != TableSource::Live {
706 Check::new(ID, Status::Warn, "unconfirmed", &[])
707 } else if table.get("experimental.session.wait").is_some() {
708 Check::new(ID, Status::Ok, "", &[])
709 } else {
710 Check::new(ID, Status::Warn, "missing", &[])
711 }
712}
713
714async fn pairing_check<T: Transport, C: Clock>(
715 engine: &Engine<T, C>,
716 server: &ServerState,
717) -> Check {
718 const ID: &str = "pairing";
719 let Some(pair) = engine.env.pair.as_deref() else {
720 return Check::new(ID, Status::Skip, "", &[]);
721 };
722 let home = &engine.env.home;
723 let record = match read_record(home, pair) {
724 Ok(Some(r)) => r,
725 Ok(None) => return Check::new(ID, Status::Fail, "record", &[("pair", pair)]),
726 Err(e) => {
727 return Check::new(
728 ID,
729 Status::Fail,
730 "unreadable",
731 &[("pair", pair), ("error", &e.to_string())],
732 )
733 }
734 };
735 let now = engine.clock().now_ms();
736 let tui = side_liveness(Side::Tui, &record, home, &*engine.probe, now);
737 let controller = side_liveness(Side::Controller, &record, home, &*engine.probe, now);
738 let sentinel = if !record.tab {
739 "not applicable (no tab)"
740 } else if home
741 .join(format!("pair.{pair}.g{}.tui-pid", record.generation))
742 .is_file()
743 {
744 "present"
745 } else {
746 "missing"
747 };
748 let session_id = engine
749 .env
750 .session
751 .clone()
752 .unwrap_or_else(|| record.session_id.clone());
753 let (state, state_key) = if !server.ok {
754 ("skipped".to_string(), "skipped")
755 } else {
756 match engine
757 .call_json(
758 "session.get",
759 vec![("sessionID".to_string(), session_id.clone())],
760 None,
761 )
762 .await
763 {
764 Ok(_) => ("found".to_string(), "found"),
765 Err(e) if e.code == ErrorCode::SessionNotFound => {
766 ("not found".to_string(), "not_found")
767 }
768 Err(e) => (format!("lookup failed ({})", e.code.as_str()), "error"),
769 }
770 };
771 let faulty = |l: Liveness| matches!(l, Liveness::Dead | Liveness::Unknown);
772 let (status, variant) = if state_key == "not_found" {
773 (Status::Fail, "session")
774 } else if faulty(tui) || faulty(controller) || sentinel == "missing" || state_key == "error" {
775 (Status::Warn, "")
776 } else {
777 (Status::Ok, "")
778 };
779 let (tui_s, ctl_s) = (
780 side_words(Side::Tui, tui),
781 side_words(Side::Controller, controller),
782 );
783 let generation = record.generation.to_string();
784 Check::new(
785 ID,
786 status,
787 variant,
788 &[
789 ("pair", pair),
790 ("tui", &tui_s),
791 ("controller", &ctl_s),
792 ("generation", &generation),
793 ("sentinel", sentinel),
794 ("session", &session_id),
795 ("session_state", &state),
796 ],
797 )
798 .details(json!({
799 "pair_id": pair,
800 "session_id": session_id,
801 "tui": tui.as_str(),
802 "controller": controller.as_str(),
803 "generation": record.generation,
804 "sentinel": sentinel,
805 "session": state_key,
806 }))
807}
808
809fn lock_source_check<T: Transport, C: Clock>(engine: &Engine<T, C>) -> Check {
810 let s = &engine.settings;
811 let src = |k: &str| source_name(s.sources.get(k).copied().unwrap_or(Source::Default));
812 Check::new(
813 "lock_source",
814 Status::Ok,
815 "",
816 &[
817 ("locked", &s.permissions_locked.to_string()),
818 ("locked_source", src("permissions_locked")),
819 ("policy", s.permission_policy.as_str()),
820 ("policy_source", src("permission_policy")),
821 ],
822 )
823}
824
825const PROTECTED: [&str; 7] = [
827 "opencode_bin",
828 "server_url",
829 "default_model",
830 "default_agent",
831 "permission_policy",
832 "permissions_locked",
833 "wait_max_s",
834];
835
836fn env_vs_user_file<T: Transport, C: Clock>(engine: &Engine<T, C>, ctx: &DoctorCtx) -> Check {
837 const ID: &str = "env_vs_user_file";
838 let file = std::fs::read_to_string(engine.env.home.join("settings.local.md"))
839 .map(|raw| settings::parse_frontmatter(&raw))
840 .unwrap_or_default();
841 let mut found = Vec::new();
842 for key in PROTECTED {
843 let name = settings::env_name(key);
844 let env = if key == "server_url" {
846 (ctx.env)(&name)
847 } else {
848 blank_is_unset((ctx.env)(&name))
849 };
850 let from_file = file
851 .iter()
852 .find(|(k, v)| k == key && !v.trim().is_empty())
853 .map(|(_, v)| v.as_str());
854 if let (Some(e), Some(f)) = (env, from_file) {
855 if e.trim() != f.trim() {
856 found.push((key, redact_text(&e), redact_text(f)));
857 }
858 }
859 }
860 if found.is_empty() {
861 return Check::new(ID, Status::Ok, "", &[]);
862 }
863 let list: Vec<String> = found
864 .iter()
865 .map(|(k, e, f)| format!("{k} (env \"{e}\", file \"{f}\")"))
866 .collect();
867 Check::new(ID, Status::Warn, "", &[("keys", &list.join(", "))]).details(json!({
868 "keys": found.iter().map(|(k, e, f)| json!({"key": k, "env": e, "file": f})).collect::<Vec<_>>(),
869 }))
870}
871
872fn server_url_password<T: Transport, C: Clock>(engine: &Engine<T, C>, ctx: &DoctorCtx) -> Check {
873 const ID: &str = "server_url_password";
874 let Some(url) = engine.settings.server_url.as_deref() else {
875 return Check::new(ID, Status::Skip, "", &[]);
876 };
877 if blank_is_unset((ctx.env)("OPENCODE_SERVER_PASSWORD")).is_some() {
878 Check::new(ID, Status::Ok, "", &[])
879 } else {
880 Check::new(ID, Status::Warn, "", &[("url", &redact_text(url))])
881 }
882}
883
884fn opencode_password_set(ctx: &DoctorCtx) -> Check {
885 if blank_is_unset((ctx.env)("OPENCODE_PASSWORD")).is_some() {
886 Check::new("opencode_password_set", Status::Warn, "", &[])
887 } else {
888 Check::new("opencode_password_set", Status::Ok, "", &[])
889 }
890}
891
892fn log_check<T: Transport, C: Clock>(engine: &Engine<T, C>) -> Check {
893 const ID: &str = "log";
894 let dir = engine.env.home.join("logs");
895 let newest = std::fs::read_dir(&dir)
896 .into_iter()
897 .flatten()
898 .flatten()
899 .filter(|e| {
900 let n = e.file_name().to_string_lossy().into_owned();
901 n.ends_with(".log") && (n.starts_with("serve-") || n.starts_with("pair-"))
902 })
903 .filter_map(|e| {
904 let m = e.metadata().ok()?;
905 Some((mtime_ms(&m)?, e.file_name(), e.path()))
906 })
907 .max_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1)));
908 let Some((_, _, path)) = newest else {
909 return Check::new(ID, Status::Skip, "", &[("dir", &dir.display().to_string())]);
910 };
911 let shown = path.display().to_string();
912 let text = std::fs::read(&path)
913 .map(|b| String::from_utf8_lossy(&b).into_owned())
914 .unwrap_or_default();
915 let last = text
916 .lines()
917 .rev()
918 .find(|l| l.split_whitespace().any(|w| w == "ERROR"));
919 match last {
920 None => Check::new(ID, Status::Ok, "", &[("path", &shown)]),
921 Some(line) => {
922 let line: String = redact_text(line.trim()).chars().take(300).collect();
923 Check::new(ID, Status::Warn, "", &[("path", &shown), ("line", &line)])
924 .details(json!({"path": shown, "last_error": line}))
925 }
926 }
927}
928
929fn orphaned_controllers<T: Transport, C: Clock>(engine: &Engine<T, C>) -> Check {
930 let home = &engine.env.home;
931 let now = engine.clock().now_ms();
932 let ids: Vec<String> = list_records(home)
933 .into_iter()
934 .filter(|r| {
935 side_liveness(Side::Controller, r, home, &*engine.probe, now) == Liveness::Dead
936 && side_liveness(Side::Tui, r, home, &*engine.probe, now) == Liveness::Alive
937 })
938 .map(|r| r.pair_id)
939 .collect();
940 if ids.is_empty() {
941 Check::new("orphaned_controllers", Status::Ok, "", &[])
942 } else {
943 Check::new(
944 "orphaned_controllers",
945 Status::Warn,
946 "",
947 &[("ids", &ids.join(", "))],
948 )
949 .details(json!({"pairs": ids}))
950 }
951}
952
953async fn orphaned_workers<T: Transport, C: Clock>(
954 engine: &Engine<T, C>,
955 server: &ServerState,
956) -> Check {
957 const ID: &str = "orphaned_workers";
958 if !server.ok {
959 return Check::new(ID, Status::Skip, "", &[]);
960 }
961 let active = match engine.call_json("session.active", Vec::new(), None).await {
962 Ok((data, _, _)) => data,
963 Err(e) => return Check::new(ID, Status::Warn, "api", &[("message", &e.message)]),
964 };
965 let home = &engine.env.home;
966 let now = engine.clock().now_ms();
967 let records = list_records(home);
968 let living = |l: Liveness| matches!(l, Liveness::Alive | Liveness::Starting);
969 let mut ids: Vec<String> = active
970 .as_object()
971 .map(|o| o.keys().cloned().collect())
972 .unwrap_or_default();
973 ids.sort();
974 ids.retain(|id| {
975 let mine: Vec<&Record> = records.iter().filter(|r| &r.session_id == id).collect();
976 !mine.is_empty()
977 && !mine.iter().any(|r| {
978 living(side_liveness(Side::Tui, r, home, &*engine.probe, now))
979 || living(side_liveness(
980 Side::Controller,
981 r,
982 home,
983 &*engine.probe,
984 now,
985 ))
986 })
987 });
988 if ids.is_empty() {
989 Check::new(ID, Status::Ok, "", &[])
990 } else {
991 Check::new(ID, Status::Warn, "", &[("ids", &ids.join(", "))])
992 .details(json!({"sessions": ids}))
993 }
994}
995
996fn reason_words(r: &NoteReason) -> String {
997 match r {
998 NoteReason::UnknownKey => "unknown key".to_string(),
999 NoteReason::InvalidValue => "invalid value".to_string(),
1000 NoteReason::Clamped { min, max } => format!("clamped to {min}..{max}"),
1001 NoteReason::ProtectedInWorkspace => "protected key in a workspace file".to_string(),
1002 NoteReason::OverriddenByStickyLock => "overridden by the permission lock".to_string(),
1003 NoteReason::UserinfoInUrl => "userinfo in the URL".to_string(),
1004 }
1005}
1006
1007fn note_json(n: &Note) -> Value {
1008 json!({
1009 "source": source_name(n.source),
1010 "key": n.key,
1011 "value": redact_text(&n.value),
1012 "reason": reason_words(&n.reason),
1013 })
1014}
1015
1016fn settings_notes<T: Transport, C: Clock>(engine: &Engine<T, C>) -> Check {
1017 if engine.notes.is_empty() {
1018 return Check::new("settings_notes", Status::Ok, "", &[]);
1019 }
1020 let list: Vec<String> = engine
1021 .notes
1022 .iter()
1023 .map(|n| {
1024 format!(
1025 "{} {}={} ({})",
1026 source_name(n.source),
1027 n.key,
1028 redact_text(&n.value),
1029 reason_words(&n.reason)
1030 )
1031 })
1032 .collect();
1033 Check::new(
1034 "settings_notes",
1035 Status::Warn,
1036 "",
1037 &[
1038 ("count", &engine.notes.len().to_string()),
1039 ("list", &list.join("; ")),
1040 ],
1041 )
1042}
1043
1044fn settings_file_hash<T: Transport, C: Clock>(engine: &Engine<T, C>) -> Check {
1045 const ID: &str = "settings_file_hash";
1046 let file = engine.env.home.join("settings.local.md");
1047 let shown = file.display().to_string();
1048 match std::fs::read(&file) {
1049 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1050 Check::new(ID, Status::Skip, "", &[("path", &shown)])
1051 }
1052 Err(e) => Check::new(
1053 ID,
1054 Status::Warn,
1055 "",
1056 &[("path", &shown), ("message", &e.to_string())],
1057 ),
1058 Ok(bytes) => {
1059 let hash = hex(&Sha256::digest(&bytes));
1060 let mtime = std::fs::metadata(&file).ok().and_then(|m| mtime_ms(&m));
1061 let mtime_s = mtime.map_or("unknown".to_string(), |m| m.to_string());
1062 Check::new(
1063 ID,
1064 Status::Ok,
1065 "",
1066 &[("path", &shown), ("hash", &hash), ("mtime_ms", &mtime_s)],
1067 )
1068 .details(json!({"path": shown, "sha256": hash, "mtime_ms": mtime}))
1069 }
1070 }
1071}
1072
1073fn resolved_settings<T: Transport, C: Clock>(engine: &Engine<T, C>) -> Value {
1074 let s = &engine.settings;
1075 let mut sources = Map::new();
1076 for (k, v) in &s.sources {
1077 sources.insert((*k).to_string(), json!(source_name(*v)));
1078 }
1079 json!({
1080 "wait_timeout_s": s.wait_timeout_s,
1081 "poll_s": s.poll_s,
1082 "stall_s": s.stall_s,
1083 "max_turn_s": s.max_turn_s,
1084 "call_timeout_s": s.call_timeout_s,
1085 "output_max_bytes": s.output_max_bytes,
1086 "body_max_bytes": s.body_max_bytes,
1087 "wait_max_s": s.wait_max_s,
1088 "opencode_bin": s.opencode_bin.as_ref().map(|p| p.display().to_string()),
1089 "server_url": s.server_url.as_deref().map(redact_text),
1090 "default_model": s.default_model,
1091 "default_agent": s.default_agent,
1092 "permission_policy": s.permission_policy.as_str(),
1093 "permissions_locked": s.permissions_locked,
1094 "lock_source": s.lock_source.map(source_name),
1095 "sources": sources,
1096 })
1097}
1098
1099pub async fn doctor_json<T: Transport, C: Clock>(
1101 engine: &Engine<T, C>,
1102 ctx: &DoctorCtx<'_>,
1103 verbose: bool,
1104) -> Value {
1105 let mut checks = vec![ctl_home_override(ctx)];
1108 let binary = binary_check(engine, ctx);
1109 let registration = registration_check(engine, ctx);
1110 let (server_check, server) = server_info_check(engine).await;
1111 checks.push(binary.clone());
1112 checks.push(registration);
1113 checks.push(server_check);
1114 checks.push(version_range_check(engine, &binary, &server).await);
1115 checks.push(wait_route_check(engine, &server).await);
1116 checks.push(pairing_check(engine, &server).await);
1117 checks.push(lock_source_check(engine));
1118 checks.push(env_vs_user_file(engine, ctx));
1119 checks.push(windows_source_old_check(ctx.bin_dir, cfg!(windows)));
1120 checks.push(launcher_link_check(engine, ctx));
1121 checks.push(server_url_password(engine, ctx));
1122 checks.push(opencode_password_set(ctx));
1123 checks.push(log_check(engine));
1124 checks.push(orphaned_controllers(engine));
1125 checks.push(orphaned_workers(engine, &server).await);
1126 checks.push(settings_notes(engine));
1127 checks.push(settings_file_hash(engine));
1128 checks.sort_by_key(|c| CHECK_IDS.iter().position(|i| *i == c.id));
1130 let mut out = json!({
1131 "checks": checks.iter().map(Check::to_json).collect::<Vec<_>>(),
1132 "notes": engine.notes.iter().map(note_json).collect::<Vec<_>>(),
1133 });
1134 if verbose {
1135 out["settings"] = resolved_settings(engine);
1136 }
1137 out
1138}