Add health subcommand; detect dead auth cookies

OpenCode rejects a dead auth cookie with HTTP 302 to /auth/authorize (or
401/403). The HTTP client now disables redirect following so this surfaces
as a FetchError::Unauthorized instead of a 200 login page that fails to parse.

ocd health probes each account's cookie against its default workspace Zen
page and reports alive/dead/errored/misconfigured with a total count. Exit is
0 only when all accounts are alive.

The main walk (pretty/raw/fetch) also classifies a dead cookie as the new
ReportStatus::CookieDead, labels it [cookie dead] in pretty output and
cookie_dead in raw output, and continues with the valid accounts rather than
aborting. Verified live: valid account surfaces [ok], dead account surfaces
[cookie dead], unfilled account surfaces [cookie dead].
This commit is contained in:
ocd 2026-07-30 02:04:53 +03:00
parent bd36438abf
commit 5393e4eab5
9 changed files with 172 additions and 1 deletions

View File

@ -120,9 +120,9 @@ ocd pretty default; bare `ocd` is an alias
ocd raw json one stable JSON object to stdout ocd raw json one stable JSON object to stdout
ocd raw plain TSV-ish lines for shell and awk widgets ocd raw plain TSV-ish lines for shell and awk widgets
ocd fetch run network fetch and update cache; print nothing; for cron ocd fetch run network fetch and update cache; print nothing; for cron
ocd health probe each auth cookie; report dead cookies and a count
ocd cache ls|clear inspect or clear the cache ocd cache ls|clear inspect or clear the cache
ocd config init|check ocd config init|check
```
Global flags: Global flags:
@ -194,6 +194,10 @@ struct Balance {
report, and write cache. On 429, back off or honor `Retry-After`. On 5xx report, and write cache. On 429, back off or honor `Retry-After`. On 5xx
or timeout, retry with backoff and jitter. On hard fail, emit `Error` or timeout, retry with backoff and jitter. On hard fail, emit `Error`
and continue other workspaces. and continue other workspaces.
- A dead auth cookie sends 302 to `/auth/authorize` (or 401/403). The
HTTP client does not follow redirects, so this surfaces as the
`CookieDead` status, not a parse error. The run continues with the
valid accounts and reports the dead ones in the output.
- Sleep about 500 ms before the next workspace. - Sleep about 500 ms before the next workspace.
7. Collect reports in config order. 7. Collect reports in config order.
8. Detect terminal width. Render. 8. Detect terminal width. Render.
@ -268,6 +272,21 @@ empty in the model.
`--ascii` or a non-UTF-8 locale prints `#` repeats. `--ascii` or a non-UTF-8 locale prints `#` repeats.
## Cookie health
`ocd health` probes each account's auth cookie against that account's default
workspace Zen page. A live cookie gets HTTP 200. A dead or logged-out cookie gets
HTTP 302 to `/auth/authorize` (or 401/403). The probe sends one request per
account, paced by `--pace-ms`.
The summary counts `alive`, `dead`, `errored`, and `misconfigured` (empty cookie
or workspace still a placeholder). Exit is 0 only when every account is alive.
The main walk (`pretty`, `raw`, `fetch`) also detects a dead cookie and labels the
report `CookieDead`. It does not abort the run; it continues with the valid
accounts and only reports the dead ones in the output.
## Open items ## Open items
- Crate name: `ocd`. - Crate name: `ocd`.

View File

