diff --git a/changelog.d/fixes/9407-gemini-web-false-positive.md b/changelog.d/fixes/9407-gemini-web-false-positive.md new file mode 100644 index 0000000000..d76caa14c5 --- /dev/null +++ b/changelog.d/fixes/9407-gemini-web-false-positive.md @@ -0,0 +1 @@ +- fix(providers): detect expired gemini-web sessions via ServiceLogin redirect and add testConnection override (#9407) diff --git a/open-sse/executors/gemini-web.ts b/open-sse/executors/gemini-web.ts index 4befb78c6e..975d13093f 100644 --- a/open-sse/executors/gemini-web.ts +++ b/open-sse/executors/gemini-web.ts @@ -348,6 +348,30 @@ export class GeminiWebExecutor extends BaseExecutor { super("gemini-web", { id: "gemini-web", baseUrl: GEMINI_URL }); } + /** + * testConnection — validates the cookie format without making a network call + * or launching Playwright. Returns true when the cookie is non-empty and + * contains at least one name=value pair with a non-empty value. This is a + * lightweight pre-check before the browser automation path; full session + * validation is done by validateGeminiWebProvider in the connection test + * flow (#9407). + */ + async testConnection( + credentials: Record, + _signal?: AbortSignal + ): Promise { + try { + const cookie = resolveGeminiWebCookie( + credentials as unknown as ExecuteInput["credentials"] + ); + if (!cookie) return false; + const pairs = parseCookies(cookie); + return pairs.some((p) => p.value.length > 0); + } catch { + return false; + } + } + /** * Read the live Playwright cookie jar back after a successful run and, if * Google rotated any of the __Secure-1PSID* cookies, forward the merged @@ -593,6 +617,30 @@ export class GeminiWebExecutor extends BaseExecutor { transformedBody: body, }; } + // #9407: Playwright selector/click timeout errors are terminal — they indicate + // the page DOM does not match expectations (e.g. Gemini changed their UI or + // the session is so expired it lands on a different page). Return 400 so the + // account-fallback system does NOT retry this request as a transient 5xx. + if ( + error instanceof Error && + (error.name === "TimeoutError" || + rawMessage.includes("waitForSelector") || + rawMessage.includes("Timeout") || + rawMessage.includes("actionability") || + rawMessage.includes("interception")) + ) { + return { + response: new Response( + JSON.stringify({ + error: sanitizeErrorMessage(rawMessage), + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ), + url: GEMINI_URL, + headers: {}, + transformedBody: body, + }; + } return { response: new Response( JSON.stringify({ diff --git a/open-sse/services/comboConfig.ts b/open-sse/services/comboConfig.ts index 16cf3a2262..12dc440466 100644 --- a/open-sse/services/comboConfig.ts +++ b/open-sse/services/comboConfig.ts @@ -99,7 +99,7 @@ const DEFAULT_COMBO_CONFIG = { retryDelayMs: 2000, fallbackDelayMs: 0, concurrencyPerModel: 3, // max simultaneous requests per model (round-robin) - queueTimeoutMs: 30000, // max wait time in semaphore queue (round-robin) + queueTimeoutMs: 120000, // max wait time in semaphore queue (round-robin); raised from 30s for browser-automation providers like gemini-web (#9407) queueDepth: DEFAULT_COMBO_QUEUE_DEPTH, // pre-cascade semaphore queue depth (round-robin, #3872) handoffThreshold: 0.85, handoffModel: "", diff --git a/src/lib/providers/validation/webProvidersB.ts b/src/lib/providers/validation/webProvidersB.ts index 2f5ac3a4ef..0c11661284 100644 --- a/src/lib/providers/validation/webProvidersB.ts +++ b/src/lib/providers/validation/webProvidersB.ts @@ -248,11 +248,34 @@ export async function validateGeminiWebProvider({ apiKey, providerSpecificData = // session looks like here, so treat it as success. A redirect to a private/internal // host is a genuine SSRF signal and must stay invalid — isSecurityBlockError() // already makes that distinction. + // + // #9407: EXPIRED gemini sessions redirect to accounts.google.com/ServiceLogin, + // which is a PUBLIC redirect (not SSRF) but represents a dead session. Inspect + // the redirect target to distinguish between: + // - accounts.google.com/ServiceLogin — expired session → valid:false + // - other accounts.google.com paths — ambiguous, warn but treat as valid + // - non-Google redirects (e.g. gemini.google.com redirect loop) — valid if ( error instanceof SafeOutboundFetchError && error.code === "REDIRECT_BLOCKED" && !isSecurityBlockError(error) ) { + const location = error.location ?? ""; + if (/accounts\.google\.com\/.*ServiceLogin/i.test(location)) { + return { + valid: false, + error: + "Session expired — re-paste __Secure-1PSID from gemini.google.com DevTools → Cookies", + }; + } + if (/accounts\.google\.com/i.test(location)) { + return { + valid: true, + error: null, + warning: + "Cookie accepted. Full verification requires browser test on first chat.", + }; + } return { valid: true, error: null }; } return toValidationErrorResult(error); diff --git a/tests/unit/issue-9407-gemini-web-validation-false-positive.test.ts b/tests/unit/issue-9407-gemini-web-validation-false-positive.test.ts new file mode 100644 index 0000000000..3ef47b16b5 --- /dev/null +++ b/tests/unit/issue-9407-gemini-web-validation-false-positive.test.ts @@ -0,0 +1,130 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +/** + * #9407 — gemini-web connection test false-positives + * + * Validates: + * 1. validateGeminiWebProvider detects ServiceLogin redirect (expired session) + * 2. GeminiWebExecutor has testConnection() for cookie format validation + * 3. Queue timeout is reasonable for browser automation lifecycle + */ + +describe("validateGeminiWebProvider — ServiceLogin detection (#9407)", () => { + it("source references ServiceLogin and returns valid:false for expired sessions", async () => { + const { validateGeminiWebProvider } = await import( + "@/lib/providers/validation/webProvidersB" + ); + const fnStr = validateGeminiWebProvider.toString(); + // Regex literal in source: /accounts\.google\.com\/ + assert.ok( + fnStr.includes("ServiceLogin"), + "Must detect ServiceLogin specifically" + ); + assert.ok( + fnStr.includes('valid:false'), + "ServiceLogin redirect must be classified as invalid" + ); + assert.ok( + fnStr.includes('valid:true') && fnStr.includes('warning'), + "Ambiguous redirect must have valid:true with warning" + ); + }); + + it("returns valid:false for missing cookie (early return, no network call)", async () => { + const { validateGeminiWebProvider } = await import( + "@/lib/providers/validation/webProvidersB" + ); + const result = await validateGeminiWebProvider({ apiKey: "" }); + assert.equal(result.valid, false); + assert.ok(result.error?.includes("Paste your __Secure-1PSID")); + }); +}); + +describe("GeminiWebExecutor — testConnection", () => { + it("has a testConnection method", async () => { + const { GeminiWebExecutor } = await import( + "@omniroute/open-sse/executors/gemini-web.ts" + ); + const executor = new GeminiWebExecutor(); + assert.equal(typeof (executor as any).testConnection, "function"); + }); + + it("returns false for empty credentials", async () => { + const { GeminiWebExecutor } = await import( + "@omniroute/open-sse/executors/gemini-web.ts" + ); + assert.equal(await new GeminiWebExecutor().testConnection({}), false); + }); + + it("returns false for missing apiKey", async () => { + const { GeminiWebExecutor } = await import( + "@omniroute/open-sse/executors/gemini-web.ts" + ); + assert.equal( + await new GeminiWebExecutor().testConnection({ apiKey: "" }), + false + ); + }); + + it("returns false for empty cookie value", async () => { + const { GeminiWebExecutor } = await import( + "@omniroute/open-sse/executors/gemini-web.ts" + ); + assert.equal( + await new GeminiWebExecutor().testConnection({ + apiKey: "__Secure-1PSID=", + }), + false + ); + }); + + it("returns true for well-formed cookie", async () => { + const { GeminiWebExecutor } = await import( + "@omniroute/open-sse/executors/gemini-web.ts" + ); + assert.equal( + await new GeminiWebExecutor().testConnection({ + apiKey: "__Secure-1PSID=abc123.def456.ghi789", + }), + true + ); + }); + + it("accepts bare cookie value (without prefix)", async () => { + const { GeminiWebExecutor } = await import( + "@omniroute/open-sse/executors/gemini-web.ts" + ); + assert.equal( + await new GeminiWebExecutor().testConnection({ + apiKey: "abc123.def456.ghi789", + }), + true + ); + }); + + it("handles providerSpecificData.cookie", async () => { + const { GeminiWebExecutor } = await import( + "@omniroute/open-sse/executors/gemini-web.ts" + ); + assert.equal( + await new GeminiWebExecutor().testConnection({ + providerSpecificData: { cookie: "__Secure-1PSID=xyz.789" }, + }), + true + ); + }); +}); + +describe("gemini-web queue timeout", () => { + it("default queueTimeoutMs is at least 30s", async () => { + const { getDefaultComboConfig } = await import( + "@omniroute/open-sse/services/comboConfig.ts" + ); + const config = getDefaultComboConfig(); + assert.ok( + config.queueTimeoutMs >= 30000, + `queueTimeoutMs should be at least 30s (got ${config.queueTimeoutMs}ms)` + ); + }); +});