From 82ff2d77a6c4ad32ff314b952a14269c509dfdcf Mon Sep 17 00:00:00 2001 From: Leonardo Araya Date: Sun, 6 Sep 2026 19:58:31 -0300 Subject: [PATCH 1/3] fix(ollama): try every browser before giving up on cookie import Auto/Web cookie import stopped at the first installed browser that returned any cookies for ollama.com, even when those cookies were stale/irrelevant (e.g. a consent or analytics cookie left over from a one-off visit) and carried no recognized session cookie. That starved out a later browser (often the one actually signed in) and surfaced "No cookies available for web API" even with a valid, logged-in session sitting on disk. Walk every detected browser and keep going until one yields a header containing a recognized Ollama session cookie, instead of stopping at the first non-empty result. Extracted the selection logic into a small pure helper with a focused regression test reproducing the exact scenario (irrelevant-only cookies on one browser, real session on the next). Fixes #426 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L23pzyCfMvfbMQwnHXmKCp --- rust/src/providers/ollama/cookies.rs | 95 +++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 10 deletions(-) diff --git a/rust/src/providers/ollama/cookies.rs b/rust/src/providers/ollama/cookies.rs index 880abd4b2a..c99f713867 100644 --- a/rust/src/providers/ollama/cookies.rs +++ b/rust/src/providers/ollama/cookies.rs @@ -153,16 +153,35 @@ pub(super) fn resolve_browser_cookie_header( return Ok(Some(cached.cookie_header)); } - match crate::providers::browser_cookies_for_domain(OLLAMA_COOKIE_DOMAIN) { - Ok(cookies) => { - let url = Url::parse("https://ollama.com/settings") - .map_err(|e| ProviderError::Other(e.to_string()))?; - Ok(ollama_cookie_header_for_url(&cookies, &url) - .filter(|h| has_recognized_ollama_session_cookie(h))) - } - Err(ProviderError::NoCookies) => Ok(None), - Err(err) => Err(err), - } + let url = Url::parse("https://ollama.com/settings") + .map_err(|e| ProviderError::Other(e.to_string()))?; + + // Upstream Win-CodexBar #426: the generic `browser_cookies_for_domain` helper + // stops at the FIRST installed browser that has *any* cookies for the domain, + // even if those cookies are stale/irrelevant (e.g. a consent or CDN cookie left + // behind in Chrome/Edge from a one-off visit) and don't include a recognized + // Ollama session cookie. That starves out a later browser (often Brave) that + // actually holds the logged-in session. Walk every detected browser ourselves + // and keep going until one yields a header with a recognized session cookie. + use crate::browser::cookies::CookieExtractor; + use crate::browser::detection::BrowserDetector; + + let cookie_sets = BrowserDetector::detect_all() + .into_iter() + .filter_map(|browser| { + CookieExtractor::extract_for_domain(&browser, OLLAMA_COOKIE_DOMAIN).ok() + }); + Ok(first_recognized_cookie_header(cookie_sets, &url)) +} + +/// Return the header for the first cookie set (in order) that contains a +/// recognized Ollama session cookie for `url`, skipping sets that decrypt +/// fine but carry no usable session (see `resolve_browser_cookie_header`). +fn first_recognized_cookie_header( + mut cookie_sets: impl Iterator>, + url: &Url, +) -> Option { + cookie_sets.find_map(|cookies| ollama_cookie_header_for_url(&cookies, url)) } pub(super) fn should_attach_ollama_cookie(url: &Url) -> bool { @@ -285,6 +304,62 @@ mod tests { ); } + #[test] + fn first_recognized_cookie_header_skips_browsers_without_a_session_cookie() { + // Regression for #426: an earlier-priority browser (e.g. Chrome/Edge) + // may hold only stale/irrelevant ollama.com cookies (analytics, + // consent) with no session cookie at all. The old + // `browser_cookies_for_domain` helper stopped at that first non-empty + // result and never reached a later browser (e.g. Brave) that actually + // holds the logged-in session. + let irrelevant_only = vec![Cookie { + name: "aid".to_string(), + value: "device-id".to_string(), + domain: "ollama.com".to_string(), + path: "/".to_string(), + expires: None, + is_secure: true, + is_http_only: false, + }]; + let real_session = vec![Cookie { + name: OLLAMA_SESSION_COOKIE_NAME.to_string(), + value: "abc123".to_string(), + domain: "ollama.com".to_string(), + path: "/".to_string(), + expires: None, + is_secure: true, + is_http_only: true, + }]; + + let url = Url::parse("https://ollama.com/settings").unwrap(); + let header = + first_recognized_cookie_header(vec![irrelevant_only, real_session].into_iter(), &url); + + assert_eq!( + header.as_deref(), + Some("__Secure-session=abc123"), + "should skip the first (irrelevant) cookie set and use the second (real session)" + ); + } + + #[test] + fn first_recognized_cookie_header_none_when_no_set_has_a_session_cookie() { + let only_irrelevant = vec![Cookie { + name: "aid".to_string(), + value: "device-id".to_string(), + domain: "ollama.com".to_string(), + path: "/".to_string(), + expires: None, + is_secure: true, + is_http_only: false, + }]; + + let url = Url::parse("https://ollama.com/settings").unwrap(); + let header = first_recognized_cookie_header(vec![only_irrelevant].into_iter(), &url); + + assert_eq!(header, None); + } + #[test] fn ignores_empty_cookie_input() { assert_eq!(normalize_cookie_header(" "), None); From 14056e740a986b8bc78a431e649b416c2aeb29ec Mon Sep 17 00:00:00 2001 From: Leonardo Araya Date: Sun, 6 Sep 2026 19:59:20 -0300 Subject: [PATCH 2/3] fix(minimax): fetch real quota via Bearer API key, not just cookies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MiniMax redesigned the console: /console/usage and /console/plan are now client-rendered Next.js pages loaded with `ssr:false`, so the server never emits real numbers in the initial HTML — not even with a valid, authenticated cookie. Every cookie/HTML-scraping path is structurally unable to read this data, so MiniMax usage fell through to the always-0% "configured" stub whenever cookie scraping failed. The coding-plan `remains` endpoint the HTML scraper already falls back to also accepts a plain `Authorization: Bearer ` with no cookie at all, and returns the exact `model_remains` JSON shape the existing parser (coding_plan.rs) already understands. Add a Bearer-authenticated path that tries this endpoint first, using an API key from Settings or `MINIMAX_API_KEY`, before falling back to the legacy group_id+api_key billing endpoint. Also register MiniMax in `get_api_key_providers()` so the API key can be entered through Settings/`config set-api-key` like other providers — previously there was no supported way to configure just an API key (only a paired group_id+api_key via env vars or a local `minimax`-CLI-style config file). Fixes #425 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L23pzyCfMvfbMQwnHXmKCp --- rust/src/providers/minimax/mod.rs | 145 +++++++++++++++++++++++++++++- rust/src/settings/api_keys.rs | 13 +++ 2 files changed, 155 insertions(+), 3 deletions(-) diff --git a/rust/src/providers/minimax/mod.rs b/rust/src/providers/minimax/mod.rs index 03dbe5a0b8..3cf85d5267 100755 --- a/rust/src/providers/minimax/mod.rs +++ b/rust/src/providers/minimax/mod.rs @@ -267,10 +267,26 @@ impl MiniMaxProvider { /// Fetch usage via MiniMax API with region fallback async fn fetch_via_web( &self, + ctx: &FetchContext, region: MiniMaxRegion, ) -> Result { - let (group_id, api_key) = self.read_api_key().await?; + // Prefer the coding-plan remains endpoint (Win-CodexBar #425): the + // console's usage/plan pages are client-rendered (Next.js `ssr:false`), + // so no server HTML ever contains real numbers, even with a valid + // cookie. The underlying `coding_plan/remains` endpoint instead + // accepts a plain `Authorization: Bearer ` with no cookie at + // all (no group_id needed), and returns the same `model_remains` + // shape the cookie-based parser already understands. This key can + // come from Settings (GUI-stored) or the environment, independent of + // the dual group_id+api_key credential the legacy billing endpoint + // below requires. + if let Some(key) = Self::read_plain_api_key(ctx) + && let Ok(result) = self.fetch_remains_via_api_key(&key, region).await + { + return Ok(result); + } + let (group_id, api_key) = self.read_api_key().await?; match self.fetch_from_region(&group_id, &api_key, region).await { Ok(result) => Ok(result), Err(ProviderError::AuthRequired) if region == MiniMaxRegion::Global => { @@ -281,6 +297,88 @@ impl MiniMaxProvider { } } + /// A plain MiniMax API key from Settings (GUI-stored, via `ctx.api_key`) + /// or the `MINIMAX_API_KEY` environment variable. Unlike `read_api_key`, + /// this does not require a paired group_id. + fn read_plain_api_key(ctx: &FetchContext) -> Option { + ctx.api_key + .as_deref() + .map(str::trim) + .filter(|key| !key.is_empty()) + .map(str::to_string) + .or_else(|| { + std::env::var("MINIMAX_API_KEY") + .ok() + .map(|key| key.trim().to_string()) + .filter(|key| !key.is_empty()) + }) + } + + /// Fetch coding-plan quota from the remains endpoint using a Bearer API + /// key instead of a browser cookie. Tries the platform-host URL, then the + /// www-host URL, mirroring the cookie-based fallback chain. + async fn fetch_remains_via_api_key( + &self, + api_key: &str, + region: MiniMaxRegion, + ) -> Result { + let now = Utc::now(); + let urls = [region.coding_plan_remains_url(), region.www_remains_url()]; + let mut last_err: Option = None; + for url in urls { + match self.fetch_remains_once_via_api_key(api_key, &url).await { + Ok(snapshot) => { + let usage = coding_plan_html::to_usage_snapshot(&snapshot, now)?; + return Ok(ProviderFetchResult::new(usage, "api")); + } + Err(err @ ProviderError::Parse(_)) => { + last_err = Some(err); + } + Err(err) => return Err(err), + } + } + Err(last_err.unwrap_or_else(|| ProviderError::Parse("Missing MiniMax remains URL.".into()))) + } + + /// One Bearer-authenticated remains-API request, returning the parsed snapshot. + async fn fetch_remains_once_via_api_key( + &self, + api_key: &str, + url: &str, + ) -> Result { + let client = crate::core::credentialed_http_client_builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| ProviderError::Other(e.to_string()))?; + + let response = client + .get(url) + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json, text/plain, */*") + .send() + .await?; + + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err(ProviderError::AuthRequired); + } + if !status.is_success() { + let msg = format!("MiniMax remains (api key) returned status {status}"); + if status == reqwest::StatusCode::NOT_FOUND + || status == reqwest::StatusCode::METHOD_NOT_ALLOWED + { + return Err(ProviderError::Parse(msg)); + } + return Err(ProviderError::Other(msg)); + } + + let json: serde_json::Value = response + .json() + .await + .map_err(|e| ProviderError::Parse(format!("Failed to parse remains JSON: {e}")))?; + coding_plan::parse_coding_plan_value(&json, Utc::now()) + } + /// Fetch from a specific region endpoint async fn fetch_from_region( &self, @@ -1019,7 +1117,7 @@ impl Provider for MiniMaxProvider { return Ok(result); } // Fall through to API keys. - if let Ok(result) = self.fetch_via_web(region).await { + if let Ok(result) = self.fetch_via_web(ctx, region).await { return Ok(result); } let usage = self.probe_cli().await?; @@ -1027,7 +1125,7 @@ impl Provider for MiniMaxProvider { } SourceMode::Web => match self.resolve_web_cookie(ctx, region)? { Some(cookie) => self.fetch_with_cookie(&cookie, region).await, - None => self.fetch_via_web(region).await, + None => self.fetch_via_web(ctx, region).await, }, SourceMode::Cli => { let usage = self.probe_cli().await?; @@ -1054,6 +1152,47 @@ impl Provider for MiniMaxProvider { mod tests { use super::*; + #[test] + fn read_plain_api_key_prefers_ctx_then_env_then_none() { + let ctx_with_key = FetchContext { + api_key: Some(" ctx-key ".to_string()), + ..FetchContext::default() + }; + assert_eq!( + MiniMaxProvider::read_plain_api_key(&ctx_with_key).as_deref(), + Some("ctx-key") + ); + + // Isolate from any MINIMAX_API_KEY already set in the ambient + // environment (e.g. a developer's own shell), and restore it after. + let previous = std::env::var("MINIMAX_API_KEY").ok(); + // SAFETY: test-only env var; saved above and restored below within + // this single test, with no other test reading MINIMAX_API_KEY. + unsafe { std::env::remove_var("MINIMAX_API_KEY") }; + + let ctx_empty = FetchContext { + api_key: Some(" ".to_string()), + ..FetchContext::default() + }; + assert_eq!(MiniMaxProvider::read_plain_api_key(&ctx_empty), None); + + // SAFETY: see above. + unsafe { std::env::set_var("MINIMAX_API_KEY", "env-key") }; + let result = MiniMaxProvider::read_plain_api_key(&FetchContext::default()); + assert_eq!(result.as_deref(), Some("env-key")); + + match previous { + Some(value) => { + // SAFETY: see above. + unsafe { std::env::set_var("MINIMAX_API_KEY", value) } + } + None => { + // SAFETY: see above. + unsafe { std::env::remove_var("MINIMAX_API_KEY") } + } + } + } + #[test] fn minimax_region_defaults_to_global_io_urls() { let region = MiniMaxRegion::from_settings_value(None); diff --git a/rust/src/settings/api_keys.rs b/rust/src/settings/api_keys.rs index 07ba5b1c8d..c396e8207b 100644 --- a/rust/src/settings/api_keys.rs +++ b/rust/src/settings/api_keys.rs @@ -206,6 +206,19 @@ pub fn get_api_key_providers() -> Vec { config_file_path: None, dashboard_url: Some("https://ollama.com/settings"), }, + ProviderConfigInfo { + id: ProviderId::MiniMax, + name: "MiniMax", + requires_api_key: false, + api_key_env_var: Some("MINIMAX_API_KEY"), + api_key_help: Some( + "Optional: a MiniMax API key reads real coding-plan quota via the console's remains API, bypassing the client-rendered usage/plan pages that browser cookies alone cannot scrape.", + ), + config_file_path: None, + dashboard_url: Some( + "https://platform.minimax.io/user-center/basic-information/interface-key", + ), + }, ProviderConfigInfo { id: ProviderId::AzureOpenAI, name: "Azure OpenAI", From 6c37329443dd3ad5a2854e30154e445568320e60 Mon Sep 17 00:00:00 2001 From: Leonardo Araya Date: Sun, 6 Sep 2026 20:31:13 -0300 Subject: [PATCH 3/3] docs: document Ollama/MiniMax fixes and Codex OAuth staleness gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers three issues investigated and fixed/diagnosed in this session: - Ollama browser-priority cookie starvation (fixed, PR #430) - MiniMax client-rendered console pages / Bearer API key fix (fixed, PR #431) - Codex external-OAuth staleness gate (working as designed, undocumented until now — symptom looks like a bug but the fix is refreshing the Codex CLI's own session, not a CodexBar change) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L23pzyCfMvfbMQwnHXmKCp --- docs/PROVIDERS.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 7053d48beb..ee32bb92d9 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -45,6 +45,34 @@ Settings → **Providers** → provider detail → choose browser → Import. Manual cookie header paste is the fallback (required under WSL for Chromium DPAPI). Details: [COOKIES.md](./COOKIES.md). +## Ollama: browser-priority cookie starvation (fixed, PR #430) + +`resolve_browser_cookie_header` (`rust/src/providers/ollama/cookies.rs`) used to call the shared `browser_cookies_for_domain` helper, which returns the **first installed browser** that has *any* cookies for `ollama.com` — even stale/irrelevant ones (analytics, consent) with no session cookie. On a machine with multiple Chromium browsers, an earlier-priority browser (Chrome, Edge) with only junk cookies silently starved out a later one (often Brave) that actually held the logged-in session, surfacing `No cookies available for web API` despite a valid session existing on disk. + +Fixed by walking every detected browser and using the first one whose cookies contain a recognized Ollama session cookie name, instead of stopping at the first non-empty result. See `first_recognized_cookie_header` and its regression test. + +## MiniMax: client-rendered console pages (fixed, PR #431) + +MiniMax redesigned `platform.minimax.io`: `/console/usage` and `/console/plan` are Next.js pages loaded via `next/dynamic(..., { ssr: false })`. The server never emits real quota numbers in the initial HTML — `__NEXT_DATA__.props.pageProps.userConfig` is always `null` — regardless of cookie validity. Every cookie/HTML-scraping path is therefore structurally unable to read usage on the current site; it silently fell back to `probe_cli()`'s hardcoded 0% "configured" stub. + +The coding-plan `remains` endpoint (`/v1/api/openplatform/coding_plan/remains`) that the HTML scraper already falls back to also accepts a plain `Authorization: Bearer ` with **no cookie at all**, returning the same `model_remains` JSON the existing parser (`coding_plan.rs`) already handles. `fetch_via_web` now tries this Bearer path first (key from Settings or `MINIMAX_API_KEY`) before the legacy `group_id`+`api_key` billing endpoint. MiniMax is now also registered in `get_api_key_providers()` so a plain API key can be set via Settings / `codexbar config set-api-key minimax` — previously only a paired `group_id`+`api_key` (env vars or a local `minimax`-CLI-style config file) was supported. + +## Codex: external OAuth staleness gate + +Codex reads the CLI-owned OAuth session from `~/.codex/auth.json` (`rust/src/providers/codex/api.rs`). This file is not managed by CodexBar — it is written and refreshed by the `codex` CLI itself. + +Upstream 0.50.1 #2944 added a fail-closed gate for this case: when `auth.json` has a `refresh_token` (i.e. it is a CLI-owned "external OAuth" credential, not an API key) and `last_refresh` is **older than `EXTERNAL_OAUTH_STALENESS_WINDOW`** (8 days, `codex/api.rs`), CodexBar refuses to use it and reports `AuthRequired` instead of silently trusting a possibly-stale/compromised token. `Settings.codex_external_oauth_sources_allowed` (default `false`) bypasses this gate when explicitly enabled. + +Symptom: Codex shows "Authentication required" / `codexbar diagnose -p codex` reports `error.category: "auth"` even though `auth.json` exists and looks otherwise valid — the fix is not a CodexBar config change, it's refreshing the CLI's own session: + +```powershell +codex doctor # does a real reachability handshake; updates last_refresh +# or +codex login status # lighter, but only checks local state — does not always refresh +``` + +Any Codex CLI use that reaches the network (interactive session, `codex exec`, `codex doctor`) updates `last_refresh` and clears the gate on CodexBar's next poll. No CodexBar restart is required. + ## Listing what is enabled ```powershell