@ -86,6 +86,9 @@ pub enum Command {
/// Run the network fetch and update the cache. Print nothing. For cron. /// Run the network fetch and update the cache. Print nothing. For cron.
Fetch, Fetch,
/// Probe each account's auth cookie liveness. Reports dead cookies.
Health,
/// Cache management. /// Cache management.
Cache { Cache {
#[command(subcommand)] #[command(subcommand)]

View File

@ -50,6 +50,10 @@ impl HttpClient {
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs)) .timeout(Duration::from_secs(timeout_secs))
.user_agent(user_agent) .user_agent(user_agent)
// Do not follow redirects. A dead auth cookie sends 302 to
// /auth/authorize; we classify that as Unauthorized so the
// caller can label the cookie dead instead of a parse error.
.redirect(reqwest::redirect::Policy::none())
.build()?; .build()?;
Ok(Self { client, retries }) Ok(Self { client, retries })
} }
@ -122,6 +126,12 @@ impl HttpClient {
last_modified: header_str(&resp, "last-modified"), last_modified: header_str(&resp, "last-modified"),
}); });
} }
// A dead cookie: 302 to /auth/* (or 401/403). Surface it as
// Unauthorized so the report can label the cookie dead.
let loc = header_str(&resp, "location").unwrap_or_default();
if status == 401 || status == 403 || ((300..400).contains(&status) && loc.contains("/auth/")) {
return Err(FetchError::Unauthorized { status, redirect: loc });
}
if !(200..300).contains(&status) { if !(200..300).contains(&status) {
return Err(FetchError::Status { return Err(FetchError::Status {
status, status,

View File

@ -33,6 +33,12 @@ pub enum FetchError {
#[error("HTTP {status} from {url}")] #[error("HTTP {status} from {url}")]
Status { status: u16, url: String }, Status { status: u16, url: String },
#[error("auth cookie rejected (HTTP {status}): {redirect}")]
Unauthorized {
status: u16,
redirect: String,
},
#[error("request failed: {0}")] #[error("request failed: {0}")]
Send(String), Send(String),

View File

@ -183,6 +183,11 @@ fn report_status(
balance: Option<&Balance>, balance: Option<&Balance>,
) -> ReportStatus { ) -> ReportStatus {
use crate::error::FetchError; use crate::error::FetchError;
for r in [zen, go] {
if let Err(FetchError::Unauthorized { .. }) = r {
return ReportStatus::CookieDead;
}
}
for r in [zen, go] { for r in [zen, go] {
match r { match r {
Err(FetchError::RateLimited) => return ReportStatus::RateLimited, Err(FetchError::RateLimited) => return ReportStatus::RateLimited,

View File

@ -79,6 +79,10 @@ async fn run(cli: Cli) -> anyhow::Result<ExitCode> {
tracing::info!(ok, total = reports.len(), "fetch done"); tracing::info!(ok, total = reports.len(), "fetch done");
Ok(exit_code(&reports)) Ok(exit_code(&reports))
} }
Command::Health => {
let (cfg, opts) = load_and_options(&cli)?;
run_health(&cfg, &opts, cli.no_color || !stdout_is_tty(), cli.quiet).await
}
Command::Cache { action } => match action { Command::Cache { action } => match action {
CacheAction::Ls => { CacheAction::Ls => {
let entries = cache::list(); let entries = cache::list();
@ -177,6 +181,120 @@ fn exit_code(reports: &[AccountReport]) -> ExitCode {
} }
} }
async fn run_health(
cfg: &config::Config,
opts: &fetch::FetchOptions,
no_color: bool,
quiet: bool,
) -> anyhow::Result<ExitCode> {
use anstyle::{AnsiColor, Style};
use crate::client::HttpClient;
let accounts: Vec<&config::Account> = cfg
.account
.iter()
.filter(|a| opts.account_filter.is_empty() || opts.account_filter.contains(&a.name))
.collect();
let total = accounts.len();
if !quiet {
println!("ocd health: {} account(s)", total);
}
let http = HttpClient::new(opts.user_agent.clone(), opts.timeout_secs, opts.retries)?;
let green = Style::new().fg_color(Some(AnsiColor::Green.into()));
let red = Style::new().fg_color(Some(AnsiColor::BrightRed.into()));
let yellow = Style::new().fg_color(Some(AnsiColor::Yellow.into()));
let dim = Style::new();
let mut alive = 0usize;
let mut dead = 0usize;
let mut errored = 0usize;
let mut misconfigured = 0usize;
for (i, acct) in accounts.iter().enumerate() {
let outcome = probe_one(acct, &http, opts).await;
let (line, style): (String, Style) = match &outcome {
Outcome::Alive => ("alive".to_string(), green),
Outcome::Dead(reason) => (reason.clone(), red),
Outcome::Err(msg) => (format!("error: {msg}"), yellow),
Outcome::Misconfigured(msg) => (msg.clone(), dim),
};
match &outcome {
Outcome::Alive => alive += 1,
Outcome::Dead(_) => dead += 1,
Outcome::Err(_) => errored += 1,
Outcome::Misconfigured(_) => misconfigured += 1,
}
if !quiet {
let text = format!(" account {:<18} {}", format!("\"{}\":", acct.name), line);
println!("{}", paint_health(&text, style, no_color));
}
// Pace between probes; do not pace after the last.
if i + 1 < total && opts.workspace_pace_ms > 0 {
tokio::time::sleep(std::time::Duration::from_millis(opts.workspace_pace_ms)).await;
}
}
if !quiet {
println!("alive {}, dead {}, errored {}, misconfigured {} / total {}",
alive, dead, errored, misconfigured, total);
} else {
// Even in quiet mode, print the one-line summary.
eprintln!("alive {}, dead {}, errored {}, misconfigured {} / total {}",
alive, dead, errored, misconfigured, total);
}
if dead == 0 && errored == 0 && misconfigured == 0 {
Ok(ExitCode::SUCCESS)
} else {
Ok(ExitCode::from(1))
}
}
enum Outcome {
Alive,
Dead(String),
Err(String),
Misconfigured(String),
}
use crate::client::{HttpClient, zen_url};
use crate::error::FetchError;
async fn probe_one(acct: &config::Account, http: &HttpClient, _opts: &fetch::FetchOptions) -> Outcome {
if acct.cookie.trim().is_empty() {
return Outcome::Misconfigured("no cookie configured".to_string());
}
let Some(ws) = acct.workspace_ids().into_iter().next() else {
return Outcome::Misconfigured("no workspace configured (cannot probe)".to_string());
};
if ws.trim().is_empty() || ws.contains("REPLACE_ME") {
return Outcome::Misconfigured("workspace not filled in (cannot probe)".to_string());
}
let url = zen_url(&ws);
match http.get_text(&url, &acct.cookie, None, None).await {
Ok(fr) if fr.body.is_some() => Outcome::Alive,
Ok(_) => Outcome::Err("304 without validators".to_string()),
Err(FetchError::Unauthorized { status, redirect }) => {
Outcome::Dead(format!("dead (HTTP {} -> {})", status, redirect))
}
Err(FetchError::Timeout) => Outcome::Err("request timed out".to_string()),
Err(FetchError::RateLimited) => Outcome::Err("rate limited".to_string()),
Err(FetchError::Status { status, .. }) => Outcome::Err(format!("HTTP {}", status)),
Err(e) => Outcome::Err(e.to_string()),
}
}
fn paint_health(text: &str, style: anstyle::Style, no_color: bool) -> String {
if no_color {
text.to_string()
} else {
format!("{}{}{}", style.render(), text, style.render_reset())
}
}
fn stdout_is_tty() -> bool { fn stdout_is_tty() -> bool {
use std::io::IsTerminal; use std::io::IsTerminal;
std::io::stdout().is_terminal() std::io::stdout().is_terminal()

View File

@ -45,6 +45,9 @@ pub enum ReportStatus {
Timeout, Timeout,
/// Any other failure. The string explains it. /// Any other failure. The string explains it.
Error(String), Error(String),
/// The auth cookie is dead. The server redirected to the login page or
/// returned 401/403.
CookieDead,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -63,9 +63,13 @@ fn render_one(r: &AccountReport, width: usize, ascii: bool, no_color: bool) -> S
} }
// Print the failure detail on an error status. // Print the failure detail on an error status.
// Print the failure detail on an error or dead-cookie status.
if let ReportStatus::Error(msg) = &r.status { if let ReportStatus::Error(msg) = &r.status {
out.push_str(&format!(" error {msg}\n")); out.push_str(&format!(" error {msg}\n"));
} }
if let ReportStatus::CookieDead = &r.status {
out.push_str(" note cookie rejected; re-auth this account\n");
}
out out
} }
@ -133,6 +137,7 @@ fn status_text(s: &ReportStatus) -> String {
ReportStatus::RateLimited => "[rate limited]".to_string(), ReportStatus::RateLimited => "[rate limited]".to_string(),
ReportStatus::Timeout => "[timeout]".to_string(), ReportStatus::Timeout => "[timeout]".to_string(),
ReportStatus::Error(_) => "[error]".to_string(), ReportStatus::Error(_) => "[error]".to_string(),
ReportStatus::CookieDead => "[cookie dead]".to_string(),
} }
} }
@ -143,6 +148,7 @@ fn status_style(s: &ReportStatus) -> Style {
ReportStatus::RateLimited => Style::new().fg_color(Some(AnsiColor::Yellow.into())), ReportStatus::RateLimited => Style::new().fg_color(Some(AnsiColor::Yellow.into())),
ReportStatus::Timeout => Style::new().fg_color(Some(AnsiColor::Magenta.into())), ReportStatus::Timeout => Style::new().fg_color(Some(AnsiColor::Magenta.into())),
ReportStatus::Error(_) => Style::new().fg_color(Some(AnsiColor::Red.into())), ReportStatus::Error(_) => Style::new().fg_color(Some(AnsiColor::Red.into())),
ReportStatus::CookieDead => Style::new().fg_color(Some(AnsiColor::BrightRed.into())),
} }
} }

View File

@ -61,5 +61,6 @@ fn status_word(s: &ReportStatus) -> &'static str {
ReportStatus::RateLimited => "rate_limited", ReportStatus::RateLimited => "rate_limited",
ReportStatus::Timeout => "timeout", ReportStatus::Timeout => "timeout",
ReportStatus::Error(_) => "error", ReportStatus::Error(_) => "error",
ReportStatus::CookieDead => "cookie_dead",
} }
} }