mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 15:22:12 +03:00
fix(providers): detect expired gemini-web sessions and add testConnection (#9407)
This commit is contained in:
committed by
GitHub
parent
85f30d4da8
commit
f1ea77fd04
1
changelog.d/fixes/9407-gemini-web-false-positive.md
Normal file
1
changelog.d/fixes/9407-gemini-web-false-positive.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- fix(providers): detect expired gemini-web sessions via ServiceLogin redirect and add testConnection override (#9407)
|
||||||
@@ -348,6 +348,30 @@ export class GeminiWebExecutor extends BaseExecutor {
|
|||||||
super("gemini-web", { id: "gemini-web", baseUrl: GEMINI_URL });
|
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
|
* Read the live Playwright cookie jar back after a successful run and, if
|
||||||
* Google rotated any of the __Secure-1PSID* cookies, forward the merged
|
* Google rotated any of the __Secure-1PSID* cookies, forward the merged
|
||||||
@@ -593,6 +617,30 @@ export class GeminiWebExecutor extends BaseExecutor {
|
|||||||
transformedBody: body,
|
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 {
|
return {
|
||||||
response: new Response(
|
response: new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ const DEFAULT_COMBO_CONFIG = {
|
|||||||
retryDelayMs: 2000,
|
retryDelayMs: 2000,
|
||||||
fallbackDelayMs: 0,
|
fallbackDelayMs: 0,
|
||||||
concurrencyPerModel: 3, // max simultaneous requests per model (round-robin)
|
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)
|
queueDepth: DEFAULT_COMBO_QUEUE_DEPTH, // pre-cascade semaphore queue depth (round-robin, #3872)
|
||||||
handoffThreshold: 0.85,
|
handoffThreshold: 0.85,
|
||||||
handoffModel: "",
|
handoffModel: "",
|
||||||
|
|||||||
@@ -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
|
// 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()
|
// host is a genuine SSRF signal and must stay invalid — isSecurityBlockError()
|
||||||
// already makes that distinction.
|
// 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 (
|
if (
|
||||||
error instanceof SafeOutboundFetchError &&
|
error instanceof SafeOutboundFetchError &&
|
||||||
error.code === "REDIRECT_BLOCKED" &&
|
error.code === "REDIRECT_BLOCKED" &&
|
||||||
!isSecurityBlockError(error)
|
!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 { valid: true, error: null };
|
||||||
}
|
}
|
||||||
return toValidationErrorResult(error);
|
return toValidationErrorResult(error);
|
||||||
|
|||||||
@@ -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)`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user