From 55864a061050a21995665c6f13635af08959a5aa Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 16 Oct 2026 15:11:38 +0700 Subject: [PATCH 1/5] Add lazy per-browser cookie session scan --- .../src-tauri/src/commands/providers.rs | 12 +- rust/src/browser/cookies.rs | 141 ++---- rust/src/browser/mod.rs | 1 + rust/src/browser/session_scan.rs | 427 ++++++++++++++++++ rust/src/providers/mod.rs | 103 ++++- 5 files changed, 564 insertions(+), 120 deletions(-) create mode 100644 rust/src/browser/session_scan.rs 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/rust/src/browser/cookies.rs b/rust/src/browser/cookies.rs index 3233d702a50..95281ee2775 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..c2b6db5b5e1 --- /dev/null +++ b/rust/src/browser/session_scan.rs @@ -0,0 +1,427 @@ +//! 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) + } +} + +/// Iterator over browser cookie candidates for a list of domains. +/// +/// Order is domain-major: every browser is tried for the first domain before +/// any browser is tried for the second, so the first candidate is exactly the +/// one the older first-match helpers returned. 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 later domain that +/// yields the same header from the same browser 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>, + domain_index: usize, + browser_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, + domain_index: 0, + browser_index: 0, + yielded: Vec::new(), + app_bound_encryption_seen: false, + } + } + + /// 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(); + while self.domain_index < self.domains.len() { + if self.browser_index >= browser_count { + self.domain_index += 1; + self.browser_index = 0; + continue; + } + let index = self.browser_index; + let browser = self.browsers()[index].clone(); + self.browser_index += 1; + let domain = self.domains[self.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 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/mod.rs b/rust/src/providers/mod.rs index 550c9404aa4..8ae38c1356f 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -296,6 +296,67 @@ 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, + ) +} + +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)), + } +} + +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 +619,49 @@ pub(crate) fn extract_renewal(text: &str) -> Option Date: Fri, 16 Oct 2026 15:19:32 +0700 Subject: [PATCH 2/5] Try each browser session in self-resolving cookie providers --- rust/src/providers/claude/web_api.rs | 23 +++++-- rust/src/providers/helmcode.rs | 30 +++++---- rust/src/providers/hyper/mod.rs | 66 ++++++++++++-------- rust/src/providers/hyper/tests.rs | 91 ++++++++++++++++++++++++++++ rust/src/providers/mod.rs | 10 +++ rust/src/providers/opencodego/mod.rs | 17 ++++-- rust/src/providers/typesafe.rs | 36 +++++++---- 7 files changed, 215 insertions(+), 58 deletions(-) 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/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 8ae38c1356f..4b8bc50f84f 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -315,6 +315,16 @@ pub(crate) fn browser_cookie_sessions( ) } +/// [`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, + )), + } +} + pub(crate) fn browser_cookie_sessions_from( store: S, ctx: &crate::core::FetchContext, 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"))? } From 7fd17cf331e0f938cbe953eda20c9b557ebae3ad Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 16 Oct 2026 15:24:41 +0700 Subject: [PATCH 3/5] Try each browser session in Zed, Notion, and Cursor --- rust/src/providers/cursor/mod.rs | 36 +++++++++++--------- rust/src/providers/notion/mod.rs | 48 ++++++++++++++++++-------- rust/src/providers/zed/mod.rs | 58 +++++++++++++++++++++----------- 3 files changed, 94 insertions(+), 48 deletions(-) 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/notion/mod.rs b/rust/src/providers/notion/mod.rs index f36d3a93781..88de2c13f2c 100644 --- a/rust/src/providers/notion/mod.rs +++ b/rust/src/providers/notion/mod.rs @@ -17,7 +17,7 @@ use crate::core::{ FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, }; -use crate::providers::browser_cookie_header; +use crate::providers::browser_cookie_sessions; const BASE_URL: &str = "https://app.notion.com"; const GET_SPACES_URL: &str = "https://app.notion.com/api/v3/getSpaces"; @@ -153,24 +153,38 @@ impl NotionProvider { } } - fn resolve_cookie_header(ctx: &FetchContext) -> 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/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 { From fb2fe0bfb46a3e5715df041997f33cd57450310b Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 16 Oct 2026 15:25:07 +0700 Subject: [PATCH 4/5] Document first accepted browser session scan --- CHANGELOG.md | 1 + docs/COOKIES.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72ce7fc1112..3a50595e822 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). - 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. - OpenAI API: each Admin refresh also reads the last hour of 1-minute usage from the Admin Usage API (input plus output tokens, cached input included, Batch API traffic kept out of limit headroom) and carries a Tokens/min grid with a reporting frontier, so buckets that arrive late show as pending instead of zero. The backend can also load 15-minute, 1-hour or 6-hour windows, read per-project rate-limit caps, and measure headroom against manual per-model organization limits stored in settings. The per-refresh request can be turned off, and a failure never affects the daily usage. The menu bar token comes in a later release (upstream #4495). - OpenAI API: the card shows the last-hour Tokens/min grid (latest, peak, 5-minute average, reporting frontier, and pending minutes drawn as outlines), and Settings > Providers > OpenAI adds a Live Usage section with a refresh switch, a per-model organization limits editor, a 15-minute / 1-hour / 6-hour window with per-model rows, and the organization caps list, which stays hidden when a project id is saved (upstream #4495). diff --git a/docs/COOKIES.md b/docs/COOKIES.md index 1a33031228b..3c3a0e129cc 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: 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 From 6588e7af39425ff1d3b6299d5af495409f1e23e5 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 16 Oct 2026 17:07:36 +0700 Subject: [PATCH 5/5] Scan provider browser sessions browser-major --- docs/COOKIES.md | 2 +- rust/src/browser/session_scan.rs | 102 +++++++++++++++++++++++++------ rust/src/providers/mod.rs | 10 +-- 3 files changed, 88 insertions(+), 26 deletions(-) diff --git a/docs/COOKIES.md b/docs/COOKIES.md index 3c3a0e129cc..7fb3249b389 100644 --- a/docs/COOKIES.md +++ b/docs/COOKIES.md @@ -23,7 +23,7 @@ Chromium App-Bound Encryption (ABE) binds protected cookie keys to the browser i ### 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: 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. +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 diff --git a/rust/src/browser/session_scan.rs b/rust/src/browser/session_scan.rs index c2b6db5b5e1..aab63b71c9e 100644 --- a/rust/src/browser/session_scan.rs +++ b/rust/src/browser/session_scan.rs @@ -62,23 +62,35 @@ impl BrowserCookieCandidate { } } +/// 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 domain-major: every browser is tried for the first domain before -/// any browser is tried for the second, so the first candidate is exactly the -/// one the older first-match helpers returned. 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 later domain that -/// yields the same header from the same browser is skipped too, since it -/// would only repeat a session the caller already tried. +/// 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>, - domain_index: usize, - browser_index: usize, + order: ScanOrder, + outer_index: usize, + inner_index: usize, yielded: Vec<(BrowserType, String)>, app_bound_encryption_seen: bool, } @@ -98,13 +110,21 @@ impl BrowserCookieScan { domains: domains.iter().map(|domain| (*domain).to_string()).collect(), browser_order: None, browsers: None, - domain_index: 0, - browser_index: 0, + 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. @@ -148,16 +168,24 @@ impl Iterator for BrowserCookieScan { fn next(&mut self) -> Option { let browser_count = self.browsers().len(); - while self.domain_index < self.domains.len() { - if self.browser_index >= browser_count { - self.domain_index += 1; - self.browser_index = 0; + 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 index = self.browser_index; - let browser = self.browsers()[index].clone(); - self.browser_index += 1; - let domain = self.domains[self.domain_index].clone(); + 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) { @@ -393,6 +421,40 @@ mod tests { ); } + #[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() diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index 4b8bc50f84f..2a36b72080f 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -319,9 +319,7 @@ pub(crate) fn browser_cookie_sessions( /// 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, - )), + scan: Some(crate::browser::session_scan::BrowserCookieScan::new(domains).browser_major()), } } @@ -331,8 +329,10 @@ pub(crate) fn browser_cookie_sessions_from BrowserCookieSessions { BrowserCookieSessions { - scan: (!ctx.manual_cookie_missing) - .then(|| crate::browser::session_scan::BrowserCookieScan::with_store(store, domains)), + scan: (!ctx.manual_cookie_missing).then(|| { + crate::browser::session_scan::BrowserCookieScan::with_store(store, domains) + .browser_major() + }), } }