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:
parent
bd36438abf
commit
5393e4eab5
21
DESIGN.md
21
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`.
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
118
src/main.rs
118
src/main.rs
@ -79,6 +79,10 @@ async fn run(cli: Cli) -> anyhow::Result<ExitCode> {
|
||||
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<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 {
|
||||
use std::io::IsTerminal;
|
||||
std::io::stdout().is_terminal()
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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())),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user