From 5393e4eab55320fae9f01a8d7339c79e66840d99 Mon Sep 17 00:00:00 2001 From: ocd Date: Thu, 30 Jul 2026 02:04:53 +0300 Subject: [PATCH] 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]. --- DESIGN.md | 21 +++++++- src/cli.rs | 3 ++ src/client.rs | 10 ++++ src/error.rs | 6 +++ src/fetch.rs | 5 ++ src/main.rs | 118 +++++++++++++++++++++++++++++++++++++++++++ src/model.rs | 3 ++ src/render/pretty.rs | 6 +++ src/render/raw.rs | 1 + 9 files changed, 172 insertions(+), 1 deletion(-) diff --git a/DESIGN.md b/DESIGN.md index c9630a9..cb3949a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -120,9 +120,9 @@ ocd pretty default; bare `ocd` is an alias ocd raw json one stable JSON object to stdout ocd raw plain TSV-ish lines for shell and awk widgets 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 config init|check -``` Global flags: @@ -194,6 +194,10 @@ struct Balance { 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` 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. 7. Collect reports in config order. 8. Detect terminal width. Render. @@ -268,6 +272,21 @@ empty in the model. `--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 - Crate name: `ocd`. diff --git a/src/cli.rs b/src/cli.rs index db3cd66..0fd7bfb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -86,6 +86,9 @@ pub enum Command { /// Run the network fetch and update the cache. Print nothing. For cron. Fetch, + /// Probe each account's auth cookie liveness. Reports dead cookies. + Health, + /// Cache management. Cache { #[command(subcommand)] diff --git a/src/client.rs b/src/client.rs index 08d850d..180db1f 100644 --- a/src/client.rs +++ b/src/client.rs @@ -50,6 +50,10 @@ impl HttpClient { let client = reqwest::Client::builder() .timeout(Duration::from_secs(timeout_secs)) .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()?; Ok(Self { client, retries }) } @@ -122,6 +126,12 @@ impl HttpClient { 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) { return Err(FetchError::Status { status, diff --git a/src/error.rs b/src/error.rs index 49602a7..a26de65 100644 --- a/src/error.rs +++ b/src/error.rs @@ -33,6 +33,12 @@ pub enum FetchError { #[error("HTTP {status} from {url}")] Status { status: u16, url: String }, + #[error("auth cookie rejected (HTTP {status}): {redirect}")] + Unauthorized { + status: u16, + redirect: String, + }, + #[error("request failed: {0}")] Send(String), diff --git a/src/fetch.rs b/src/fetch.rs index c145397..bf76ba0 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -183,6 +183,11 @@ fn report_status( balance: Option<&Balance>, ) -> ReportStatus { use crate::error::FetchError; + for r in [zen, go] { + if let Err(FetchError::Unauthorized { .. }) = r { + return ReportStatus::CookieDead; + } + } for r in [zen, go] { match r { Err(FetchError::RateLimited) => return ReportStatus::RateLimited, diff --git a/src/main.rs b/src/main.rs index 964e857..33c49ce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,6 +79,10 @@ async fn run(cli: Cli) -> anyhow::Result { tracing::info!(ok, total = reports.len(), "fetch done"); 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 { CacheAction::Ls => { 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 { + 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 { use std::io::IsTerminal; std::io::stdout().is_terminal() diff --git a/src/model.rs b/src/model.rs index 40c6134..48f9023 100644 --- a/src/model.rs +++ b/src/model.rs @@ -45,6 +45,9 @@ pub enum ReportStatus { Timeout, /// Any other failure. The string explains it. Error(String), + /// The auth cookie is dead. The server redirected to the login page or + /// returned 401/403. + CookieDead, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/render/pretty.rs b/src/render/pretty.rs index 71db7aa..0e77aad 100644 --- a/src/render/pretty.rs +++ b/src/render/pretty.rs @@ -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 or dead-cookie status. if let ReportStatus::Error(msg) = &r.status { 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 } @@ -133,6 +137,7 @@ fn status_text(s: &ReportStatus) -> String { ReportStatus::RateLimited => "[rate limited]".to_string(), ReportStatus::Timeout => "[timeout]".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::Timeout => Style::new().fg_color(Some(AnsiColor::Magenta.into())), ReportStatus::Error(_) => Style::new().fg_color(Some(AnsiColor::Red.into())), + ReportStatus::CookieDead => Style::new().fg_color(Some(AnsiColor::BrightRed.into())), } } diff --git a/src/render/raw.rs b/src/render/raw.rs index b762400..62fca6f 100644 --- a/src/render/raw.rs +++ b/src/render/raw.rs @@ -61,5 +61,6 @@ fn status_word(s: &ReportStatus) -> &'static str { ReportStatus::RateLimited => "rate_limited", ReportStatus::Timeout => "timeout", ReportStatus::Error(_) => "error", + ReportStatus::CookieDead => "cookie_dead", } } \ No newline at end of file