From ec9acb1da8e64e4755789387f6b2e3240a0f31b0 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Thu, 19 Feb 2026 18:48:48 +0800 Subject: [PATCH 1/4] feat: wire up HTTP definition headers orthogonally from auth, remove uninstall confirm - Headers from server definitions are now always applied as default_headers on the reqwest::Client regardless of auth strategy (OAuth, PAT, or no-auth) - If headers contain an Authorization header, OAuth is skipped entirely - If headers have no Authorization, they ride alongside OAuth tokens - Add build_default_headers() and build_http_client() helpers - Remove standalone connect_with_headers() in favor of orthogonal design - Add 19 unit tests for HttpTransport (requires_oauth, header building, connect routing logic, transport_type, description) - Remove broken confirm() dialog from server uninstall action Signed-off-by: Mohammod Al Amin Ashik --- .../src/features/servers/ServersPage.tsx | 10 +- .../mcpmux-gateway/src/pool/transport/http.rs | 582 +++++++++++++++++- 2 files changed, 559 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src/features/servers/ServersPage.tsx b/apps/desktop/src/features/servers/ServersPage.tsx index 07ad188e..599e2ff0 100644 --- a/apps/desktop/src/features/servers/ServersPage.tsx +++ b/apps/desktop/src/features/servers/ServersPage.tsx @@ -709,15 +709,9 @@ export function ServersPage() { }; const handleUninstall = async (server: ServerViewModel) => { - // Import source-aware helpers - const { getUninstallConfirmMessage, getUninstallLabel } = await import('@/components/SourceBadge'); - const confirmMsg = getUninstallConfirmMessage(server.name, server.installation_source); + const { getUninstallLabel } = await import('@/components/SourceBadge'); const actionLabel = getUninstallLabel(server.installation_source); - - if (!confirm(confirmMsg)) { - return; - } - + setActionLoading(`uninstall-${server.id}`); try { const { uninstallServer } = await import('@/lib/api/registry'); diff --git a/crates/mcpmux-gateway/src/pool/transport/http.rs b/crates/mcpmux-gateway/src/pool/transport/http.rs index d1e99ba8..250f0046 100644 --- a/crates/mcpmux-gateway/src/pool/transport/http.rs +++ b/crates/mcpmux-gateway/src/pool/transport/http.rs @@ -29,7 +29,6 @@ use crate::pool::credential_store::DatabaseCredentialStore; /// automatically refreshed by RMCP on every request when needed. pub struct HttpTransport { url: String, - #[allow(dead_code)] // Reserved for future custom headers headers: HashMap, space_id: Uuid, server_id: String, @@ -99,7 +98,7 @@ impl HttpTransport { oauth_indicators.iter().any(|s| error_lower.contains(s)) } - /// Connect with OAuth using DatabaseCredentialStore. + /// Connect with OAuth using DatabaseCredentialStore (with definition headers if any). /// /// RMCP's AuthClient will automatically: /// - Load tokens from the credential store @@ -107,11 +106,18 @@ impl HttpTransport { /// - Save refreshed tokens back to the store /// - Add auth header to every request /// + /// Definition headers are applied as default_headers on the underlying reqwest::Client, + /// so they're sent alongside OAuth tokens on every request. + /// /// If RMCP's metadata discovery fails (non-spec-compliant servers), we use /// stored metadata from the initial OAuth flow. - async fn connect_with_auth(&self) -> TransportConnectResult { + async fn connect_with_auth( + &self, + header_map: reqwest::header::HeaderMap, + ) -> TransportConnectResult { debug!( server_id = %self.server_id, + header_count = header_map.len(), "Connecting with OAuth via CredentialStore" ); @@ -119,8 +125,9 @@ impl HttpTransport { LogLevel::Info, LogSource::HttpRequest, format!( - "Connecting to {} with OAuth (auto-refresh enabled)", - self.url + "Connecting to {} with OAuth (auto-refresh enabled, {} custom header(s))", + self.url, + header_map.len() ), ) .await; @@ -234,12 +241,17 @@ impl HttpTransport { ) .await; - return self.connect_with_manual_token().await; + return self.connect_with_manual_token(header_map).await; } } - // Create AuthClient - this wraps reqwest::Client with automatic token injection & refresh - let auth_client = AuthClient::new(reqwest::Client::default(), auth_manager); + // Create AuthClient - wraps reqwest::Client with automatic token injection & refresh. + // Definition headers are baked into the client so they're sent on every request. + let base_client = match self.build_http_client(header_map) { + Ok(c) => c, + Err(err) => return TransportConnectResult::Failed(err), + }; + let auth_client = AuthClient::new(base_client, auth_manager); let transport_config = StreamableHttpClientTransportConfig::with_uri(self.url.as_str()); let transport = StreamableHttpClientTransport::with_client(auth_client, transport_config); @@ -303,8 +315,12 @@ impl HttpTransport { /// /// Some servers (like Cloudflare) don't serve OAuth metadata at the standard location /// that RMCP expects. In this case, we manually inject the stored token into requests. + /// Definition headers are merged in (token Authorization header takes precedence). /// NOTE: Auto-refresh won't work in this mode - tokens must be refreshed manually. - async fn connect_with_manual_token(&self) -> TransportConnectResult { + async fn connect_with_manual_token( + &self, + mut header_map: reqwest::header::HeaderMap, + ) -> TransportConnectResult { debug!( server_id = %self.server_id, "Connecting with manual token injection (RMCP metadata failed)" @@ -341,12 +357,11 @@ impl HttpTransport { ) .await; - // Build HTTP client with Authorization header - let mut headers = reqwest::header::HeaderMap::new(); + // Add Authorization header to the definition headers (overrides if already present) let auth_value = format!("Bearer {}", access_token); match reqwest::header::HeaderValue::from_str(&auth_value) { Ok(val) => { - headers.insert(reqwest::header::AUTHORIZATION, val); + header_map.insert(reqwest::header::AUTHORIZATION, val); } Err(e) => { let err = format!("Invalid token format: {}", e); @@ -355,13 +370,9 @@ impl HttpTransport { } } - let client = match reqwest::Client::builder().default_headers(headers).build() { + let client = match self.build_http_client(header_map) { Ok(c) => c, - Err(e) => { - let err = format!("Failed to build HTTP client: {}", e); - error!(server_id = %self.server_id, "{}", err); - return TransportConnectResult::Failed(err); - } + Err(err) => return TransportConnectResult::Failed(err), }; let transport_config = StreamableHttpClientTransportConfig::with_uri(self.url.as_str()); @@ -423,21 +434,74 @@ impl HttpTransport { } } - /// Try connecting without authentication - async fn connect_without_auth(&self) -> TransportConnectResult { + /// Build a reqwest HeaderMap from definition-provided headers. + /// + /// These headers (resolved from `${input:ID}` placeholders) are always applied + /// to the HTTP client regardless of auth strategy. Returns an empty map if no + /// definition headers are configured. + fn build_default_headers(&self) -> Result { + let mut header_map = reqwest::header::HeaderMap::new(); + for (key, value) in &self.headers { + let header_name = + reqwest::header::HeaderName::from_bytes(key.as_bytes()).map_err(|e| { + let err = format!("Invalid header name '{}': {}", key, e); + error!(server_id = %self.server_id, "{}", err); + err + })?; + let header_value = reqwest::header::HeaderValue::from_str(value).map_err(|e| { + let err = format!("Invalid header value for '{}': {}", key, e); + error!(server_id = %self.server_id, "{}", err); + err + })?; + header_map.insert(header_name, header_value); + } + Ok(header_map) + } + + /// Build a reqwest::Client with definition headers as default_headers. + fn build_http_client( + &self, + header_map: reqwest::header::HeaderMap, + ) -> Result { + reqwest::Client::builder() + .default_headers(header_map) + .build() + .map_err(|e| { + let err = format!("Failed to build HTTP client: {}", e); + error!(server_id = %self.server_id, "{}", err); + err + }) + } + + /// Try connecting without authentication (but with definition headers if any) + async fn connect_without_auth( + &self, + header_map: reqwest::header::HeaderMap, + ) -> TransportConnectResult { debug!( server_id = %self.server_id, + header_count = header_map.len(), "Trying connection without auth" ); self.log( LogLevel::Info, LogSource::HttpRequest, - format!("Connecting to {} without auth", self.url), + format!( + "Connecting to {} without auth ({} custom header(s))", + self.url, + header_map.len() + ), ) .await; - let transport = StreamableHttpClientTransport::from_uri(self.url.as_str()); + let client = match self.build_http_client(header_map) { + Ok(c) => c, + Err(err) => return TransportConnectResult::Failed(err), + }; + + let transport_config = StreamableHttpClientTransportConfig::with_uri(self.url.as_str()); + let transport = StreamableHttpClientTransport::with_client(client, transport_config); let client_handler = create_client_handler( &self.server_id, self.space_id, @@ -519,7 +583,33 @@ impl Transport for HttpTransport { return TransportConnectResult::Failed(err); } - // Check if we have stored credentials for this server + // Build definition headers (always applied regardless of auth strategy) + let header_map = match self.build_default_headers() { + Ok(h) => h, + Err(err) => return TransportConnectResult::Failed(err), + }; + + if !header_map.is_empty() { + info!( + server_id = %self.server_id, + header_count = header_map.len(), + "Applying definition-provided headers to connection" + ); + } + + // Check if definition headers already include an Authorization header. + // If so, skip OAuth — the user explicitly provided auth via the definition (e.g., PAT). + let has_explicit_auth = header_map.contains_key(reqwest::header::AUTHORIZATION); + + if has_explicit_auth { + info!( + server_id = %self.server_id, + "Definition includes Authorization header, skipping OAuth" + ); + return self.connect_without_auth(header_map).await; + } + + // No explicit auth in headers — check for stored OAuth credentials let has_credentials = self .credential_repo .get( @@ -537,13 +627,13 @@ impl Transport for HttpTransport { server_id = %self.server_id, "Found stored credentials, connecting with OAuth (auto-refresh enabled)" ); - self.connect_with_auth().await + self.connect_with_auth(header_map).await } else { debug!( server_id = %self.server_id, "No stored credentials, trying without auth" ); - self.connect_without_auth().await + self.connect_without_auth(header_map).await } } @@ -555,3 +645,445 @@ impl Transport for HttpTransport { format!("http:{}", self.url) } } + +#[cfg(test)] +mod tests { + use super::*; + use mcpmux_core::{Credential, CredentialType, OutboundOAuthRegistration}; + + // ── Mock repos (minimal, sufficient for HttpTransport unit tests) ── + + #[derive(Clone)] + struct MockCredentialRepo { + credentials: Arc>>, + } + + impl MockCredentialRepo { + fn new() -> Self { + Self { + credentials: Arc::new(tokio::sync::RwLock::new(Vec::new())), + } + } + + fn with_credential(cred: Credential) -> Self { + Self { + credentials: Arc::new(tokio::sync::RwLock::new(vec![cred])), + } + } + } + + #[async_trait] + impl CredentialRepository for MockCredentialRepo { + async fn get( + &self, + space_id: &Uuid, + server_id: &str, + credential_type: &CredentialType, + ) -> anyhow::Result> { + let creds = self.credentials.read().await; + Ok(creds + .iter() + .find(|c| { + c.space_id == *space_id + && c.server_id == server_id + && c.credential_type == *credential_type + }) + .cloned()) + } + + async fn get_all( + &self, + space_id: &Uuid, + server_id: &str, + ) -> anyhow::Result> { + let creds = self.credentials.read().await; + Ok(creds + .iter() + .filter(|c| c.space_id == *space_id && c.server_id == server_id) + .cloned() + .collect()) + } + + async fn save(&self, credential: &Credential) -> anyhow::Result<()> { + let mut creds = self.credentials.write().await; + creds.retain(|c| { + !(c.space_id == credential.space_id + && c.server_id == credential.server_id + && c.credential_type == credential.credential_type) + }); + creds.push(credential.clone()); + Ok(()) + } + + async fn delete( + &self, + space_id: &Uuid, + server_id: &str, + credential_type: &CredentialType, + ) -> anyhow::Result<()> { + let mut creds = self.credentials.write().await; + creds.retain(|c| { + !(c.space_id == *space_id + && c.server_id == server_id + && c.credential_type == *credential_type) + }); + Ok(()) + } + + async fn delete_all(&self, space_id: &Uuid, server_id: &str) -> anyhow::Result<()> { + let mut creds = self.credentials.write().await; + creds.retain(|c| !(c.space_id == *space_id && c.server_id == server_id)); + Ok(()) + } + + async fn clear_tokens(&self, space_id: &Uuid, server_id: &str) -> anyhow::Result { + let mut creds = self.credentials.write().await; + let before = creds.len(); + creds.retain(|c| { + !(c.space_id == *space_id + && c.server_id == server_id + && c.credential_type.is_oauth()) + }); + Ok(creds.len() < before) + } + + async fn list_for_space(&self, space_id: &Uuid) -> anyhow::Result> { + let creds = self.credentials.read().await; + Ok(creds + .iter() + .filter(|c| c.space_id == *space_id) + .cloned() + .collect()) + } + } + + #[derive(Clone)] + struct MockOAuthRepo; + + #[async_trait] + impl OutboundOAuthRepository for MockOAuthRepo { + async fn get( + &self, + _space_id: &Uuid, + _server_id: &str, + ) -> anyhow::Result> { + Ok(None) + } + + async fn save(&self, _registration: &OutboundOAuthRegistration) -> anyhow::Result<()> { + Ok(()) + } + + async fn delete(&self, _space_id: &Uuid, _server_id: &str) -> anyhow::Result<()> { + Ok(()) + } + + async fn list_for_space( + &self, + _space_id: &Uuid, + ) -> anyhow::Result> { + Ok(vec![]) + } + } + + /// Helper to create an HttpTransport with given headers and credential repo. + fn make_transport( + headers: HashMap, + credential_repo: Arc, + ) -> HttpTransport { + HttpTransport::new( + "https://example.com/mcp".to_string(), + headers, + Uuid::new_v4(), + "test-server".to_string(), + credential_repo, + Arc::new(MockOAuthRepo), + None, + Duration::from_secs(10), + None, + ) + } + + fn make_transport_with_space( + headers: HashMap, + credential_repo: Arc, + space_id: Uuid, + server_id: &str, + ) -> HttpTransport { + HttpTransport::new( + "https://example.com/mcp".to_string(), + headers, + space_id, + server_id.to_string(), + credential_repo, + Arc::new(MockOAuthRepo), + None, + Duration::from_secs(10), + None, + ) + } + + // ── requires_oauth tests ── + + #[test] + fn test_requires_oauth_401() { + assert!(HttpTransport::requires_oauth("HTTP 401 Unauthorized")); + } + + #[test] + fn test_requires_oauth_bearer() { + assert!(HttpTransport::requires_oauth("Missing Bearer token")); + } + + #[test] + fn test_requires_oauth_www_authenticate() { + assert!(HttpTransport::requires_oauth("WWW-Authenticate: Bearer")); + } + + #[test] + fn test_requires_oauth_channel_closed() { + assert!(HttpTransport::requires_oauth("transport channel closed")); + } + + #[test] + fn test_requires_oauth_false_for_unrelated() { + assert!(!HttpTransport::requires_oauth("connection refused")); + assert!(!HttpTransport::requires_oauth("DNS lookup failed")); + assert!(!HttpTransport::requires_oauth("timeout")); + } + + // ── build_default_headers tests ── + + #[test] + fn test_build_default_headers_empty() { + let transport = make_transport(HashMap::new(), Arc::new(MockCredentialRepo::new())); + let headers = transport.build_default_headers().unwrap(); + assert!(headers.is_empty()); + } + + #[test] + fn test_build_default_headers_single() { + let mut h = HashMap::new(); + h.insert("Authorization".to_string(), "Bearer token123".to_string()); + let transport = make_transport(h, Arc::new(MockCredentialRepo::new())); + let headers = transport.build_default_headers().unwrap(); + + assert_eq!(headers.len(), 1); + assert_eq!( + headers.get(reqwest::header::AUTHORIZATION).unwrap(), + "Bearer token123" + ); + } + + #[test] + fn test_build_default_headers_multiple() { + let mut h = HashMap::new(); + h.insert("Authorization".to_string(), "Bearer pat_xxx".to_string()); + h.insert("X-Custom-Header".to_string(), "custom-value".to_string()); + let transport = make_transport(h, Arc::new(MockCredentialRepo::new())); + let headers = transport.build_default_headers().unwrap(); + + assert_eq!(headers.len(), 2); + assert_eq!( + headers.get(reqwest::header::AUTHORIZATION).unwrap(), + "Bearer pat_xxx" + ); + assert_eq!(headers.get("x-custom-header").unwrap(), "custom-value"); + } + + #[test] + fn test_build_default_headers_invalid_name() { + let mut h = HashMap::new(); + h.insert("Invalid Header\n".to_string(), "value".to_string()); + let transport = make_transport(h, Arc::new(MockCredentialRepo::new())); + let result = transport.build_default_headers(); + assert!(result.is_err()); + } + + #[test] + fn test_build_default_headers_invalid_value() { + let mut h = HashMap::new(); + h.insert("X-Header".to_string(), "bad\nvalue".to_string()); + let transport = make_transport(h, Arc::new(MockCredentialRepo::new())); + let result = transport.build_default_headers(); + assert!(result.is_err()); + } + + // ── build_http_client tests ── + + #[test] + fn test_build_http_client_empty_headers() { + let transport = make_transport(HashMap::new(), Arc::new(MockCredentialRepo::new())); + let client = transport.build_http_client(reqwest::header::HeaderMap::new()); + assert!(client.is_ok()); + } + + #[test] + fn test_build_http_client_with_headers() { + let transport = make_transport(HashMap::new(), Arc::new(MockCredentialRepo::new())); + let mut header_map = reqwest::header::HeaderMap::new(); + header_map.insert( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_static("Bearer token"), + ); + let client = transport.build_http_client(header_map); + assert!(client.is_ok()); + } + + // ── connect() routing logic tests ── + + #[tokio::test] + async fn test_connect_invalid_url_fails() { + let transport = HttpTransport::new( + "not a valid url".to_string(), + HashMap::new(), + Uuid::new_v4(), + "test-server".to_string(), + Arc::new(MockCredentialRepo::new()), + Arc::new(MockOAuthRepo), + None, + Duration::from_secs(5), + None, + ); + + let result = transport.connect().await; + match result { + TransportConnectResult::Failed(msg) => { + assert!(msg.contains("Invalid URL"), "Got: {}", msg); + } + _ => panic!("Expected Failed for invalid URL"), + } + } + + #[tokio::test] + async fn test_connect_with_explicit_auth_header_skips_oauth_check() { + // When headers include Authorization, connect should NOT check credential_repo + // for OAuth tokens — it should go straight to connect_without_auth with headers. + // We verify this by giving it an unreachable server URL (connection will fail) + // and checking that the error is a connection failure, NOT OAuthRequired. + let mut h = HashMap::new(); + h.insert( + "Authorization".to_string(), + "Bearer ghp_testtoken123".to_string(), + ); + + let transport = HttpTransport::new( + "https://127.0.0.1:1/mcp".to_string(), // unreachable + h, + Uuid::new_v4(), + "test-server".to_string(), + Arc::new(MockCredentialRepo::new()), + Arc::new(MockOAuthRepo), + None, + Duration::from_secs(2), + None, + ); + + let result = transport.connect().await; + // Should be Failed (connection error) or timeout — NOT OAuthRequired + match result { + TransportConnectResult::Failed(_) => {} // expected + TransportConnectResult::OAuthRequired { .. } => { + panic!("Should not trigger OAuth when Authorization header is present") + } + _ => {} // Connected would be surprising but not wrong + } + } + + #[tokio::test] + async fn test_connect_no_headers_no_credentials_tries_no_auth() { + // No headers, no stored credentials → connect_without_auth + // Will fail to connect to unreachable server + let transport = HttpTransport::new( + "https://127.0.0.1:1/mcp".to_string(), + HashMap::new(), + Uuid::new_v4(), + "test-server".to_string(), + Arc::new(MockCredentialRepo::new()), + Arc::new(MockOAuthRepo), + None, + Duration::from_secs(2), + None, + ); + + let result = transport.connect().await; + // Should be Failed (connection error/timeout) or OAuthRequired (if 401 detected) + match result { + TransportConnectResult::Failed(_) | TransportConnectResult::OAuthRequired { .. } => {} + TransportConnectResult::Connected(_) => { + panic!("Should not connect to unreachable server") + } + } + } + + #[tokio::test] + async fn test_connect_with_stored_credentials_routes_to_oauth() { + // When stored credentials exist and no explicit Authorization header, + // connect should route to connect_with_auth (which will fail on unreachable server, + // but we verify it doesn't go to connect_without_auth by observing the error path). + let space_id = Uuid::new_v4(); + let server_id = "test-server"; + + let cred = Credential::access_token(space_id, server_id, "stored_token", None); + let cred_repo = Arc::new(MockCredentialRepo::with_credential(cred)); + + let transport = make_transport_with_space(HashMap::new(), cred_repo, space_id, server_id); + + let result = transport.connect().await; + // connect_with_auth will fail (can't reach server / AuthorizationManager::new fails) + // but it should NOT be OAuthRequired from the no-auth path's 401 detection + match result { + TransportConnectResult::Failed(_) | TransportConnectResult::OAuthRequired { .. } => {} + TransportConnectResult::Connected(_) => { + panic!("Should not connect to example.com MCP endpoint") + } + } + } + + #[tokio::test] + async fn test_connect_custom_headers_always_applied_with_credentials() { + // Even when we have stored OAuth credentials, custom (non-auth) headers + // from the definition should be applied. We can verify this indirectly: + // the transport should route to connect_with_auth (not connect_without_auth) + // because there's no Authorization in the custom headers. + let space_id = Uuid::new_v4(); + let server_id = "test-server"; + + let cred = Credential::access_token(space_id, server_id, "stored_token", None); + let cred_repo = Arc::new(MockCredentialRepo::with_credential(cred)); + + let mut h = HashMap::new(); + h.insert("X-MCP-Toolsets".to_string(), "tools-only".to_string()); + + let transport = make_transport_with_space(h, cred_repo, space_id, server_id); + + // Verify headers are built correctly (non-auth header present, no Authorization) + let headers = transport.build_default_headers().unwrap(); + assert_eq!(headers.len(), 1); + assert!(!headers.contains_key(reqwest::header::AUTHORIZATION)); + assert_eq!(headers.get("x-mcp-toolsets").unwrap(), "tools-only"); + + // connect() should route to connect_with_auth since no explicit Authorization header + let result = transport.connect().await; + match result { + TransportConnectResult::Failed(_) | TransportConnectResult::OAuthRequired { .. } => {} + TransportConnectResult::Connected(_) => { + panic!("Should not connect to example.com MCP endpoint") + } + } + } + + // ── transport_type / description tests ── + + #[test] + fn test_transport_type() { + let transport = make_transport(HashMap::new(), Arc::new(MockCredentialRepo::new())); + assert!(matches!(transport.transport_type(), TransportType::Http)); + } + + #[test] + fn test_description() { + let transport = make_transport(HashMap::new(), Arc::new(MockCredentialRepo::new())); + assert_eq!(transport.description(), "http:https://example.com/mcp"); + } +} From d7b251962404825d2ea8ef76b9087259b57af07c Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Fri, 20 Feb 2026 16:16:34 +0800 Subject: [PATCH 2/4] fix: detect OAuth requirement from unexpected content-type responses Some MCP servers (e.g., Atlassian) return text/plain error responses instead of HTTP 401 when unauthenticated. Add "unexpected content type" to OAuth detection heuristics so these are correctly routed to the OAuth flow rather than surfaced as generic connection errors. Signed-off-by: Mohammod Al Amin Ashik --- crates/mcpmux-gateway/src/pool/transport/http.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/mcpmux-gateway/src/pool/transport/http.rs b/crates/mcpmux-gateway/src/pool/transport/http.rs index 250f0046..af157740 100644 --- a/crates/mcpmux-gateway/src/pool/transport/http.rs +++ b/crates/mcpmux-gateway/src/pool/transport/http.rs @@ -94,6 +94,7 @@ impl HttpTransport { "access token", "missing or invalid", "bearer", + "unexpected content type", ]; oauth_indicators.iter().any(|s| error_lower.contains(s)) } @@ -845,6 +846,13 @@ mod tests { assert!(HttpTransport::requires_oauth("transport channel closed")); } + #[test] + fn test_requires_oauth_unexpected_content_type() { + assert!(HttpTransport::requires_oauth( + "Unexpected content type: Some(\"text/plain;charset=UTF-8\")" + )); + } + #[test] fn test_requires_oauth_false_for_unrelated() { assert!(!HttpTransport::requires_oauth("connection refused")); From 74e31a9a68f945f06b40009bdd36f50bec1c0c7f Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Fri, 20 Feb 2026 17:38:10 +0800 Subject: [PATCH 3/4] ci: install matching EdgeDriver for Windows E2E The windows-latest runner image can have a msedgedriver version that doesn't match the installed Edge browser. Dynamically download the EdgeDriver matching the installed Edge version to prevent "session not created" failures. Signed-off-by: Mohammod Al Amin Ashik --- .github/workflows/e2e-desktop.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/e2e-desktop.yml b/.github/workflows/e2e-desktop.yml index 50d1f396..085d4f07 100644 --- a/.github/workflows/e2e-desktop.yml +++ b/.github/workflows/e2e-desktop.yml @@ -127,6 +127,24 @@ jobs: xvfb-run --auto-servernum pnpm test:e2e ' + - name: Install matching EdgeDriver (Windows) + if: matrix.os == 'windows-latest' + shell: pwsh + run: | + $edgePath = "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" + if (-not (Test-Path $edgePath)) { $edgePath = "C:\Program Files\Microsoft\Edge\Application\msedge.exe" } + $edgeVersion = (Get-Item $edgePath).VersionInfo.ProductVersion + Write-Host "Edge version: $edgeVersion" + $driverUrl = "https://msedgewebdriverstorage.blob.core.windows.net/edgewebdriver/$edgeVersion/edgedriver_win64.zip" + $zipPath = "$env:TEMP\edgedriver.zip" + $extractDir = "$env:TEMP\edgedriver" + Invoke-WebRequest -Uri $driverUrl -OutFile $zipPath + Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force + $driverDir = "$extractDir\edgedriver_win64" + if (-not (Test-Path "$driverDir\msedgedriver.exe")) { $driverDir = $extractDir } + Write-Host "EdgeDriver path: $driverDir" + echo "$driverDir" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH + - name: Run desktop E2E tests (Windows) if: matrix.os == 'windows-latest' run: pnpm test:e2e From ad39944a2624d92c8a8a757d99601b043a20d040 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Fri, 20 Feb 2026 18:02:06 +0800 Subject: [PATCH 4/4] ci: match EdgeDriver to WebView2 runtime, not Edge browser Tauri apps use the WebView2 runtime, which can be a different version than the Edge browser on the same machine. Read the WebView2 runtime version from the registry and download the matching EdgeDriver. Signed-off-by: Mohammod Al Amin Ashik --- .github/workflows/e2e-desktop.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/e2e-desktop.yml b/.github/workflows/e2e-desktop.yml index 085d4f07..5d580236 100644 --- a/.github/workflows/e2e-desktop.yml +++ b/.github/workflows/e2e-desktop.yml @@ -131,11 +131,13 @@ jobs: if: matrix.os == 'windows-latest' shell: pwsh run: | - $edgePath = "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" - if (-not (Test-Path $edgePath)) { $edgePath = "C:\Program Files\Microsoft\Edge\Application\msedge.exe" } - $edgeVersion = (Get-Item $edgePath).VersionInfo.ProductVersion - Write-Host "Edge version: $edgeVersion" - $driverUrl = "https://msedgewebdriverstorage.blob.core.windows.net/edgewebdriver/$edgeVersion/edgedriver_win64.zip" + # Tauri uses the WebView2 runtime, not Edge directly. + # msedgedriver must match the WebView2 runtime version, not the Edge browser version. + $wv2Key = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}' + if (-not (Test-Path $wv2Key)) { $wv2Key = 'HKLM:\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}' } + $wv2Version = (Get-ItemProperty $wv2Key).pv + Write-Host "WebView2 runtime version: $wv2Version" + $driverUrl = "https://msedgewebdriverstorage.blob.core.windows.net/edgewebdriver/$wv2Version/edgedriver_win64.zip" $zipPath = "$env:TEMP\edgedriver.zip" $extractDir = "$env:TEMP\edgedriver" Invoke-WebRequest -Uri $driverUrl -OutFile $zipPath