From dc8e458dfeafac668e55ca98540a64ec8042d187 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Wed, 2 Sep 2026 08:30:50 -0300 Subject: [PATCH] fix(dashboard): encode chatgpt-session credentials with the shared predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both connection modals wrapped the pasted Cookie header in the {version, cookie, runtimeKey} envelope only when provider === "chatgpt-web-codex", but the create and update routes send every provider accepted by usesChatGptBrowserSessionCredentials() through finalizeValidatedChatGptWebCodexSecrets, whose first statement is JSON.parse. A chatgpt-session save therefore always failed with 400 "Unexpected token '_', "__Secure-n"... is not valid JSON" — the dashboard could never create or update a connection. Both modals now key the envelope off the same predicate the routes use, so client and server cannot drift again, and omit runtimeKey when empty (this provider never has one). The two failure paths returned error.message raw, echoing the first characters of the pasted credential; they now go through sanitizeErrorMessage, and their untranslated German fallback is replaced with provider-neutral English. --- .../[id]/components/modals/AddApiKeyModal.tsx | 16 +- .../components/modals/EditConnectionModal.tsx | 12 +- src/app/api/providers/[id]/route.ts | 6 +- src/app/api/providers/route.ts | 6 +- .../chatgpt-session-providers-route.test.ts | 153 ++++++++++++++++++ 5 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 tests/unit/chatgpt-session-providers-route.test.ts diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx index 3611433feb..28b4e7f984 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx @@ -2,7 +2,10 @@ import { useState, useEffect, useRef } from "react"; import { useTranslations } from "next-intl"; import { Button, Badge, Input, Modal, Toggle, TALL_MODAL_PROPS } from "@/shared/components"; -import { CHATGPT_WEB_CODEX_CONNECTOR_NAME } from "@/shared/constants/chatgptWebCodex"; +import { + CHATGPT_WEB_CODEX_CONNECTOR_NAME, + usesChatGptBrowserSessionCredentials, +} from "@/shared/constants/chatgptWebCodex"; import { providerAllowsOptionalApiKey, supportsBulkApiKey, @@ -97,6 +100,12 @@ export default function AddApiKeyModal({ const isLocalSelfHostedProvider = !!localProviderMetadata; const isGooglePse = provider === "google-pse-search"; const isChatGptWebCodex = provider === "chatgpt-web-codex"; + // The credential ENVELOPE is decided by the shared browser-session lifecycle predicate, + // not by the codex id: `/api/providers` routes every provider this predicate accepts into + // `finalizeValidatedChatGptWebCodexSecrets`, which starts with `JSON.parse`. Posting a raw + // cookie for one of them fails the save with a JSON parse error, so client and server must + // read the same predicate. + const usesBrowserSessionCredential = usesChatGptBrowserSessionCredentials(provider); const isAwsPolly = provider === "aws-polly"; const webSessionCredential = getWebSessionCredentialRequirement(provider); const isNoAuthWebSessionCredential = webSessionCredential?.kind === "none"; @@ -402,11 +411,12 @@ export default function AddApiKeyModal({ ...(validatedProviderSpecificData || {}), }; - const encodedCredential = isChatGptWebCodex + const encodedCredential = usesBrowserSessionCredential ? JSON.stringify({ version: 1, cookie: credentialInput.trim().replace(/^cookie\s*:\s*/i, ""), - runtimeKey: formData.runtimeKey.trim(), + // Only chatgpt-web-codex ever has a runtime key; omit the field entirely otherwise. + ...(formData.runtimeKey.trim() ? { runtimeKey: formData.runtimeKey.trim() } : {}), }) : credentialInput.trim(); const payload = { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx index ede857c50a..604d90b117 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -3,7 +3,10 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; import { Button, Badge, Input, Modal, Toggle, Select } from "@/shared/components"; -import { CHATGPT_WEB_CODEX_CONNECTOR_NAME } from "@/shared/constants/chatgptWebCodex"; +import { + CHATGPT_WEB_CODEX_CONNECTOR_NAME, + usesChatGptBrowserSessionCredentials, +} from "@/shared/constants/chatgptWebCodex"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider, @@ -230,6 +233,11 @@ export default function EditConnectionModal({ const isLocalSelfHostedProvider = !!localProviderMetadata; const isGooglePse = provider === "google-pse-search"; const isChatGptWebCodex = provider === "chatgpt-web-codex"; + // The credential ENVELOPE is decided by the shared browser-session lifecycle predicate, + // not by the codex id: `/api/providers/[id]` routes every provider this predicate accepts + // into `decodeChatGptWebCodexSecrets` + `finalizeValidatedChatGptWebCodexSecrets`, both of + // which expect the JSON envelope. Client and server must read the same predicate. + const usesBrowserSessionCredential = usesChatGptBrowserSessionCredentials(provider); const isAwsPolly = provider === "aws-polly"; const isM365TierCapable = isM365TierCapableProvider(provider); const webSessionCredential = getWebSessionCredentialRequirement(provider); @@ -648,7 +656,7 @@ export default function EditConnectionModal({ } } if (isValid) { - updates.apiKey = isChatGptWebCodex + updates.apiKey = usesBrowserSessionCredential ? JSON.stringify({ version: 1, cookie: formData.apiKey.trim().replace(/^cookie\s*:\s*/i, ""), diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index 8faf21b202..c635859051 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -34,6 +34,7 @@ import { decodeChatGptWebCodexSecrets, encodeChatGptWebCodexSecrets, } from "@omniroute/open-sse/services/chatgptWebCodexAdmin.ts"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; import { rejectRetiredCommonChatGptWebProvider } from "@/lib/providers/chatgptWebRetirementResponse"; import { usesChatGptBrowserSessionCredentials } from "@/shared/constants/chatgptWebCodex"; @@ -192,10 +193,11 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: } catch (error) { return NextResponse.json( { - error: + error: sanitizeErrorMessage( error instanceof Error ? error.message - : "Die ChatGPT-Browserprüfung konnte nicht abgeschlossen werden.", + : "The browser session verification could not be completed." + ), }, { status: 400 } ); diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index 1f95f0b78c..8104f3b996 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -48,6 +48,7 @@ import { getModelSyncInternalBaseUrl, } from "@/shared/services/modelSyncScheduler"; import { finalizeValidatedChatGptWebCodexSecrets } from "@omniroute/open-sse/services/chatgptWebCodexAdmin.ts"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; import { usesChatGptBrowserSessionCredentials } from "@/shared/constants/chatgptWebCodex"; import { isAutoFetchModelsEnabled } from "@/lib/providerModels/modelDiscovery"; import { testSingleConnection } from "./[id]/test/route"; @@ -212,10 +213,11 @@ export async function POST(request: Request) { } catch (error) { return NextResponse.json( { - error: + error: sanitizeErrorMessage( error instanceof Error ? error.message - : "Die ChatGPT-Browserprüfung konnte nicht abgeschlossen werden.", + : "The browser session verification could not be completed." + ), }, { status: 400 } ); diff --git a/tests/unit/chatgpt-session-providers-route.test.ts b/tests/unit/chatgpt-session-providers-route.test.ts new file mode 100644 index 0000000000..e9bf97ecfa --- /dev/null +++ b/tests/unit/chatgpt-session-providers-route.test.ts @@ -0,0 +1,153 @@ +/** + * C2 regression guard — creating a `chatgpt-session` connection from the dashboard. + * + * `POST /api/providers` routes every provider accepted by + * `usesChatGptBrowserSessionCredentials()` into `finalizeValidatedChatGptWebCodexSecrets`, + * whose first statement is `JSON.parse`. The dashboard modals used to build the + * `{version, cookie}` envelope only for `chatgpt-web-codex` and post the pasted Cookie header + * verbatim for anything else, so every `chatgpt-session` save died with + * `Unexpected token '_', "__Secure-n"... is not valid JSON`. These tests pin both halves of the + * contract: the envelope the fixed modals send is accepted and stored as the VERIFIED storage + * state (never the raw cookie), and a raw header is rejected — which is exactly why the client + * must key its encoding off the same shared predicate the route uses. + * + * The browser probe is not run: the verification artifacts the real Playwright inspector leaves + * behind are seeded on disk, and a CDP endpoint is configured so nothing in this file can ever + * reach `chromium.launch()`. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { makeManagementSessionRequest } from "../helpers/managementSession.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-chatgpt-session-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; +// Hard "never launch Chrome" guarantee for the fire-and-forget auto-test the POST kicks off: +// with a CDP endpoint set, the vendored login inspector connects over CDP (to a dead loopback +// port here) instead of spawning a browser. +process.env.CHATGPT_WEB_CODEX_CDP_URL = "http://127.0.0.1:1"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const storageState = await import("../../open-sse/executors/chatgpt-web-codex/storageState.ts"); +const browserLogin = await import("../../open-sse/vendor/codex-chatgpt-web/browser-login.ts"); +const providersRoute = await import("../../src/app/api/providers/route.ts"); + +const RAW_COOKIE = "__Secure-next-auth.session-token=session-value-abc; _cfuvid=cf-value"; + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +/** + * Reproduce, without Playwright, exactly what a successful browser validation leaves on disk: + * the cookie-derived storage state plus the verification marker the inspector writes on success. + */ +function seedVerifiedValidation(cookie: string): string { + const validationId = `validation-${randomBytes(12).toString("hex")}`; + const paths = storageState.connectionRuntimePaths(validationId); + storageState.ensureConnectionStorageState(validationId, cookie); + browserLogin.writeVerificationMarker(paths.storageStatePath, { + solAvailable: false, + proAvailable: false, + }); + return validationId; +} + +async function createConnection(apiKey: string, validationId: string): Promise { + return providersRoute.POST( + await makeManagementSessionRequest("http://localhost/api/providers", { + method: "POST", + headers: { "x-skip-model-sync": "true" }, + body: { + provider: "chatgpt-session", + name: `ChatGPT session ${randomBytes(4).toString("hex")}`, + apiKey, + providerSpecificData: { validationId }, + }, + }) + ); +} + +test("POST creates a chatgpt-session connection from a pasted cookie header", async () => { + const validationId = seedVerifiedValidation(RAW_COOKIE); + // What the fixed modals send for ANY provider `usesChatGptBrowserSessionCredentials()` + // accepts — the raw pasted header wrapped in the shared credential envelope, with no + // `runtimeKey` (this provider never has one). + const envelope = JSON.stringify({ version: 1, cookie: RAW_COOKIE }); + + const response = await createConnection(envelope, validationId); + const body = (await response.json()) as { connection?: { id: string }; error?: string }; + + assert.equal(response.status, 201, `expected 201, got ${response.status}: ${body.error ?? ""}`); + assert.ok(body.connection?.id); + + const stored = (await providersDb.getProviderConnectionById(body.connection.id)) as Record< + string, + unknown + > | null; + assert.ok(stored); + const persisted = JSON.parse(String(stored.apiKey)) as Record; + // The raw cookie is discarded in favour of the verified Playwright storage state. + assert.equal(persisted.version, 2); + assert.equal("cookie" in persisted, false); + assert.equal("runtimeKey" in persisted, false); + const state = persisted.storageState as Record; + assert.ok(Array.isArray(state.cookies)); + assert.equal(String(stored.apiKey).includes("session-value-abc"), true); + assert.equal( + String(stored.apiKey).includes(RAW_COOKIE), + false, + "the pasted Cookie header itself must never be persisted" + ); + + // The one-shot validation scratch directory is consumed by the finalize step. + assert.equal( + fs.existsSync(storageState.connectionRuntimePaths(validationId).storageStatePath), + false + ); +}); + +test("POST rejects a raw cookie header and never echoes a stack or a path", async () => { + const validationId = seedVerifiedValidation(RAW_COOKIE); + + const response = await createConnection(RAW_COOKIE, validationId); + const body = (await response.json()) as { error?: string }; + + assert.equal(response.status, 400); + assert.ok(body.error); + // Routed through sanitizeErrorMessage: no stack tail, no absolute source path. + assert.doesNotMatch(String(body.error), /\n\s+at /); + assert.doesNotMatch(String(body.error), /at \//); + // The German fallback is gone — this provider is labelled in English. + assert.doesNotMatch(String(body.error), /Browserprüfung/); +}); + +test("both dashboard modals derive the credential envelope from the shared predicate", () => { + // The client/server drift that caused C2 is only detectable at the source level: the modals + // are React components with no unit-testable seam. Pin that neither of them gates the + // ENVELOPE on the codex-only id any more. + const modals = [ + "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx", + "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx", + ]; + for (const modal of modals) { + const source = fs.readFileSync(modal, "utf8"); + assert.match( + source, + /usesChatGptBrowserSessionCredentials\(provider\)/, + `${modal} must derive the envelope from the shared browser-session predicate` + ); + assert.doesNotMatch( + source, + /=\s*isChatGptWebCodex\s*\n?\s*\?\s*JSON\.stringify\(\{/, + `${modal} must not gate the credential envelope on the codex-only provider id` + ); + } +});