diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e1c69d9eee..4d49625f961 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ## [Windows] Unreleased +- Browser cookies: automatic import reads browsers one at a time and stops at the first session the site accepts. Typesafe, HelmCode, Hyper, Claude (web), OpenCode Go (web), Zed, Notion, and Cursor now move on to the next browser when a signed-out or expired session is rejected, instead of failing on the first browser that had any cookies, and later browsers are not decrypted once a session works. Cookie source Off still never reads a browser (upstream v0.75.0). - Ollama: the account email is read only from the settings page header (`id="header-email"`) instead of the first address anywhere on the page, so a footer or support address is no longer shown as the account; a page without that header shows no email (upstream v0.75.0). - **Claude**: in Auto mode, a Claude CLI probe timeout now falls back to the retained CLI usage from the last 15 minutes, as other CLI failures already did, instead of failing the refresh outright. Without a retained result the timeout is reported as before. - Settings providers: show the FLORA Workspace ID and Fireworks account slug fields, which were defined but never displayed, so they no longer need the `FLORA_WORKSPACE_ID` / `FIREWORKS_ACCOUNT_SLUG` environment variables. diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 7dbc58021e5..0b26e35a217 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -392,10 +392,14 @@ pub(crate) fn build_fetch_context( && !provider.cookie_source_scopes_session_only() && !cookies_only_enrich_usage { - if cookie_header - .as_deref() - .map(str::trim) - .is_none_or(|s| s.is_empty()) + // Providers that own browser cookie resolution try each browser's + // session themselves; a pre-resolved first-browser header would pin + // them to that browser. + if !defer_provider_browser_cookie_lookup + && cookie_header + .as_deref() + .map(str::trim) + .is_none_or(|s| s.is_empty()) { cookie_header = provider_cookie_domain(id, settings).and_then(|domain| { codexbar::browser::cookies::get_cookie_header(domain) diff --git a/docs/COOKIES.md b/docs/COOKIES.md index 1a33031228b..7fb3249b389 100644 --- a/docs/COOKIES.md +++ b/docs/COOKIES.md @@ -21,6 +21,10 @@ Chromium App-Bound Encryption (ABE) binds protected cookie keys to the browser i 3. Only cookies for enabled providers are extracted (e.g., `claude.ai`, `cursor.com`) 4. Cookies are stored in-memory and refreshed on each provider poll +### Automatic browser order + +With the cookie source set to automatic, browsers are read one at a time in the shared detection order, and only when the provider asks for the next session. Typesafe, HelmCode, Hyper, Claude (web), OpenCode Go (web), Zed, Notion, and Cursor try each browser's session in turn, checking every site domain in one browser before moving to the next browser: a session the site rejects (signed out or expired) moves on to the next browser, and the first accepted session ends the scan, so later browsers are never opened or decrypted. Providers that have not been moved to this scan yet still use the first browser that has any cookies for the site. Cookie source Off or Manual never reads a browser, and the **Import** button reads only the browser you chose. + ## Setting Up Cookie Import 1. Open **Settings** → **Providers** tab diff --git a/rust/src/browser/cookies.rs b/rust/src/browser/cookies.rs index 855ab479bab..21589738ae9 100755 --- a/rust/src/browser/cookies.rs +++ b/rust/src/browser/cookies.rs @@ -19,6 +19,7 @@ use rusqlite::Connection; use thiserror::Error; use super::detection::{BrowserProfile, BrowserType, DetectedBrowser}; +use super::session_scan::BrowserCookieScan; /// Errors that can occur during cookie extraction #[derive(Debug, Error)] @@ -660,112 +661,34 @@ fn domain_matches(host_key: &str, domain: &str) -> bool { host == domain || host == format!(".{domain}") || host.ends_with(&format!(".{domain}")) } -/// Per-browser cookies found while scanning every detected browser. -struct BrowserCookieCandidates { - candidates: Vec<(BrowserType, Vec)>, - abe_error_seen: bool, -} - -/// Scan every detected browser for readable cookies of `domain`. -/// -/// Returns the per-browser candidates in detection order, ignoring browsers -/// with no cookies, plus whether any browser was blocked by App-Bound -/// Encryption so callers can surface that specific, actionable error when no -/// other browser succeeded. -fn extract_domain_candidates(domain: &str) -> Result { - use super::detection::BrowserDetector; - - let browsers = BrowserDetector::detect_all(); - - if browsers.is_empty() { - return Err(CookieError::BrowserNotInstalled); - } - - let mut candidates = Vec::new(); - let mut abe_error_seen = false; - - for browser in browsers { - match CookieExtractor::extract_for_domain(&browser, domain) { - Ok(cookies) if !cookies.is_empty() => { - candidates.push((browser.browser_type, cookies)); - } - Ok(_) => {} - Err(CookieError::AppBoundEncryption) => { - // Chromium ABE is blocking this browser; log a warning and keep - // trying the remaining browsers; Firefox does not use Chromium ABE. - tracing::warn!( - browser = %browser.browser_type.display_name(), - "App-Bound Encryption prevents automatic cookie import; \ - trying remaining browsers" - ); - abe_error_seen = true; - } - Err(error) => { - tracing::debug!( - browser = %browser.browser_type.display_name(), - "Failed to get cookies from {}: {}", - browser.browser_type.display_name(), - error - ); - } - } - } - - Ok(BrowserCookieCandidates { - candidates, - abe_error_seen, - }) -} - /// Helper to get cookies for a specific domain from any available browser. /// -/// Stops at the first browser with any matching cookie; providers that must -/// try every browser after an auth failure use `get_cookie_headers_for_domain`. +/// Stops at the first browser with any matching cookie and reads no further +/// browser. Providers that validate the session and must move on after an +/// auth failure iterate a [`BrowserCookieScan`] instead. pub fn get_cookies_for_domain(domain: &str) -> Result, CookieError> { - let scan = extract_domain_candidates(domain)?; - - if let Some((browser, cookies)) = scan.candidates.into_iter().next() { - tracing::debug!( - "Found {} cookies for {} in {}", - cookies.len(), - domain, - browser.display_name() - ); - return Ok(cookies); - } - - // Surface a clear ABE error if it was the only kind of failure encountered, - // so the UI can show an actionable message instead of a generic "not found". - if scan.abe_error_seen { - return Err(CookieError::AppBoundEncryption); + let mut scan = BrowserCookieScan::new(&[domain]); + match scan.next() { + Some(candidate) => Ok(candidate.cookies), + None => Err(scan.exhaustion_error()), } - - Err(CookieError::NotFound(domain.to_string())) } /// Get cookie-header candidates from every detected browser that has readable /// cookies for a domain. /// -/// Providers whose session cookie is only in one browser need the complete -/// candidate set so they can validate the session-bearing header and try the -/// next browser after an auth failure. +/// Reads every browser up front; prefer iterating a [`BrowserCookieScan`] so +/// later browsers are read only when an earlier session is rejected. pub fn get_cookie_headers_for_domain( domain: &str, ) -> Result, CookieError> { - let scan = extract_domain_candidates(domain)?; - + let mut scan = BrowserCookieScan::new(&[domain]); let headers = scan - .candidates - .into_iter() - .map(|(browser, cookies)| (browser, CookieExtractor::build_cookie_header(&cookies))) - .filter(|(_, header)| !header.trim().is_empty()) + .by_ref() + .map(|candidate| (candidate.browser, candidate.header())) .collect::>(); - - if headers.is_empty() && scan.abe_error_seen { - return Err(CookieError::AppBoundEncryption); - } if headers.is_empty() { - return Err(CookieError::NotFound(domain.to_string())); + return Err(scan.exhaustion_error()); } Ok(headers) } @@ -776,14 +699,15 @@ pub fn get_cookie_headers_for_domain( pub fn get_cookies_by_browser_for_domain( domain: &str, ) -> Result)>, CookieError> { - let scan = extract_domain_candidates(domain)?; - if scan.candidates.is_empty() && scan.abe_error_seen { - return Err(CookieError::AppBoundEncryption); - } - if scan.candidates.is_empty() { - return Err(CookieError::NotFound(domain.to_string())); + let mut scan = BrowserCookieScan::new(&[domain]); + let candidates = scan + .by_ref() + .map(|candidate| (candidate.browser, candidate.cookies)) + .collect::>(); + if candidates.is_empty() { + return Err(scan.exhaustion_error()); } - Ok(scan.candidates) + Ok(candidates) } /// Get a cookie header string for a domain @@ -794,25 +718,12 @@ pub fn get_cookie_header(domain: &str) -> Result { /// Get a cookie header string from the first domain that has readable cookies. pub fn get_cookie_header_for_domains(domains: &[&str]) -> Result { - let mut app_bound_encryption_seen = false; - let mut last_error = None; - - for domain in domains { - match get_cookie_header(domain) { - Ok(header) if !header.trim().is_empty() => return Ok(header), - Ok(_) => {} - Err(CookieError::AppBoundEncryption) => app_bound_encryption_seen = true, - Err(error) => last_error = Some(error), - } - } - - if app_bound_encryption_seen { - Err(CookieError::AppBoundEncryption) - } else { - Err(last_error.unwrap_or_else(|| CookieError::NotFound(domains.join(", ")))) + let mut scan = BrowserCookieScan::new(domains); + match scan.next() { + Some(candidate) => Ok(candidate.header()), + None => Err(scan.exhaustion_error()), } } - /// Get a cookie header string for a domain from a specific browser pub fn get_cookie_header_from_browser( domain: &str, diff --git a/rust/src/browser/mod.rs b/rust/src/browser/mod.rs index b15cbdf3c56..83df6350536 100755 --- a/rust/src/browser/mod.rs +++ b/rust/src/browser/mod.rs @@ -4,6 +4,7 @@ pub mod cookie_cache; pub mod cookies; pub mod detection; pub mod leveldb; +pub mod session_scan; pub mod storage_discovery; pub mod watchdog; pub mod wsl_paths; diff --git a/rust/src/browser/session_scan.rs b/rust/src/browser/session_scan.rs new file mode 100644 index 00000000000..aab63b71c9e --- /dev/null +++ b/rust/src/browser/session_scan.rs @@ -0,0 +1,489 @@ +//! Lazy, one-browser-at-a-time cookie scan. +//! +//! Reading a browser's cookie store copies its SQLite database and, for +//! Chromium browsers, decrypts every matching value with DPAPI/AES-GCM. The +//! scan therefore reads the next browser only when the caller asks for another +//! candidate, so a caller that accepts the first browser's session never +//! touches the others. Callers that validate a session (an HTTP request) and +//! see it rejected simply ask for the next candidate instead of being stuck +//! with the first browser that holds any cookie for the domain. +//! +//! The scan is provider-agnostic: it knows browsers, domains and cookies, not +//! what a valid session looks like. + +use super::cookies::{Cookie, CookieError, CookieExtractor}; +use super::detection::{BrowserDetector, BrowserType, DetectedBrowser}; + +/// Where browser installations and their cookies come from. The system store +/// reads real browser profiles; tests substitute an in-memory store. +pub trait BrowserCookieStore { + /// Installed browsers in scan order. Must be cheap: it only discovers + /// profile directories and never reads or decrypts cookies. + fn detect_browsers(&self) -> Vec; + + /// Read (and decrypt) the cookies `browser` holds for `domain`. + fn read_cookies( + &self, + browser: &DetectedBrowser, + domain: &str, + ) -> Result, CookieError>; +} + +/// The real browser profiles on this machine (and the Windows host under WSL). +#[derive(Debug, Clone, Copy, Default)] +pub struct SystemCookieStore; + +impl BrowserCookieStore for SystemCookieStore { + fn detect_browsers(&self) -> Vec { + BrowserDetector::detect_all() + } + + fn read_cookies( + &self, + browser: &DetectedBrowser, + domain: &str, + ) -> Result, CookieError> { + CookieExtractor::extract_for_domain(browser, domain) + } +} + +/// One browser's cookies for one domain. +#[derive(Debug, Clone)] +pub struct BrowserCookieCandidate { + pub browser: BrowserType, + pub domain: String, + pub cookies: Vec, +} + +impl BrowserCookieCandidate { + /// The `Cookie` request-header value for these cookies. + pub fn header(&self) -> String { + CookieExtractor::build_cookie_header(&self.cookies) + } +} + +/// How a multi-domain scan walks the browser × domain grid. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ScanOrder { + /// Every browser for the first domain, then every browser for the next. + /// The first candidate is exactly the one the older first-match helpers + /// returned. + #[default] + DomainMajor, + /// Every domain for the first browser, then every domain for the next, so + /// an earlier browser's session is always tried before a later browser's. + BrowserMajor, +} + +/// Iterator over browser cookie candidates for a list of domains. +/// +/// Order is [`ScanOrder::DomainMajor`] unless [`BrowserCookieScan::browser_major`] +/// is used. Browsers follow `BrowserType::all()` order, or the explicit order +/// passed to [`BrowserCookieScan::with_browser_order`]. Browsers without +/// cookies for a domain, and browsers that fail to read, are skipped. A repeat +/// of the same header from the same browser under another domain is skipped +/// too, since it would only repeat a session the caller already tried. +pub struct BrowserCookieScan { + store: S, + domains: Vec, + browser_order: Option>, + browsers: Option>, + order: ScanOrder, + outer_index: usize, + inner_index: usize, + yielded: Vec<(BrowserType, String)>, + app_bound_encryption_seen: bool, +} + +impl BrowserCookieScan { + /// Scan the real browser profiles for `domains`. + pub fn new(domains: &[&str]) -> Self { + Self::with_store(SystemCookieStore, domains) + } +} + +impl BrowserCookieScan { + /// Scan `store` for `domains`. Nothing is read until the first `next()`. + pub fn with_store(store: S, domains: &[&str]) -> Self { + Self { + store, + domains: domains.iter().map(|domain| (*domain).to_string()).collect(), + browser_order: None, + browsers: None, + order: ScanOrder::DomainMajor, + outer_index: 0, + inner_index: 0, + yielded: Vec::new(), + app_bound_encryption_seen: false, + } + } + + /// Try every domain in one browser before moving to the next browser, for + /// callers that validate each session and must prefer earlier browsers. + pub fn browser_major(mut self) -> Self { + self.order = ScanOrder::BrowserMajor; + self + } + + /// Restrict the scan to `order` and try those browsers in that order, for + /// callers that honor an explicit browser selection. Browsers absent from + /// `order` are never read. + pub fn with_browser_order(mut self, order: &[BrowserType]) -> Self { + self.browser_order = Some(order.to_vec()); + self + } + + /// The error that describes why the scan produced nothing (more): App-Bound + /// Encryption when it blocked any browser, `BrowserNotInstalled` when no + /// browser was found, otherwise `NotFound` for the scanned domains. + pub fn exhaustion_error(&self) -> CookieError { + if self.app_bound_encryption_seen { + CookieError::AppBoundEncryption + } else if self.browsers.as_ref().is_some_and(Vec::is_empty) { + CookieError::BrowserNotInstalled + } else { + CookieError::NotFound(self.domains.join(", ")) + } + } + + fn browsers(&mut self) -> &[DetectedBrowser] { + if self.browsers.is_none() { + let mut detected = self.store.detect_browsers(); + if let Some(order) = &self.browser_order { + detected.retain(|browser| order.contains(&browser.browser_type)); + detected.sort_by_key(|browser| { + order + .iter() + .position(|selected| *selected == browser.browser_type) + }); + } + self.browsers = Some(detected); + } + self.browsers.as_deref().unwrap_or_default() + } +} + +impl Iterator for BrowserCookieScan { + type Item = BrowserCookieCandidate; + + fn next(&mut self) -> Option { + let browser_count = self.browsers().len(); + let domain_count = self.domains.len(); + let (outer_count, inner_count) = match self.order { + ScanOrder::DomainMajor => (domain_count, browser_count), + ScanOrder::BrowserMajor => (browser_count, domain_count), + }; + while self.outer_index < outer_count { + if self.inner_index >= inner_count { + self.outer_index += 1; + self.inner_index = 0; + continue; + } + let (domain_index, browser_index) = match self.order { + ScanOrder::DomainMajor => (self.outer_index, self.inner_index), + ScanOrder::BrowserMajor => (self.inner_index, self.outer_index), + }; + self.inner_index += 1; + let browser = self.browsers()[browser_index].clone(); + let domain = self.domains[domain_index].clone(); + let browser_name = browser.browser_type.display_name(); + + match self.store.read_cookies(&browser, &domain) { + Ok(cookies) if !cookies.is_empty() => { + let candidate = BrowserCookieCandidate { + browser: browser.browser_type, + domain, + cookies, + }; + let header = candidate.header(); + if header.trim().is_empty() + || self.yielded.iter().any(|(seen, seen_header)| { + *seen == candidate.browser && *seen_header == header + }) + { + continue; + } + tracing::debug!( + browser = browser_name, + domain = %candidate.domain, + count = candidate.cookies.len(), + "Found browser cookie candidate" + ); + self.yielded.push((candidate.browser, header)); + return Some(candidate); + } + Ok(_) => {} + Err(CookieError::AppBoundEncryption) => { + tracing::warn!( + browser = browser_name, + "App-Bound Encryption prevents automatic cookie import; \ + trying remaining browsers" + ); + self.app_bound_encryption_seen = true; + } + Err(error) => { + tracing::debug!( + browser = browser_name, + "Failed to get cookies from {browser_name}: {error}" + ); + } + } + } + None + } +} + +#[cfg(test)] +pub(crate) mod test_support { + use std::cell::RefCell; + use std::collections::HashMap; + use std::path::PathBuf; + + use super::*; + use crate::browser::detection::BrowserProfile; + + /// What a fake browser returns for a domain. + #[derive(Clone)] + pub(crate) enum FakeRead { + Cookies(Vec<(&'static str, &'static str)>), + AppBoundEncryption, + Failure, + } + + /// In-memory browsers that record every cookie read. + #[derive(Default)] + pub(crate) struct FakeCookieStore { + browsers: Vec, + reads: HashMap<(BrowserType, String), FakeRead>, + pub(crate) read_log: RefCell>, + } + + impl FakeCookieStore { + pub(crate) fn browser(mut self, browser: BrowserType) -> Self { + self.browsers.push(browser); + self + } + + pub(crate) fn with(mut self, browser: BrowserType, domain: &str, read: FakeRead) -> Self { + if !self.browsers.contains(&browser) { + self.browsers.push(browser); + } + self.reads.insert((browser, domain.to_string()), read); + self + } + + pub(crate) fn cookies( + self, + browser: BrowserType, + domain: &str, + cookies: &[(&'static str, &'static str)], + ) -> Self { + self.with(browser, domain, FakeRead::Cookies(cookies.to_vec())) + } + + pub(crate) fn reads(&self) -> Vec<(BrowserType, String)> { + self.read_log.borrow().clone() + } + } + + impl BrowserCookieStore for &FakeCookieStore { + fn detect_browsers(&self) -> Vec { + self.browsers + .iter() + .map(|browser_type| DetectedBrowser { + browser_type: *browser_type, + user_data_dir: PathBuf::from("fake"), + profiles: vec![BrowserProfile { + name: "Default".into(), + path: PathBuf::from("fake/Default"), + is_default: true, + }], + }) + .collect() + } + + fn read_cookies( + &self, + browser: &DetectedBrowser, + domain: &str, + ) -> Result, CookieError> { + self.read_log + .borrow_mut() + .push((browser.browser_type, domain.to_string())); + match self.reads.get(&(browser.browser_type, domain.to_string())) { + None => Ok(Vec::new()), + Some(FakeRead::Cookies(cookies)) => Ok(cookies + .iter() + .map(|(name, value)| Cookie { + name: (*name).to_string(), + value: (*value).to_string(), + domain: domain.to_string(), + path: "/".into(), + expires: None, + is_secure: true, + is_http_only: true, + }) + .collect()), + Some(FakeRead::AppBoundEncryption) => Err(CookieError::AppBoundEncryption), + Some(FakeRead::Failure) => Err(CookieError::Decryption("fake".into())), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::test_support::{FakeCookieStore, FakeRead}; + use super::*; + + fn headers(scan: BrowserCookieScan) -> Vec<(BrowserType, String)> { + scan.map(|candidate| (candidate.browser, candidate.header())) + .collect() + } + + #[test] + fn reads_nothing_until_first_candidate_requested() { + let store = + FakeCookieStore::default().cookies(BrowserType::Chrome, "a.test", &[("s", "1")]); + let _scan = BrowserCookieScan::with_store(&store, &["a.test"]); + assert!(store.reads().is_empty()); + } + + #[test] + fn accepting_first_browser_never_reads_later_browsers() { + let store = FakeCookieStore::default() + .cookies(BrowserType::Chrome, "a.test", &[("s", "chrome")]) + .cookies(BrowserType::Edge, "a.test", &[("s", "edge")]) + .cookies(BrowserType::Firefox, "a.test", &[("s", "firefox")]); + let mut scan = BrowserCookieScan::with_store(&store, &["a.test"]); + + let first = scan.next().expect("first candidate"); + assert_eq!(first.browser, BrowserType::Chrome); + assert_eq!(store.reads(), vec![(BrowserType::Chrome, "a.test".into())]); + } + + #[test] + fn stale_first_browser_moves_to_next_browser_with_cookies() { + let store = FakeCookieStore::default() + .browser(BrowserType::Chrome) + .cookies(BrowserType::Edge, "a.test", &[("consent", "stale")]) + .cookies(BrowserType::Brave, "a.test", &[("session", "valid")]); + let mut scan = BrowserCookieScan::with_store(&store, &["a.test"]); + + let first = scan.next().expect("stale candidate"); + assert_eq!(first.browser, BrowserType::Edge); + // The caller rejected Edge's session; only now is Brave read. + assert_eq!(store.reads().len(), 2); + let second = scan.next().expect("valid candidate"); + assert_eq!(second.browser, BrowserType::Brave); + assert_eq!(second.header(), "session=valid"); + assert!(scan.next().is_none()); + } + + #[test] + fn explicit_browser_order_is_respected_and_excludes_unselected_browsers() { + let store = FakeCookieStore::default() + .cookies(BrowserType::Chrome, "a.test", &[("s", "chrome")]) + .cookies(BrowserType::Edge, "a.test", &[("s", "edge")]) + .cookies(BrowserType::Firefox, "a.test", &[("s", "firefox")]); + let scan = BrowserCookieScan::with_store(&store, &["a.test"]) + .with_browser_order(&[BrowserType::Firefox, BrowserType::Edge]); + + assert_eq!( + headers(scan), + vec![ + (BrowserType::Firefox, "s=firefox".into()), + (BrowserType::Edge, "s=edge".into()), + ] + ); + assert!( + store + .reads() + .iter() + .all(|(browser, _)| *browser != BrowserType::Chrome) + ); + } + + #[test] + fn domains_are_scanned_domain_major_and_same_browser_repeats_are_skipped() { + let store = FakeCookieStore::default() + .cookies(BrowserType::Chrome, "app.a.test", &[("s", "1")]) + .cookies(BrowserType::Chrome, "a.test", &[("s", "1")]) + .cookies(BrowserType::Firefox, "a.test", &[("s", "2")]); + let scan = BrowserCookieScan::with_store(&store, &["app.a.test", "a.test"]); + + assert_eq!( + headers(scan), + vec![ + (BrowserType::Chrome, "s=1".into()), + (BrowserType::Firefox, "s=2".into()), + ] + ); + } + + #[test] + fn browser_major_tries_every_domain_of_an_earlier_browser_first() { + let store = FakeCookieStore::default() + .cookies(BrowserType::Chrome, "b.test", &[("s", "chrome")]) + .cookies(BrowserType::Firefox, "a.test", &[("s", "firefox")]); + + let domain_major = BrowserCookieScan::with_store(&store, &["a.test", "b.test"]); + assert_eq!( + headers(domain_major), + vec![ + (BrowserType::Firefox, "s=firefox".into()), + (BrowserType::Chrome, "s=chrome".into()), + ] + ); + + let reads_before = store.reads().len(); + let mut browser_major = + BrowserCookieScan::with_store(&store, &["a.test", "b.test"]).browser_major(); + let first = browser_major.next().expect("chrome candidate"); + assert_eq!(first.browser, BrowserType::Chrome); + assert_eq!(first.domain, "b.test"); + // Firefox is not read until Chrome's session is rejected. + assert_eq!( + store.reads()[reads_before..], + [ + (BrowserType::Chrome, "a.test".to_string()), + (BrowserType::Chrome, "b.test".to_string()), + ] + ); + let second = browser_major.next().expect("firefox candidate"); + assert_eq!(second.browser, BrowserType::Firefox); + assert!(browser_major.next().is_none()); + } + + #[test] + fn read_failures_are_skipped_and_app_bound_encryption_is_reported() { + let store = FakeCookieStore::default() + .with(BrowserType::Chrome, "a.test", FakeRead::AppBoundEncryption) + .with(BrowserType::Edge, "a.test", FakeRead::Failure); + let mut scan = BrowserCookieScan::with_store(&store, &["a.test"]); + + assert!(scan.next().is_none()); + assert!(matches!( + scan.exhaustion_error(), + CookieError::AppBoundEncryption + )); + } + + #[test] + fn exhaustion_reports_missing_browsers_and_missing_cookies() { + let none = FakeCookieStore::default(); + let mut scan = BrowserCookieScan::with_store(&none, &["a.test"]); + assert!(scan.next().is_none()); + assert!(matches!( + scan.exhaustion_error(), + CookieError::BrowserNotInstalled + )); + + let empty = FakeCookieStore::default().browser(BrowserType::Chrome); + let mut scan = BrowserCookieScan::with_store(&empty, &["a.test", "b.test"]); + assert!(scan.next().is_none()); + assert!( + matches!(scan.exhaustion_error(), CookieError::NotFound(domains) if domains == "a.test, b.test") + ); + } +} diff --git a/rust/src/providers/claude/web_api.rs b/rust/src/providers/claude/web_api.rs index 1cb462fde99..d37fcd91393 100755 --- a/rust/src/providers/claude/web_api.rs +++ b/rust/src/providers/claude/web_api.rs @@ -369,11 +369,24 @@ impl ClaudeWebApiFetcher { } } - let cookie_header = crate::providers::browser_cookie_header(&domains)?; - let result = self.fetch_with_cookie_header(&cookie_header).await?; - let _stored = - CookieHeaderCache::store(crate::core::ProviderId::Claude, &cookie_header, "browser"); - Ok(result) + // A signed-out browser session moves on to the next browser's session. + let mut sessions = crate::providers::unscoped_browser_cookie_sessions(&domains); + let mut rejected = None; + for cookie_header in sessions.by_ref() { + match self.fetch_with_cookie_header(&cookie_header).await { + Ok(result) => { + let _stored = CookieHeaderCache::store( + crate::core::ProviderId::Claude, + &cookie_header, + "browser", + ); + return Ok(result); + } + Err(error) if is_cookie_authentication_failure(&error) => rejected = Some(error), + Err(error) => return Err(error), + } + } + Err(rejected.unwrap_or_else(|| sessions.exhaustion_error())) } /// Fetch usage with a provided cookie header diff --git a/rust/src/providers/cursor/mod.rs b/rust/src/providers/cursor/mod.rs index 0903cbe8fa1..4567b6101d8 100755 --- a/rust/src/providers/cursor/mod.rs +++ b/rust/src/providers/cursor/mod.rs @@ -74,22 +74,28 @@ impl CursorProvider { ), ProviderError, > { - let cookie_header = if let Some(cookie_header) = ctx.manual_cookie_header.as_deref() { - cookie_header.to_string() - } else { - // Upstream 0.50.0 #2398: Automatic mode prefers the signed-in - // Cursor app's read-only local session over browser cookies. - // A rejected app session (stale token, account mismatch) - // surfaces in the log and falls back to the browser import. - if ctx.source_mode == SourceMode::Auto - && let Some(app_result) = self.fetch_via_app_session().await - { - return Ok(app_result); + if let Some(cookie_header) = ctx.manual_cookie_header.as_deref() { + return self.fetch_usage_and_token_report(cookie_header).await; + } + // Upstream 0.50.0 #2398: Automatic mode prefers the signed-in + // Cursor app's read-only local session over browser cookies. + // A rejected app session (stale token, account mismatch) + // surfaces in the log and falls back to the browser import. + if ctx.source_mode == SourceMode::Auto + && let Some(app_result) = self.fetch_via_app_session().await + { + return Ok(app_result); + } + let mut sessions = + crate::providers::browser_cookie_sessions(ctx, &["cursor.com", "cursor.sh"]); + let mut rejected = None; + for cookie_header in sessions.by_ref() { + match self.fetch_usage_and_token_report(&cookie_header).await { + Err(ProviderError::AuthRequired) => rejected = Some(ProviderError::AuthRequired), + result => return result, } - crate::providers::browser_cookie_header(&["cursor.com", "cursor.sh"])? - }; - - self.fetch_usage_and_token_report(&cookie_header).await + } + Err(rejected.unwrap_or_else(|| sessions.exhaustion_error())) } /// One usage pass with the app's local session; `None` means the app diff --git a/rust/src/providers/helmcode.rs b/rust/src/providers/helmcode.rs index 5f3461d7ccc..f7ded8767de 100644 --- a/rust/src/providers/helmcode.rs +++ b/rust/src/providers/helmcode.rs @@ -109,18 +109,24 @@ impl HelmcodeProvider { }; let mut rejected = false; for tenant in candidates { - let cookie = match ctx.manual_cookie_header.as_deref() { - Some(raw) => crate::providers::normalize_cookie_header(raw) - .ok_or(ProviderError::NoCookies)?, - None => match crate::providers::browser_cookie_header(&[tenant.domain()]) { - Ok(header) => header, - Err(_) => continue, - }, - }; - match self.fetch_tenant(tenant, &cookie).await { - Ok(result) => return Ok(result), - Err(ProviderError::AuthRequired) => rejected = true, - Err(error) => return Err(error), + let cookies: Box + Send> = + match ctx.manual_cookie_header.as_deref() { + Some(raw) => Box::new(std::iter::once( + crate::providers::normalize_cookie_header(raw) + .ok_or(ProviderError::NoCookies)?, + )), + // A signed-out browser session moves on to the next browser. + None => Box::new(crate::providers::browser_cookie_sessions( + ctx, + &[tenant.domain()], + )), + }; + for cookie in cookies { + match self.fetch_tenant(tenant, &cookie).await { + Ok(result) => return Ok(result), + Err(ProviderError::AuthRequired) => rejected = true, + Err(error) => return Err(error), + } } } if rejected { diff --git a/rust/src/providers/hyper/mod.rs b/rust/src/providers/hyper/mod.rs index e002b28cbd4..2f8a2692e42 100644 --- a/rust/src/providers/hyper/mod.rs +++ b/rust/src/providers/hyper/mod.rs @@ -184,6 +184,20 @@ impl HyperProvider { ctx: &FetchContext, key: Option<&str>, ) -> Result { + self.fetch_with_sessions(ctx, key, || session_cookies(ctx)) + .await + } + + /// `sessions` is called only on paths that may read a browser. + async fn fetch_with_sessions( + &self, + ctx: &FetchContext, + key: Option<&str>, + sessions: impl FnOnce() -> I + Send, + ) -> Result + where + I: Iterator + Send, + { if ctx.source_mode == SourceMode::Cli { return Err(ProviderError::UnsupportedSource(SourceMode::Cli)); } @@ -195,19 +209,18 @@ impl HyperProvider { // Upstream turns browser cookies off for the API source. if ctx.source_mode != SourceMode::OAuth { - match session_cookie(ctx) { - Some(cookie) => match self.request_dashboard(&cookie).await { + let mut session_rejected = false; + for cookie in sessions() { + match self.request_dashboard(&cookie).await { Ok(reply) => { if let Some(result) = classify_dashboard(&reply)? { return Ok(result); } // Upstream also drops its cached browser cookie here. // This port keeps no cookie cache, so the next refresh - // reads the browser again. - if fallback_key.is_none() { - return Err(ProviderError::Other(SESSION_EXPIRED.into())); - } - tracing::debug!("Charm Hyper session was rejected; trying the API key"); + // reads the browser again. A rejected browser session + // moves on to the next browser's session first. + session_rejected = true; } Err(error) if fallback_key.is_none() => { tracing::debug!(%error, "Charm Hyper session request failed"); @@ -215,10 +228,18 @@ impl HyperProvider { } Err(error) => { tracing::debug!(%error, "Charm Hyper session request failed; trying the API key"); + session_rejected = false; + break; } - }, - None if fallback_key.is_none() => return Err(missing_credential()), - None => {} + } + } + match (session_rejected, fallback_key.is_some()) { + (true, false) => return Err(ProviderError::Other(SESSION_EXPIRED.into())), + (true, true) => { + tracing::debug!("Charm Hyper session was rejected; trying the API key"); + } + (false, false) => return Err(missing_credential()), + (false, true) => {} } } @@ -389,23 +410,18 @@ fn collapse_whitespace(raw: &str) -> String { raw.split_whitespace().collect::>().join(" ") } -/// The session cookie upstream's browser broker would hand out: a manual or -/// shell-resolved header first, nothing when the cookie source is off or an -/// empty manual source, otherwise the browser's hyper.charm.land session. -fn session_cookie(ctx: &FetchContext) -> Option { +/// The session cookies upstream's browser broker would hand out: a manual or +/// shell-resolved header alone, nothing when the cookie source is off or an +/// empty manual source, otherwise each browser's hyper.charm.land session in +/// browser order, read only as far as needed. +fn session_cookies(ctx: &FetchContext) -> Box + Send> { if let Some(header) = ctx.manual_cookie_header.as_deref() { - return crate::providers::normalize_cookie_header(header); - } - if ctx.manual_cookie_missing { - return None; - } - match crate::providers::browser_cookie_header(&[COOKIE_DOMAIN]) { - Ok(header) => crate::providers::normalize_cookie_header(&header), - Err(error) => { - tracing::debug!(%error, "Charm Hyper browser session is unavailable"); - None - } + return Box::new(crate::providers::normalize_cookie_header(header).into_iter()); } + Box::new( + crate::providers::browser_cookie_sessions(ctx, &[COOKIE_DOMAIN]) + .filter_map(|header| crate::providers::normalize_cookie_header(&header)), + ) } /// Upstream's `SettingsValue.cleaned`: trimmed, one pair of wrapping quotes diff --git a/rust/src/providers/hyper/tests.rs b/rust/src/providers/hyper/tests.rs index 96888e369a1..1ceb40ccd0a 100644 --- a/rust/src/providers/hyper/tests.rs +++ b/rust/src/providers/hyper/tests.rs @@ -796,3 +796,94 @@ fn provider_capabilities_match_the_upstream_descriptor() { ProviderStateKind::Unknown ); } + +/// Browser sessions as the lazy scan yields them: Edge's stale cookie first, +/// then Brave's signed-in one. No real browser store is read. +fn browser_sessions<'a>( + log: &'a std::sync::Mutex>, +) -> impl Iterator + Send + 'a { + [ + "session=stale-edge", + "session=valid-brave", + "session=never-read", + ] + .into_iter() + .inspect(move |cookie| log.lock().unwrap().push(cookie)) + .map(str::to_string) +} + +#[tokio::test] +async fn rejected_browser_session_moves_on_to_the_next_browser() { + let mut server = Server::new_async().await; + let stale = session_mock(&mut server, "session=stale-edge", 200, LANDING_HTML, 1).await; + let valid = session_mock(&mut server, "session=valid-brave", 200, DASHBOARD_HTML, 1).await; + let api = api_mock(&mut server, 200, BALANCE_BODY, 0).await; + let provider = test_provider(&server.url()); + let ctx = FetchContext { + source_mode: SourceMode::Auto, + ..FetchContext::default() + }; + let read = std::sync::Mutex::new(Vec::new()); + + let result = provider + .fetch_with_sessions(&ctx, Some(KEY), || browser_sessions(&read)) + .await + .unwrap(); + + stale.assert_async().await; + valid.assert_async().await; + api.assert_async().await; + assert_eq!(result.source_label, "web"); + assert_eq!( + *read.lock().unwrap(), + ["session=stale-edge", "session=valid-brave"], + "the browser after the accepted session must not be read" + ); +} + +#[tokio::test] +async fn api_source_never_asks_for_browser_sessions() { + let mut server = Server::new_async().await; + let api = api_mock(&mut server, 200, BALANCE_BODY, 1).await; + let provider = test_provider(&server.url()); + let ctx = FetchContext { + source_mode: SourceMode::OAuth, + ..FetchContext::default() + }; + + provider + .fetch_with_sessions(&ctx, Some(KEY), || -> std::vec::IntoIter { + panic!("the API source must not read a browser") + }) + .await + .unwrap(); + + api.assert_async().await; +} + +#[tokio::test] +async fn every_rejected_browser_session_reports_an_expired_session_in_web() { + let mut server = Server::new_async().await; + let stale = server + .mock("GET", "/") + .match_header("authorization", Matcher::Missing) + .with_status(401) + .expect(2) + .create_async() + .await; + let provider = test_provider(&server.url()); + let ctx = FetchContext { + source_mode: SourceMode::Web, + ..FetchContext::default() + }; + + let error = provider + .fetch_with_sessions(&ctx, None, || { + ["session=a", "session=b"].into_iter().map(str::to_string) + }) + .await + .unwrap_err(); + + stale.assert_async().await; + assert_eq!(message(&error), SESSION_EXPIRED); +} diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index 550c9404aa4..2a36b72080f 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -296,6 +296,77 @@ pub(crate) fn browser_cookie_header( .map_err(map_browser_cookie_error) } +/// Browser `Cookie` headers for `domains`, read one browser at a time. +/// +/// A provider tries each header and asks for the next one only after the site +/// rejects the previous session, so a browser holding stale cookies no longer +/// hides a later browser's valid login, and browsers after an accepted session +/// are never read. Yields nothing when the cookie source forbids browser +/// import (`FetchContext::manual_cookie_missing`: an empty Manual source or +/// Off). Callers handle a pasted `manual_cookie_header` themselves first. +pub(crate) fn browser_cookie_sessions( + ctx: &crate::core::FetchContext, + domains: &[&str], +) -> BrowserCookieSessions { + browser_cookie_sessions_from( + crate::browser::session_scan::SystemCookieStore, + ctx, + domains, + ) +} + +/// [`browser_cookie_sessions`] for a path that has no `FetchContext` because +/// its caller already decided that browser import is allowed. +pub(crate) fn unscoped_browser_cookie_sessions(domains: &[&str]) -> BrowserCookieSessions { + BrowserCookieSessions { + scan: Some(crate::browser::session_scan::BrowserCookieScan::new(domains).browser_major()), + } +} + +pub(crate) fn browser_cookie_sessions_from( + store: S, + ctx: &crate::core::FetchContext, + domains: &[&str], +) -> BrowserCookieSessions { + BrowserCookieSessions { + scan: (!ctx.manual_cookie_missing).then(|| { + crate::browser::session_scan::BrowserCookieScan::with_store(store, domains) + .browser_major() + }), + } +} + +pub(crate) struct BrowserCookieSessions< + S: crate::browser::session_scan::BrowserCookieStore = + crate::browser::session_scan::SystemCookieStore, +> { + scan: Option>, +} + +impl BrowserCookieSessions { + /// Why no (further) browser session is available: `NoCookies` when no + /// browser holds cookies or import is disabled, otherwise a read error + /// such as App-Bound Encryption. + pub(crate) fn exhaustion_error(&self) -> crate::core::ProviderError { + self.scan + .as_ref() + .map_or(crate::core::ProviderError::NoCookies, |scan| { + map_browser_cookie_error(scan.exhaustion_error()) + }) + } +} + +impl Iterator for BrowserCookieSessions { + type Item = String; + + fn next(&mut self) -> Option { + self.scan + .as_mut()? + .next() + .map(|candidate| candidate.header()) + } +} + pub(crate) fn browser_cookie_headers_for_domain( domain: &str, ) -> Result, crate::core::ProviderError> { @@ -558,9 +629,49 @@ pub(crate) fn extract_renewal(text: &str) -> Option Result { - if let Some(raw) = ctx.manual_cookie_header.as_deref() - && let Some(header) = normalize_cookie_header(raw) - { - return Ok(header); - } - browser_cookie_header(COOKIE_DOMAINS) - .and_then(|header| normalize_cookie_header(&header).ok_or(ProviderError::NoCookies)) - } - + /// A usable pasted header is the only session tried. Otherwise each + /// browser's session is tried in turn, moving on only after a 401. async fn fetch_via_web(&self, ctx: &FetchContext) -> Result { - let cookie_header = Self::resolve_cookie_header(ctx)?; let client = crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(ctx.web_timeout.max(1))) .build() .map_err(|e| ProviderError::Other(e.to_string()))?; + if let Some(raw) = ctx.manual_cookie_header.as_deref() + && let Some(header) = normalize_cookie_header(raw) + { + return Self::fetch_with_cookie(ctx, &client, &header).await; + } + let mut sessions = browser_cookie_sessions(ctx, COOKIE_DOMAINS); + let mut rejected = None; + for header in sessions.by_ref() { + let Some(header) = normalize_cookie_header(&header) else { + continue; + }; + match Self::fetch_with_cookie(ctx, &client, &header).await { + Err(ProviderError::AuthRequired) => rejected = Some(ProviderError::AuthRequired), + result => return result, + } + } + Err(rejected.unwrap_or_else(|| sessions.exhaustion_error())) + } - let account = Self::fetch_account(&client, &cookie_header).await?; + async fn fetch_with_cookie( + ctx: &FetchContext, + client: &Client, + cookie_header: &str, + ) -> Result { + let account = Self::fetch_account(client, cookie_header).await?; let workspace = account .resolve_workspace(ctx.workspace_id.as_deref()) .cloned() @@ -178,7 +192,7 @@ impl NotionProvider { ProviderError::Other("No Notion workspace found for this account.".into()) })?; - let rate_limit = Self::fetch_rate_limit(&client, &cookie_header, &workspace.id).await?; + let rate_limit = Self::fetch_rate_limit(client, cookie_header, &workspace.id).await?; if rate_limit.is_not_applicable() { let name = workspace.name.as_deref().unwrap_or("this workspace"); @@ -295,6 +309,12 @@ impl Provider for NotionProvider { fn supports_cli(&self) -> bool { false } + + /// The shell would import only the first browser's cookies; the provider + /// tries each browser so a signed-out session can move to the next one. + fn owns_browser_cookie_resolution(&self) -> bool { + true + } } fn build_usage_snapshot( diff --git a/rust/src/providers/opencodego/mod.rs b/rust/src/providers/opencodego/mod.rs index c638e705d97..e1b29138881 100644 --- a/rust/src/providers/opencodego/mod.rs +++ b/rust/src/providers/opencodego/mod.rs @@ -672,10 +672,19 @@ impl OpenCodeGoProvider { return Err(ProviderError::AuthRequired); } - match crate::providers::browser_cookie_header(session_overlay::COOKIE_DOMAINS) { - Ok(cookie_header) => self.fetch_with_cookies(ctx, &cookie_header).await, - Err(ProviderError::NoCookies) => Err(ProviderError::AuthRequired), - Err(e) => Err(e), + // A signed-out browser session moves on to the next browser's session. + let mut sessions = + crate::providers::browser_cookie_sessions(ctx, session_overlay::COOKIE_DOMAINS); + let mut rejected = None; + for cookie_header in sessions.by_ref() { + match self.fetch_with_cookies(ctx, &cookie_header).await { + Err(ProviderError::AuthRequired) => rejected = Some(ProviderError::AuthRequired), + result => return result, + } + } + match rejected.unwrap_or_else(|| sessions.exhaustion_error()) { + ProviderError::NoCookies => Err(ProviderError::AuthRequired), + error => Err(error), } } } diff --git a/rust/src/providers/typesafe.rs b/rust/src/providers/typesafe.rs index 67136d6013c..4d15a965db2 100644 --- a/rust/src/providers/typesafe.rs +++ b/rust/src/providers/typesafe.rs @@ -15,6 +15,7 @@ use crate::core::{ const BILLING_URL: &str = "https://console.typesafe.ai/settings/billing"; const ORIGIN: &str = "https://console.typesafe.ai"; +const COOKIE_DOMAINS: [&str; 2] = ["console.typesafe.ai", "typesafe.ai"]; const MAX_CHUNKS: usize = 60; const CHUNK_SCAN_CONCURRENCY: usize = 6; const MAX_BODY_BYTES: usize = 2 * 1024 * 1024; @@ -66,24 +67,35 @@ impl TypeSafeProvider { } async fn fetch_web(&self, ctx: &FetchContext) -> Result { - let cookie = match ctx.manual_cookie_header.as_deref() { - Some(raw) => crate::providers::normalize_cookie_header(raw).ok_or_else(|| { + if let Some(raw) = ctx.manual_cookie_header.as_deref() { + let cookie = crate::providers::normalize_cookie_header(raw).ok_or_else(|| { ProviderError::Other( "TypeSafe needs a nonempty Cookie header from the billing page.".into(), ) - })?, - None => { - crate::providers::browser_cookie_header(&["console.typesafe.ai", "typesafe.ai"])? + })?; + return self.fetch_with_cookie(&cookie).await; + } + // A signed-out browser session moves on to the next browser. + let mut sessions = crate::providers::browser_cookie_sessions(ctx, &COOKIE_DOMAINS); + let mut rejected = None; + for cookie in sessions.by_ref() { + match self.fetch_with_cookie(&cookie).await { + Err(ProviderError::AuthRequired) => rejected = Some(ProviderError::AuthRequired), + result => return result, } - }; - let page = self.get(BILLING_URL, &cookie, "text/html").await?; - let action_id = self.discover_action(&cookie, &page).await?; - let response = match self.post_action(&cookie, &action_id).await? { + } + Err(rejected.unwrap_or_else(|| sessions.exhaustion_error())) + } + + async fn fetch_with_cookie(&self, cookie: &str) -> Result { + let page = self.get(BILLING_URL, cookie, "text/html").await?; + let action_id = self.discover_action(cookie, &page).await?; + let response = match self.post_action(cookie, &action_id).await? { Some(response) => response, None => { - let refreshed_page = self.get(BILLING_URL, &cookie, "text/html").await?; - let refreshed_action = self.discover_action(&cookie, &refreshed_page).await?; - self.post_action(&cookie, &refreshed_action) + let refreshed_page = self.get(BILLING_URL, cookie, "text/html").await?; + let refreshed_action = self.discover_action(cookie, &refreshed_page).await?; + self.post_action(cookie, &refreshed_action) .await? .ok_or_else(|| parse_failure("server action stayed stale after rediscovery"))? } diff --git a/rust/src/providers/zed/mod.rs b/rust/src/providers/zed/mod.rs index 9416a7eacc3..f87b40758fd 100644 --- a/rust/src/providers/zed/mod.rs +++ b/rust/src/providers/zed/mod.rs @@ -76,8 +76,37 @@ impl ZedProvider { snapshot::editor_result(&body, chrono::Utc::now()) } + /// A pasted header is the only session tried. Otherwise each browser's + /// session is tried in turn, moving on only after an expired session. async fn fetch_web(&self, ctx: &FetchContext) -> Result { - let cookie = web_cookie(ctx)?; + let missing = || ProviderError::NotInstalled(MISSING_SESSION.into()); + if let Some(raw) = ctx.manual_cookie_header.as_deref() { + let cookie = crate::providers::normalize_cookie_header(raw).ok_or_else(missing)?; + return self.fetch_billing(cookie).await; + } + if ctx.manual_cookie_missing { + return Err(missing()); + } + let mut sessions = crate::providers::browser_cookie_sessions(ctx, &[COOKIE_DOMAIN]); + let mut rejected = None; + for header in sessions.by_ref() { + let Some(cookie) = crate::providers::normalize_cookie_header(&header) else { + continue; + }; + match self.fetch_billing(cookie).await { + Err(error) if is_session_expired(&error) => rejected = Some(error), + result => return result, + } + } + Err( + rejected.unwrap_or_else(|| match sessions.exhaustion_error() { + ProviderError::NoCookies => missing(), + error => error, + }), + ) + } + + async fn fetch_billing(&self, cookie: String) -> Result { let request = self .client .get(&self.billing_url) @@ -132,11 +161,15 @@ impl Provider for ZedProvider { ManualEmptyCookiePolicy::FailClosedWeb } + /// The shell would import only the first browser's cookies; the provider + /// tries each browser so an expired session can move to the next one. + fn owns_browser_cookie_resolution(&self) -> bool { + true + } + fn error_state_kind(&self, error: &ProviderError) -> ProviderStateKind { match error { - ProviderError::Other(message) if message == SESSION_EXPIRED => { - ProviderStateKind::ExpiredSession - } + error if is_session_expired(error) => ProviderStateKind::ExpiredSession, other => other.state_kind(), } } @@ -158,21 +191,8 @@ async fn read_body(request: reqwest::RequestBuilder, web: bool) -> Result Result { - let missing = || ProviderError::NotInstalled(MISSING_SESSION.into()); - if let Some(raw) = ctx.manual_cookie_header.as_deref() { - return crate::providers::normalize_cookie_header(raw).ok_or_else(missing); - } - if ctx.manual_cookie_missing { - return Err(missing()); - } - match crate::providers::browser_cookie_header(&[COOKIE_DOMAIN]) { - Ok(header) => crate::providers::normalize_cookie_header(&header).ok_or_else(missing), - Err(ProviderError::NoCookies) => Err(missing()), - Err(error) => Err(error), - } +fn is_session_expired(error: &ProviderError) -> bool { + matches!(error, ProviderError::Other(message) if message == SESSION_EXPIRED) } fn status_error(status: StatusCode, web: bool) -> ProviderError {