From 47b7318e8f7b1e0ee4049deacef1734d559d97f8 Mon Sep 17 00:00:00 2001 From: Leonardo Araya Date: Sun, 6 Sep 2026 19:58:31 -0300 Subject: [PATCH 1/6] 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 6c5b6020f167313bf445dd844d14210a3a21fd56 Mon Sep 17 00:00:00 2001 From: Leonardo Araya Date: Sun, 6 Sep 2026 19:59:20 -0300 Subject: [PATCH 2/6] 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 d73c953bdc283ec49693c1de5aadfa41d12c0bd0 Mon Sep 17 00:00:00 2001 From: Leonardo Araya Date: Tue, 8 Sep 2026 07:47:11 +0700 Subject: [PATCH 3/6] fix(claude): prefer explicit manual cookie over OAuth account --- .../src-tauri/src/commands/providers.rs | 12 ++++++++ .../src-tauri/src/commands/tests.rs | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 958d8002d3..b187308fe0 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -47,6 +47,18 @@ pub(crate) fn build_fetch_context( (source_mode, None) } else { match cookie_source { + // #433: an explicitly selected, non-empty Claude manual cookie is + // authoritative. Do not let an active OAuth token account silently + // replace it; this keeps tray refresh behavior aligned with diagnose, + // whose Claude Auto path tries the supplied Web cookie before OAuth. + "manual" + if id == ProviderId::Claude + && stored_cookie + .as_deref() + .is_some_and(|cookie| !cookie.trim().is_empty()) => + { + (SourceMode::Web, stored_cookie.clone()) + } _ if active_token_env.is_some() => (SourceMode::OAuth, None), "off" if provider_uses_oauth_without_cookies(id, usage_source) => { (SourceMode::OAuth, None) diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index 8d58b7192e..c3f8aee376 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -576,6 +576,34 @@ fn fetch_context_token_account_uses_web_cookie_header() { ); } +#[test] +fn fetch_context_claude_manual_cookie_beats_active_oauth_token_account() { + let mut settings = Settings::default(); + settings.set_cookie_source(ProviderId::Claude, "manual"); + settings.set_usage_source(ProviderId::Claude, "auto"); + let mut cookies = ManualCookies::default(); + cookies.set("claude", "sessionKey=manual-session"); + let api_keys = ApiKeys::default(); + let mut token_accounts = HashMap::new(); + let mut data = ProviderAccountData::new(); + data.add_account(TokenAccount::new("Claude OAuth", "[REDACTED_SECRET]")); + token_accounts.insert(ProviderId::Claude, data); + + let ctx = super::build_fetch_context( + ProviderId::Claude, + &settings, + &cookies, + &api_keys, + &token_accounts, + ); + + assert_eq!(ctx.source_mode, SourceMode::Web); + assert_eq!( + ctx.manual_cookie_header.as_deref(), + Some("sessionKey=manual-session") + ); +} + #[test] fn fetch_context_claude_oauth_token_account_uses_oauth() { let settings = Settings::default(); From 34b65b903cc7f57c24d646ea292894486027d31c Mon Sep 17 00:00:00 2001 From: Leonardo Araya Date: Tue, 8 Sep 2026 07:47:12 +0700 Subject: [PATCH 4/6] fix(proxy): follow system proxy and stabilize settings input --- Cargo.lock | 50 +++++++++++++- .../settings/tabs/AdvancedTab.test.tsx | 40 +++++++++++ .../surfaces/settings/tabs/AdvancedTab.tsx | 68 +++++++++++++++---- rust/Cargo.toml | 2 +- rust/src/core/http_proxy.rs | 8 ++- 5 files changed, 148 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 79dee11241..c228c49a33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -782,6 +782,16 @@ dependencies = [ "url", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -805,7 +815,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -818,7 +828,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -2150,9 +2160,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -4684,6 +4696,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -4705,7 +4738,7 @@ checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ "bitflags 2.11.1", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch2", @@ -6276,6 +6309,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.2.0" diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx index e3ac4dd2a6..c1e49fc851 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx @@ -109,6 +109,46 @@ describe("AdvancedTab", () => { }); }); + + it("keeps proxy text edits local until blur or Enter", () => { + const set = vi.fn(); + render( + , + ); + + const url = screen.getByLabelText("NetworkProxyUrlLabel"); + fireEvent.change(url, { target: { value: " http://127.0.0.1:7890 " } }); + expect(url).toHaveValue(" http://127.0.0.1:7890 "); + expect(set).not.toHaveBeenCalled(); + fireEvent.blur(url); + expect(set).toHaveBeenCalledWith({ httpProxyUrl: "http://127.0.0.1:7890" }); + + set.mockClear(); + const user = screen.getByDisplayValue("old-user"); + fireEvent.focus(user); + fireEvent.change(user, { target: { value: " alice " } }); + expect(set).not.toHaveBeenCalled(); + fireEvent.blur(user); + expect(set).toHaveBeenCalledWith({ httpProxyUsername: "alice" }); + + set.mockClear(); + const password = screen.getByDisplayValue("old-pass"); + fireEvent.change(password, { target: { value: "secret with spaces" } }); + expect(set).not.toHaveBeenCalled(); + fireEvent.blur(password); + expect(set).toHaveBeenCalledWith({ httpProxyPassword: "secret with spaces" }); + }); + it("shows an error when copying diagnostics fails", async () => { tauriMocks.getSafeDiagnostics.mockRejectedValue(new Error("invoke failed")); render(); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx index bcae463719..fcf98d09c3 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx @@ -36,6 +36,15 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) { const [sshHostsDraft, setSshHostsDraft] = useState(() => (settings.agentSessionSshHosts ?? []).join(", "), ); + const [proxyUrlDraft, setProxyUrlDraft] = useState(() => + settings.httpProxyUrl ?? "", + ); + const [proxyUsernameDraft, setProxyUsernameDraft] = useState(() => + settings.httpProxyUsername ?? "", + ); + const [proxyPasswordDraft, setProxyPasswordDraft] = useState(() => + settings.httpProxyPassword ?? "", + ); const copyDiagnostics = useCallback(async () => { try { @@ -61,6 +70,34 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) { if (!saving) setSshHostsDraft((settings.agentSessionSshHosts ?? []).join(", ")); }, [saving, settings.agentSessionSshHosts]); + useEffect(() => { + if (!saving) setProxyUrlDraft(settings.httpProxyUrl ?? ""); + }, [saving, settings.httpProxyUrl]); + + useEffect(() => { + if (!saving) setProxyUsernameDraft(settings.httpProxyUsername ?? ""); + }, [saving, settings.httpProxyUsername]); + + useEffect(() => { + if (!saving) setProxyPasswordDraft(settings.httpProxyPassword ?? ""); + }, [saving, settings.httpProxyPassword]); + + const commitProxyUrl = useCallback(() => { + const next = proxyUrlDraft.trim(); + if (next !== (settings.httpProxyUrl ?? "")) set({ httpProxyUrl: next }); + }, [proxyUrlDraft, set, settings.httpProxyUrl]); + + const commitProxyUsername = useCallback(() => { + const next = proxyUsernameDraft.trim(); + if (next !== (settings.httpProxyUsername ?? "")) set({ httpProxyUsername: next }); + }, [proxyUsernameDraft, set, settings.httpProxyUsername]); + + const commitProxyPassword = useCallback(() => { + if (proxyPasswordDraft !== (settings.httpProxyPassword ?? "")) { + set({ httpProxyPassword: proxyPasswordDraft }); + } + }, [proxyPasswordDraft, set, settings.httpProxyPassword]); + const commitShortcut = useCallback( async (accelerator: string) => { setShortcutError(null); @@ -235,26 +272,29 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) { set({ httpProxyUrl: event.target.value })} - onBlur={(event) => - set({ httpProxyUrl: event.target.value.trim() }) - } + onChange={(event) => setProxyUrlDraft(event.target.value)} + onBlur={commitProxyUrl} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} /> - set({ httpProxyUsername: event.target.value }) - } + onChange={(event) => setProxyUsernameDraft(event.target.value)} + onBlur={commitProxyUsername} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} /> - set({ httpProxyPassword: event.target.value }) - } + onChange={(event) => setProxyPasswordDraft(event.target.value)} + onBlur={commitProxyPassword} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} /> diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 77b2552753..78fd9e1619 100755 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -20,7 +20,7 @@ default = [] tokio = { version = "1", features = ["full"] } # HTTP client -reqwest = { version = "0.12", features = ["json", "cookies", "rustls-tls", "stream", "http2"], default-features = false } +reqwest = { version = "0.12", features = ["json", "cookies", "rustls-tls", "stream", "http2", "system-proxy"], default-features = false } # Serialization serde = { version = "1", features = ["derive"] } diff --git a/rust/src/core/http_proxy.rs b/rust/src/core/http_proxy.rs index 096c8b09aa..445ba503de 100644 --- a/rust/src/core/http_proxy.rs +++ b/rust/src/core/http_proxy.rs @@ -43,8 +43,8 @@ impl HttpProxySettings { /// Resolve a reqwest [`Proxy`] from settings. /// -/// - Disabled or empty URL → `Ok(None)` (direct / default). -/// - Invalid URL when enabled → `Err(...)`. +/// - Disabled → `Ok(None)`, leaving reqwest's Windows/macOS system-proxy path intact. +/// - Empty or invalid URL when enabled → `Err(...)`. /// - Supports `http` and `https` proxy schemes only (MVP). pub fn resolve_proxy(settings: &HttpProxySettings) -> Result, String> { if !settings.enabled { @@ -90,10 +90,12 @@ pub fn apply_proxy_to_builder( ) -> ClientBuilder { match resolve_proxy(settings) { Ok(Some(proxy)) => builder.proxy(proxy), + // Do not call `no_proxy()`: with the reqwest `system-proxy` feature, + // an unchanged builder follows Windows/macOS system proxy settings. Ok(None) => builder, Err(err) => { tracing::warn!(error = %err, "http proxy config ignored; using direct connection"); - builder + builder.no_proxy() } } } From 435d639a7a5ef6642ef816978c0f34b448eb569e Mon Sep 17 00:00:00 2001 From: Leonardo Araya Date: Tue, 8 Sep 2026 07:50:12 +0700 Subject: [PATCH 5/6] refactor(minimax): isolate remains API client --- rust/src/providers/minimax/mod.rs | 84 ++-------------------- rust/src/providers/minimax/remains_api.rs | 86 +++++++++++++++++++++++ 2 files changed, 90 insertions(+), 80 deletions(-) create mode 100644 rust/src/providers/minimax/remains_api.rs diff --git a/rust/src/providers/minimax/mod.rs b/rust/src/providers/minimax/mod.rs index 3cf85d5267..42a9abb15d 100755 --- a/rust/src/providers/minimax/mod.rs +++ b/rust/src/providers/minimax/mod.rs @@ -6,6 +6,7 @@ mod coding_plan; mod coding_plan_html; mod local_storage; +mod remains_api; mod token_plan; // Re-exports for local storage import @@ -281,7 +282,7 @@ impl MiniMaxProvider { // 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 + && let Ok(result) = remains_api::fetch_remains_via_api_key(&key, region).await { return Ok(result); } @@ -297,86 +298,9 @@ 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. + /// A plain MiniMax API key from Settings or `MINIMAX_API_KEY`. 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()) + remains_api::read_plain_api_key(ctx) } /// Fetch from a specific region endpoint diff --git a/rust/src/providers/minimax/remains_api.rs b/rust/src/providers/minimax/remains_api.rs new file mode 100644 index 0000000000..9d8628af64 --- /dev/null +++ b/rust/src/providers/minimax/remains_api.rs @@ -0,0 +1,86 @@ +//! Bearer-authenticated MiniMax coding-plan quota fetch. +//! +//! Kept separate from the legacy billing client so the client-rendered console +//! workaround (#425) does not further grow the already-large provider module. + +use chrono::Utc; + +use crate::core::{FetchContext, ProviderError, ProviderFetchResult}; + +use super::{MiniMaxRegion, coding_plan, coding_plan_html}; + +/// A plain MiniMax API key from Settings (`ctx.api_key`) or the environment. +/// Unlike the legacy billing API, the coding-plan remains endpoint does not +/// require a paired `group_id`. +pub(super) 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 using a Bearer API key. Try the platform host, then +/// the www host, matching the existing cookie-based fallback chain. +pub(super) async fn fetch_remains_via_api_key( + 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 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()))) +} + +async fn fetch_remains_once_via_api_key( + 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 message = format!("MiniMax remains (api key) returned status {status}"); + if status == reqwest::StatusCode::NOT_FOUND + || status == reqwest::StatusCode::METHOD_NOT_ALLOWED + { + return Err(ProviderError::Parse(message)); + } + return Err(ProviderError::Other(message)); + } + + 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()) +} From 0d8c2f548c6cd46fbc9e67568e7fc3e8b1c5cf19 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:57:02 +0700 Subject: [PATCH 6/6] refactor(claude): move manual-cookie precedence to provider --- apps/desktop-tauri/src-tauri/src/commands/providers.rs | 3 ++- rust/src/core/provider.rs | 5 +++++ rust/src/providers/claude/mod.rs | 4 ++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index b187308fe0..3015b88520 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -15,6 +15,7 @@ pub(crate) fn build_fetch_context( api_keys: &ApiKeys, token_accounts: &HashMap, ) -> FetchContext { + let provider = instantiate_provider(id); let cookie_source = settings.cookie_source(id); let stored_cookie = cookies.get(id.cli_name()).map(|s| s.to_string()); let stored_api_key = api_keys.get(id.cli_name()).map(|s| s.to_string()); @@ -52,7 +53,7 @@ pub(crate) fn build_fetch_context( // replace it; this keeps tray refresh behavior aligned with diagnose, // whose Claude Auto path tries the supplied Web cookie before OAuth. "manual" - if id == ProviderId::Claude + if provider.manual_cookie_precedes_token_account() && stored_cookie .as_deref() .is_some_and(|cookie| !cookie.trim().is_empty()) => diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index cff8512331..a48e67669c 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -678,6 +678,11 @@ pub trait Provider: Send + Sync { None } + /// Whether an explicitly selected manual cookie outranks a token-account override. + fn manual_cookie_precedes_token_account(&self) -> bool { + false + } + /// Presentation-safe availability state for a refresh error. The default /// maps `ProviderError` variants, treating `NotInstalled` as a missing /// credential (most providers raise it for a missing API key or auth diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index 0a01617703..38b437d14f 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -385,6 +385,10 @@ async fn run_claude_pty_probe( #[async_trait] impl Provider for ClaudeProvider { + fn manual_cookie_precedes_token_account(&self) -> bool { + true + } + fn id(&self) -> ProviderId { ProviderId::Claude }