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 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`.
|
||||||
|
|||||||
@ -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)]
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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),
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
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");
|
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()
|
||||||
|
|||||||
@ -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)]
|
||||||
|
|||||||
@ -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())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user