From 84ac06b4a77ce54ddcf699e06f159a2c7e70f917 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 19 Jun 2026 22:05:42 -0300 Subject: [PATCH] fix(executors): reconstruct LMArena split auth cookie (#4271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LMArena migrated to @supabase/ssr chunked auth cookies: the single arena-auth-prod-v1 cookie is now empty and the session is split across arena-auth-prod-v1.0, .1, … (ascending). Pasting the now-empty single cookie sent an empty session, which upstream rejected as "invalid cookie". reconstructLMArenaCookie() rebuilds the single cookie from its chunks (ascending join, no decode/parse β€” combineChunks semantics), preserving the rest of the pasted jar; a non-empty single cookie is forwarded unchanged (back-compat). The credential UX now instructs pasting the full Cookie header and tracks the .0/.1 storage keys. Closes #4271 --- CHANGELOG.md | 1 + open-sse/executors/lmarena.ts | 88 +++++++++++- src/shared/constants/providers.ts | 2 +- src/shared/providers/webSessionCredentials.ts | 17 ++- tests/unit/lmarena-split-cookie-4271.test.ts | 126 ++++++++++++++++++ 5 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 tests/unit/lmarena-split-cookie-4271.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index df43754a53ad..078f56a8eedc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ _In development β€” bullets added per PR; finalized at release._ ### πŸ› Fixed +- **fix(executors): ArenaLLM accepts LMArena's split Supabase SSR auth cookie** β€” LMArena migrated to `@supabase/ssr` chunked auth cookies: the single `arena-auth-prod-v1` cookie is now empty and the real session is split across `arena-auth-prod-v1.0`, `arena-auth-prod-v1.1`, … (ascending). A user who pasted the (now-empty) single cookie therefore sent an empty session and upstream rejected it as "invalid cookie". The LMArena executor now reconstructs the single cookie from its chunks β€” reading `.0`, `.1`, … in ascending numeric order until one is missing and concatenating their raw values (`@supabase/ssr`'s `combineChunks` rule: plain `join("")`, no base64-decode, no JSON-parse, the `base64-` prefix kept verbatim) β€” while preserving the rest of the pasted jar. A non-empty single cookie is still forwarded unchanged (back-compat). The credential UX now instructs pasting the **full Cookie header** and tracks the `.0`/`.1` storage keys. ([#4271](https://github.com/diegosouzapw/OmniRoute/issues/4271) β€” thanks @caussao) - **fix(sse): retry direct-connection socket failures on a fresh socket (fewer `502` bursts)** β€” the default direct-connection undici dispatcher pools keep-alive sockets for up to 4 s, but some edges (e.g. `nvidia`, `opencode-zen`) silently close idle keep-alive sockets within that window, so the next request reusing a pooled socket fails with `UND_ERR_SOCKET` ("other side closed") β€” in bursts. `proxyFetch` already retried once on such transient errors, but the retry reused the **same** pooled dispatcher and could grab another stale socket, then fell through to native fetch (which also pools) β†’ the job sat in the rate-limit queue until the 30 s timeout β†’ `502` + circuit-breaker open. The retry now uses a dedicated **no-keep-alive / no-pipelining** dispatcher so it opens a brand-new socket that can't be a dead pooled one; the first attempt still uses the pooled dispatcher (healthy keep-alive reuse is preserved). Complements the v3.8.29 diagnostics (`describeFetchCause`, #4281). ([#4252](https://github.com/diegosouzapw/OmniRoute/issues/4252) β€” thanks @klimadev) - **fix(sse): combo now stops at the first body-specific 400 instead of trying every target** β€” the `#2101` guard that detects a body-specific 400 (context overflow / malformed / model-access-denied, e.g. "model is not supported when using Codex with a ChatGPT account") logged "stopping combo" but executed a bare `break`, which only exited the inner retry loop; `executeTarget` then returned `null` and the outer target loop treated that as "this target produced nothing" and advanced to the next model. A combo of N targets that all reject the same request body therefore marched through all N (the report shows a 143-model Codex combo iterating every target), wasting upstream calls and per-attempt work. The guard now surfaces the 400 via the `{ ok, response }` contract (mirroring the 499 client-disconnect path) so the combo resolves and stops immediately. ([#4279](https://github.com/diegosouzapw/OmniRoute/issues/4279)) - **fix(sse): non-streaming combo over a Responses-API target no longer returns empty content** β€” a Responses-API target (codex/`cx`) streams from upstream even on `stream:false`, and its terminal `response.completed` snapshot can carry a non-empty `output` that lacks the assistant message item (e.g. only a `reasoning` item) while the streamed `output_text` deltas had reconstructed the full message. The SSEβ†’JSON aggregator preferred the terminal `output` wholesale, dropping the reconstructed text β†’ HTTP 200 with empty content (hit notably via n8n, which defaults to `stream:false`). The aggregator now falls back to the reconstructed delta output when the terminal output has no message item but the reconstruction does; the terminal snapshot still wins whenever it already carries the message. ([#3948](https://github.com/diegosouzapw/OmniRoute/issues/3948)) diff --git a/open-sse/executors/lmarena.ts b/open-sse/executors/lmarena.ts index 01f2a58a506b..ca24cf3cfff3 100644 --- a/open-sse/executors/lmarena.ts +++ b/open-sse/executors/lmarena.ts @@ -33,17 +33,99 @@ const LMARENA_STREAM_URL = `${LMARENA_API_BASE}/nextjs-api/stream`; const LMARENA_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; +const LMARENA_AUTH_COOKIE = "arena-auth-prod-v1"; + +interface ParsedCookie { + name: string; + value: string; +} + +/** + * Parse a raw `Cookie:`-style blob (`name=value; name2=value2; …`) into an + * ordered list of name/value pairs. Whitespace around names is trimmed; values + * are kept verbatim (they may legitimately contain `=`, e.g. base64 padding). + */ +function parseCookieBlob(blob: string): ParsedCookie[] { + const pairs: ParsedCookie[] = []; + for (const part of blob.split(";")) { + const eq = part.indexOf("="); + if (eq < 0) continue; + const name = part.slice(0, eq).trim(); + if (!name) continue; + const value = part.slice(eq + 1).trim(); + pairs.push({ name, value }); + } + return pairs; +} + +/** + * Reconstruct LMArena's single `arena-auth-prod-v1` auth cookie from the + * Supabase SSR chunked form. + * + * LMArena migrated to `@supabase/ssr`, which splits a large auth cookie across + * `arena-auth-prod-v1.0`, `arena-auth-prod-v1.1`, … (ascending). The single + * `arena-auth-prod-v1` cookie is then left empty. Following `@supabase/ssr`'s + * `combineChunks`, we read chunks in ascending numeric order until one is + * missing and `join("")` their raw values β€” NO base64-decode, NO JSON-parse. + * The joined value typically starts with the literal `base64-` prefix; we keep + * it verbatim (the upstream expects it). + * + * - If the blob already carries a non-empty `arena-auth-prod-v1=`, it is + * returned unchanged (back-compat with the pre-migration single cookie). + * - Otherwise the reconstructed `arena-auth-prod-v1=` is injected while + * every other cookie in the pasted jar is preserved. + * - If neither the single cookie nor any `.N` chunk has a value, the blob is + * returned as-is so the existing missing-cookie path still fires. + */ +export function reconstructLMArenaCookie(rawCookie: string): string { + if (!rawCookie || !rawCookie.trim()) return rawCookie; + + const pairs = parseCookieBlob(rawCookie); + + // Back-compat: a non-empty single cookie is already usable β€” forward verbatim. + const existing = pairs.find((p) => p.name === LMARENA_AUTH_COOKIE); + if (existing && existing.value) return rawCookie; + + // Collect chunk values keyed by their numeric index (`arena-auth-prod-v1.`). + const chunkPrefix = `${LMARENA_AUTH_COOKIE}.`; + const chunks = new Map(); + for (const { name, value } of pairs) { + if (!name.startsWith(chunkPrefix)) continue; + const idxRaw = name.slice(chunkPrefix.length); + if (!/^\d+$/.test(idxRaw)) continue; + chunks.set(Number(idxRaw), value); + } + + // Join in ascending order until a chunk is missing (combineChunks semantics). + const joinedParts: string[] = []; + for (let i = 0; chunks.has(i); i++) { + joinedParts.push(chunks.get(i) ?? ""); + } + const joined = joinedParts.join(""); + + // No usable session anywhere β†’ return as-is so the missing-cookie path fires. + if (!joined) return rawCookie; + + // Inject the reconstructed single cookie while preserving the rest of the jar + // (drop the empty base cookie and the now-redundant chunks). + const preserved = pairs.filter( + (p) => p.name !== LMARENA_AUTH_COOKIE && !p.name.startsWith(chunkPrefix) + ); + const rebuilt = [`${LMARENA_AUTH_COOKIE}=${joined}`, ...preserved.map((p) => `${p.name}=${p.value}`)]; + return rebuilt.join("; "); +} + function readLMArenaCookie(credentials: unknown): string { if (!credentials || typeof credentials !== "object") return ""; const c = credentials as Record; const direct = typeof c.cookie === "string" ? c.cookie : ""; - if (direct.trim()) return direct; + if (direct.trim()) return reconstructLMArenaCookie(direct); const apiKey = typeof c.apiKey === "string" ? c.apiKey : ""; - if (apiKey.trim()) return apiKey; + if (apiKey.trim()) return reconstructLMArenaCookie(apiKey); const psd = c.providerSpecificData; if (psd && typeof psd === "object") { const nested = (psd as Record).cookie; - if (typeof nested === "string" && nested.trim()) return nested; + if (typeof nested === "string" && nested.trim()) return reconstructLMArenaCookie(nested); } return ""; } diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 3e8825beea9b..7d484f4e4b20 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -501,7 +501,7 @@ export const WEB_COOKIE_PROVIDERS = { freeNote: "Free model comparison platform β€” 40+ models (GPT, Claude, Gemini, Llama). No subscription required.", authHint: - "Paste your session cookie from lmarena.ai (DevTools β†’ Application β†’ Cookies). Optional β€” works with free tier for basic comparisons.", + "Paste the full Cookie header from lmarena.ai (DevTools β†’ Network β†’ request β†’ Cookie). The session is now split across arena-auth-prod-v1.0, .1, … β€” copy the whole header. Optional β€” works with free tier for basic comparisons.", riskNoticeVariant: "webCookie", }, huggingchat: { diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index cf5e403367e2..772fe50f3a27 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -204,10 +204,21 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { kind: "cookie", // lmarena.ai's auth cookie is `arena-auth-prod-v1` (the legacy hint said `session`, // which never matched the real cookie name and confused users). #3810 + // + // #4271: LMArena migrated to Supabase SSR chunked cookies β€” the single + // `arena-auth-prod-v1` cookie is now empty and the session is split across + // `arena-auth-prod-v1.0`, `arena-auth-prod-v1.1`, … Users must paste the FULL + // Cookie header so the executor can reconstruct the single cookie from chunks. credentialName: "arena-auth-prod-v1", - placeholder: "arena-auth-prod-v1=... or full Cookie header from lmarena.ai", - acceptsFullCookieHeader: true, - storageKeys: ["cookie", "arena-auth-prod-v1", "session"], + placeholder: "Paste the full Cookie header from lmarena.ai (the session is now split across arena-auth-prod-v1.0, .1, …)", + acceptsFullCookieHeader: true, + storageKeys: [ + "cookie", + "arena-auth-prod-v1", + "arena-auth-prod-v1.0", + "arena-auth-prod-v1.1", + "session", + ], }, } satisfies Record; diff --git a/tests/unit/lmarena-split-cookie-4271.test.ts b/tests/unit/lmarena-split-cookie-4271.test.ts new file mode 100644 index 000000000000..80b304928231 --- /dev/null +++ b/tests/unit/lmarena-split-cookie-4271.test.ts @@ -0,0 +1,126 @@ +/** + * LMArena Split Supabase SSR Cookie β€” Regression Tests (issue #4271) + * + * LMArena migrated to Supabase SSR chunked auth cookies. The single + * `arena-auth-prod-v1` cookie is now empty; the real session value is split + * across `arena-auth-prod-v1.0`, `arena-auth-prod-v1.1`, … (ascending). We must + * reconstruct the single cookie from its chunks (plain `values.join("")`, the + * `@supabase/ssr` `combineChunks` rule β€” NO base64-decode, NO JSON-parse) before + * forwarding the Cookie header upstream. + * + * Run: + * npx cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx \ + * --import ./open-sse/utils/setupPolyfill.ts \ + * --import ./tests/_setup/isolateDataDir.ts \ + * --test --test-force-exit tests/unit/lmarena-split-cookie-4271.test.ts + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + LMArenaExecutor, + reconstructLMArenaCookie, +} from "../../open-sse/executors/lmarena.ts"; +import { getWebSessionCredentialRequirement } from "../../src/shared/providers/webSessionCredentials.ts"; + +function cookieHeaderFor(credentials: unknown): string | undefined { + const executor = new LMArenaExecutor(); + const headers = (executor as any).buildHeaders("gpt-4", credentials, {}); + return headers.Cookie; +} + +describe("LMArena split Supabase SSR cookie (#4271)", () => { + it("reconstructs the single cookie from ascending chunks (no decode)", () => { + // The single base cookie is empty; the session lives in .0 + .1 + const raw = + "arena-auth-prod-v1=; arena-auth-prod-v1.0=base64-eyJABC; arena-auth-prod-v1.1=DEF.ghi"; + const reconstructed = reconstructLMArenaCookie(raw); + + // Ascending concat of the chunk values, used verbatim (base64- prefix kept). + assert.ok( + reconstructed.includes("arena-auth-prod-v1=base64-eyJABCDEF.ghi"), + `expected reconstructed cookie to carry the joined session, got: ${reconstructed}` + ); + + // And it must flow through to the forwarded Cookie header. + const header = cookieHeaderFor({ cookie: raw }); + assert.ok(header, "should set a Cookie header"); + assert.ok( + header!.includes("arena-auth-prod-v1=base64-eyJABCDEF.ghi"), + `Cookie header should carry the reconstructed session, got: ${header}` + ); + }); + + it("leaves a non-empty single cookie unchanged (back-compat)", () => { + const raw = "arena-auth-prod-v1=base64-xyz"; + const reconstructed = reconstructLMArenaCookie(raw); + assert.ok( + reconstructed.includes("arena-auth-prod-v1=base64-xyz"), + `back-compat cookie should be preserved, got: ${reconstructed}` + ); + + const header = cookieHeaderFor({ cookie: raw }); + assert.equal(header, "arena-auth-prod-v1=base64-xyz"); + }); + + it("concatenates chunks in ascending numeric order even when pasted out of order", () => { + const raw = + "arena-auth-prod-v1.1=DEF.ghi; arena-auth-prod-v1.0=base64-eyJABC; arena-auth-prod-v1="; + const reconstructed = reconstructLMArenaCookie(raw); + assert.ok( + reconstructed.includes("arena-auth-prod-v1=base64-eyJABCDEF.ghi"), + `expected .0 then .1 regardless of paste order, got: ${reconstructed}` + ); + }); + + it("preserves other cookies in the jar while injecting the reconstructed session", () => { + const raw = + "cf_clearance=abc; arena-auth-prod-v1=; arena-auth-prod-v1.0=base64-eyJABC; arena-auth-prod-v1.1=DEF.ghi; sidebar=open"; + const reconstructed = reconstructLMArenaCookie(raw); + assert.ok( + reconstructed.includes("arena-auth-prod-v1=base64-eyJABCDEF.ghi"), + `session should be reconstructed, got: ${reconstructed}` + ); + assert.ok(reconstructed.includes("cf_clearance=abc"), "should keep cf_clearance"); + assert.ok(reconstructed.includes("sidebar=open"), "should keep sidebar"); + }); + + it("treats an empty base with no chunks as no usable session (returned as-is)", () => { + const raw = "arena-auth-prod-v1="; + const reconstructed = reconstructLMArenaCookie(raw); + // No usable value to inject β€” return as-is so the existing missing-cookie path fires. + assert.equal(reconstructed, raw); + + const header = cookieHeaderFor({ cookie: raw }); + // The empty base cookie is still forwarded verbatim (non-empty string), but it + // carries no session value. + assert.ok( + !/arena-auth-prod-v1=[^;\s]/.test(header ?? ""), + `should not fabricate a session value, got: ${header}` + ); + }); +}); + +describe("LMArena split-cookie credential storage keys (#4271)", () => { + it("knows about the chunked .0 / .1 storage keys", () => { + const req = getWebSessionCredentialRequirement("lmarena"); + assert.ok(req, "should have a credential requirement"); + assert.ok( + req!.storageKeys.includes("arena-auth-prod-v1.0"), + "storageKeys should include arena-auth-prod-v1.0" + ); + assert.ok( + req!.storageKeys.includes("arena-auth-prod-v1.1"), + "storageKeys should include arena-auth-prod-v1.1" + ); + }); + + it("instructs pasting the full Cookie header in the placeholder", () => { + const req = getWebSessionCredentialRequirement("lmarena"); + assert.ok(req, "should have a credential requirement"); + assert.ok( + /full cookie header/i.test(req!.placeholder), + `placeholder should instruct pasting the full Cookie header, got: ${req!.placeholder}` + ); + }); +});