feat: add doctor command for diagnosing installs and cleaning stale daemon state (#1254)

* feat: add `doctor` command for install diagnostics and cleanup

Adds `agent-browser doctor`, a one-shot diagnostic that checks
environment, Chrome install, daemon state, config, encryption key,
providers, network reachability, and a live headless launch test.
Auto-cleans stale `.sock` / `.pid` / `.version` / `.stream` sidecar
files on every run. Destructive repairs (reinstall Chrome, purge old
state, close version-mismatched daemons, generate missing encryption
key) are gated behind `--fix`. Supports `--offline`, `--quick`, and
`--json`.

* fixes
This commit is contained in:
Chris Tate
2026-04-16 18:20:41 -05:00
committed by GitHub
parent 4cc6ca40b7
commit 14ece9b3ad
21 changed files with 2294 additions and 125 deletions
+32 -122
View File
@@ -2,6 +2,7 @@ mod chat;
mod color;
mod commands;
mod connection;
mod doctor;
mod flags;
mod install;
mod native;
@@ -20,10 +21,13 @@ use std::process::exit;
#[cfg(windows)]
use windows_sys::Win32::Foundation::CloseHandle;
#[cfg(windows)]
use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
use windows_sys::Win32::System::Threading::OpenProcess;
use commands::{gen_id, parse_command, ParseError};
use connection::{cleanup_stale_files, ensure_daemon, get_socket_dir, send_command, DaemonOptions};
use connection::{
cleanup_stale_files, ensure_daemon, get_socket_dir, is_pid_alive, send_command, walk_daemons,
DaemonOptions,
};
use flags::{clean_args, parse_flags, Flags};
use install::run_install;
use output::{
@@ -182,46 +186,11 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
match subcommand {
Some("list") => {
let socket_dir = get_socket_dir();
let mut sessions: Vec<String> = Vec::new();
if let Ok(entries) = fs::read_dir(&socket_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
// Look for pid files in socket directory
if name.ends_with(".pid") {
let session_name = name.strip_suffix(".pid").unwrap_or("");
if !session_name.is_empty() {
// Check if session is actually running
let pid_path = socket_dir.join(&name);
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
if let Ok(pid) = pid_str.trim().parse::<u32>() {
#[cfg(unix)]
let running = unsafe {
libc::kill(pid as i32, 0) == 0
|| std::io::Error::last_os_error().raw_os_error()
!= Some(libc::ESRCH)
};
#[cfg(windows)]
let running = unsafe {
let handle =
OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if handle != 0 {
CloseHandle(handle);
true
} else {
false
}
};
if running {
sessions.push(session_name.to_string());
}
}
}
}
}
}
}
let sessions: Vec<String> = walk_daemons()
.sessions
.into_iter()
.map(|s| s.name)
.collect();
if json_mode {
println!(
@@ -262,25 +231,6 @@ fn get_dashboard_pid_path() -> std::path::PathBuf {
get_socket_dir().join("dashboard.pid")
}
fn is_pid_alive(pid: u32) -> bool {
#[cfg(unix)]
{
unsafe { libc::kill(pid as i32, 0) == 0 }
}
#[cfg(windows)]
{
unsafe {
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if handle != 0 {
CloseHandle(handle);
true
} else {
false
}
}
}
}
fn run_dashboard_start(port: u16, json_mode: bool) {
let pid_path = get_dashboard_pid_path();
@@ -439,67 +389,15 @@ fn run_dashboard_stop(json_mode: bool) {
}
fn run_close_all(flags: &Flags) {
let socket_dir = get_socket_dir();
let mut sessions: Vec<(String, u32)> = Vec::new();
if let Ok(entries) = fs::read_dir(&socket_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if let Some(session_name) = name.strip_suffix(".pid") {
if session_name.is_empty() {
continue;
}
let pid_path = socket_dir.join(&name);
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
if let Ok(pid) = pid_str.trim().parse::<u32>() {
#[cfg(unix)]
let running = unsafe {
libc::kill(pid as i32, 0) == 0
|| std::io::Error::last_os_error().raw_os_error()
!= Some(libc::ESRCH)
};
#[cfg(windows)]
let running = unsafe {
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if handle != 0 {
CloseHandle(handle);
true
} else {
false
}
};
if running {
sessions.push((session_name.to_string(), pid));
} else {
// Process is gone but stale files remain; clean them up
cleanup_stale_files(session_name);
}
}
} else {
// PID file exists but is unreadable; clean up stale files
cleanup_stale_files(session_name);
}
}
}
}
// Also scan for orphaned .sock files without corresponding .pid files
#[cfg(unix)]
if let Ok(entries) = fs::read_dir(&socket_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if let Some(session_name) = name.strip_suffix(".sock") {
if session_name.is_empty() {
continue;
}
let pid_path = socket_dir.join(format!("{}.pid", session_name));
if !pid_path.exists() {
// Orphaned socket file with no PID file; remove it
cleanup_stale_files(session_name);
}
}
}
}
// walk_daemons auto-cleans stale .pid / .sock / .stream sidecar files and
// separates out the standalone dashboard. We only want to send `close` to
// real session daemons; the dashboard has its own `dashboard stop`.
let inventory = walk_daemons();
let sessions: Vec<(String, u32)> = inventory
.sessions
.iter()
.map(|s| (s.name.clone(), s.pid))
.collect();
if sessions.is_empty() {
if flags.json {
@@ -652,6 +550,18 @@ fn main() {
return;
}
// Handle doctor separately (doesn't need daemon; spawns its own scratch
// session for the live launch test).
if clean.first().map(|s| s.as_str()) == Some("doctor") {
let opts = doctor::DoctorOptions {
offline: args.iter().any(|a| a == "--offline"),
quick: args.iter().any(|a| a == "--quick"),
fix: args.iter().any(|a| a == "--fix"),
json: flags.json,
};
exit(doctor::run_doctor(opts));
}
// Handle dashboard subcommand
if clean.first().map(|s| s.as_str()) == Some("dashboard") {
match clean.get(1).map(|s| s.as_str()) {