From c95a161709ae95bf315c46fd97fb74fbf4c185d5 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:39:02 -0300 Subject: [PATCH] fix(sse): persist rotated Gemini web-session cookies via onCredentialsRefreshed (#7676) (#7751) --- .../fixes/7676-gemini-persist-cookies.md | 1 + open-sse/executors/gemini-web.ts | 68 +++++++++++++- .../gemini-web-cookie-rotation-7676.test.ts | 93 +++++++++++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/7676-gemini-persist-cookies.md create mode 100644 tests/unit/gemini-web-cookie-rotation-7676.test.ts diff --git a/changelog.d/fixes/7676-gemini-persist-cookies.md b/changelog.d/fixes/7676-gemini-persist-cookies.md new file mode 100644 index 0000000000..9e320d7ef1 --- /dev/null +++ b/changelog.d/fixes/7676-gemini-persist-cookies.md @@ -0,0 +1 @@ +- fix(sse): persist rotated Gemini web-session cookies via onCredentialsRefreshed (#7676) diff --git a/open-sse/executors/gemini-web.ts b/open-sse/executors/gemini-web.ts index 0286cc8202..c60df8eb35 100644 --- a/open-sse/executors/gemini-web.ts +++ b/open-sse/executors/gemini-web.ts @@ -169,6 +169,40 @@ function readProviderSpecificString( return ""; } +/** + * Merge rotated __Secure-1PSID* cookies read back from the live Playwright + * cookie jar into the original cookie string. Only the three long-lived + * Gemini auth cookies are considered — pulling in the entire jar would risk + * treating short-lived Google analytics/consent cookies as credentials + * (#7676). Cookies the jar didn't return, or that are unchanged, are left + * untouched in the original string. + */ +export function mergeRotatedGeminiCookies( + originalCookie: string, + jarCookies: Array<{ name: string; value: string }> +): string { + const ROTATABLE_NAMES = ["__Secure-1PSID", "__Secure-1PSIDTS", "__Secure-1PSIDCC"]; + const jarByName = new Map(jarCookies.map((c) => [c.name, c.value])); + + const pairs = parseCookies(originalCookie); + const seen = new Set(); + const merged = pairs.map(({ name, value }) => { + seen.add(name); + if (ROTATABLE_NAMES.includes(name) && jarByName.has(name)) { + return { name, value: jarByName.get(name) as string }; + } + return { name, value }; + }); + + for (const name of ROTATABLE_NAMES) { + if (!seen.has(name) && jarByName.has(name)) { + merged.push({ name, value: jarByName.get(name) as string }); + } + } + + return merged.map(({ name, value }) => `${name}=${value}`).join("; "); +} + function normalizeGeminiCookieInput(raw: string, cookieName = "__Secure-1PSID"): string { const trimmed = raw.trim(); if (!trimmed) return ""; @@ -202,8 +236,38 @@ export class GeminiWebExecutor extends BaseExecutor { super("gemini-web", { id: "gemini-web", baseUrl: GEMINI_URL }); } + /** + * Read the live Playwright cookie jar back after a successful run and, if + * Google rotated any of the __Secure-1PSID* cookies, forward the merged + * cookie string through onCredentialsRefreshed so it gets persisted to the + * encrypted provider_connections.api_key field. Mirrors the rotate-and- + * persist pattern already shipped in chatgpt-web.ts. A persistence failure + * must never fail the user-facing response (#7676). + */ + private async persistRotatedCookies( + context: import("playwright").BrowserContext, + cookie: string, + credentials: ExecuteInput["credentials"], + onCredentialsRefreshed: ExecuteInput["onCredentialsRefreshed"], + log: ExecuteInput["log"] + ): Promise { + if (!onCredentialsRefreshed) return; + try { + const jarCookies = await context.cookies(); + const mergedCookie = mergeRotatedGeminiCookies(cookie, jarCookies); + if (mergedCookie && mergedCookie !== cookie) { + await onCredentialsRefreshed({ ...credentials, apiKey: mergedCookie }); + } + } catch (err) { + log?.warn?.( + "GEMINI-WEB", + `Failed to persist rotated cookie: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + async execute(input: ExecuteInput) { - const { model, body, stream, credentials, signal } = input; + const { model, body, stream, credentials, signal, log, onCredentialsRefreshed } = input; const requestBody = body as GeminiRequestBody; const cookie = resolveGeminiWebCookie(credentials); @@ -314,6 +378,8 @@ export class GeminiWebExecutor extends BaseExecutor { }; } + await this.persistRotatedCookies(context, cookie, credentials, onCredentialsRefreshed, log); + const modelId = model || "gemini-2.5-pro"; if (stream) { diff --git a/tests/unit/gemini-web-cookie-rotation-7676.test.ts b/tests/unit/gemini-web-cookie-rotation-7676.test.ts new file mode 100644 index 0000000000..edbe5b7f1d --- /dev/null +++ b/tests/unit/gemini-web-cookie-rotation-7676.test.ts @@ -0,0 +1,93 @@ +// Repro probe for issue #7676: +// gemini-web executor never reads back the live Playwright cookie jar after a +// successful run, so rotated __Secure-1PSIDTS / __Secure-1PSIDCC values are +// never persisted via onCredentialsRefreshed — unlike chatgpt-web.ts, which +// already forwards its rotated cookie through the same callback +// (open-sse/executors/chatgpt-web.ts:2843). +import test from "node:test"; +import assert from "node:assert/strict"; + +const { GeminiWebExecutor } = await import("../../open-sse/executors/gemini-web.ts"); + +test("#7676: GeminiWebExecutor persists rotated __Secure-1PSIDTS/__Secure-1PSIDCC via onCredentialsRefreshed after a successful run", async () => { + const playwright = await import("playwright"); + const originalLaunch = playwright.chromium.launch; + + const staleCookie = + "__Secure-1PSID=abc123; __Secure-1PSIDTS=OLD_TS_VALUE; __Secure-1PSIDCC=OLD_CC_VALUE"; + + const rotatedJarCookies = [ + { name: "__Secure-1PSID", value: "abc123", domain: ".google.com", path: "/" }, + { name: "__Secure-1PSIDTS", value: "ROTATED_TS_VALUE", domain: ".google.com", path: "/" }, + { name: "__Secure-1PSIDCC", value: "ROTATED_CC_VALUE", domain: ".google.com", path: "/" }, + ]; + + playwright.chromium.launch = async () => + ({ + newContext: async () => ({ + addCookies: async () => {}, + cookies: async () => rotatedJarCookies, + newPage: async () => ({ + on: (event: string, handler: (resp: { url: () => string; text: () => Promise }) => void) => { + if (event === "response") { + const body = + ")]}'\n" + + "30\n" + + JSON.stringify([ + [ + "wrb.fr", + null, + JSON.stringify([null, null, null, null, [[null, ["hello back"]]]]), + ], + ]) + + "\n"; + handler({ + url: () => + "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate", + text: async () => body, + }); + } + }, + goto: async () => {}, + waitForTimeout: async () => {}, + waitForSelector: async () => ({ click: async () => {} }), + keyboard: { type: async () => {}, press: async () => {} }, + }), + }), + close: async () => {}, + }) as unknown as typeof originalLaunch; + + let persistedCredentials: Record | null = null; + + try { + const executor = new GeminiWebExecutor(); + const result = await executor.execute({ + model: "gemini-3.1-pro", + body: { messages: [{ role: "user", content: "hello" }], stream: false }, + stream: false, + credentials: { apiKey: staleCookie }, + signal: AbortSignal.timeout(5000), + log: null, + onCredentialsRefreshed: async (newCreds: Record) => { + persistedCredentials = newCreds; + }, + } as unknown as Parameters["execute"]>[0]); + + assert.equal(result.response.status, 200, "run should succeed with a Gemini response"); + assert.ok( + persistedCredentials, + "onCredentialsRefreshed must be called so the rotated cookie jar is persisted to provider_connections (#7676)" + ); + assert.ok( + typeof persistedCredentials.apiKey === "string" && + persistedCredentials.apiKey.includes("ROTATED_TS_VALUE"), + `persisted apiKey must contain the rotated __Secure-1PSIDTS value, got: ${persistedCredentials?.apiKey}` + ); + assert.ok( + persistedCredentials.apiKey.includes("ROTATED_CC_VALUE"), + `persisted apiKey must contain the rotated __Secure-1PSIDCC value, got: ${persistedCredentials?.apiKey}` + ); + } finally { + playwright.chromium.launch = originalLaunch; + } +});