fix(providers): detect expired gemini-web sessions and add testConnection (#9407)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-04 23:28:30 -03:00
committed by GitHub
parent 85f30d4da8
commit f1ea77fd04
5 changed files with 203 additions and 1 deletions

View File

@@ -0,0 +1 @@
- fix(providers): detect expired gemini-web sessions via ServiceLogin redirect and add testConnection override (#9407)

View File

@@ -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<string, unknown>,
_signal?: AbortSignal
): Promise<boolean> {
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({

View File

@@ -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: "",

View File

@@ -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);

View File

@@ -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)`
);
});
});