Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 8 additions & 4 deletions apps/desktop-tauri/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions docs/COOKIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
141 changes: 26 additions & 115 deletions rust/src/browser/cookies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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<Cookie>)>,
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<BrowserCookieCandidates, CookieError> {
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<Vec<Cookie>, 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<Vec<(BrowserType, String)>, 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::<Vec<_>>();

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)
}
Expand All @@ -776,14 +699,15 @@ pub fn get_cookie_headers_for_domain(
pub fn get_cookies_by_browser_for_domain(
domain: &str,
) -> Result<Vec<(BrowserType, Vec<Cookie>)>, 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::<Vec<_>>();
if candidates.is_empty() {
return Err(scan.exhaustion_error());
}
Ok(scan.candidates)
Ok(candidates)
}

/// Get a cookie header string for a domain
Expand All @@ -794,25 +718,12 @@ pub fn get_cookie_header(domain: &str) -> Result<String, CookieError> {

/// Get a cookie header string from the first domain that has readable cookies.
pub fn get_cookie_header_for_domains(domains: &[&str]) -> Result<String, CookieError> {
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,
Expand Down
1 change: 1 addition & 0 deletions rust/src/browser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading