From 5d36686d00043ffe9febbe6d365e2ffaebe1773f Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 21 Aug 2026 14:46:28 +0900 Subject: [PATCH] fix: recover pooled ChatGPT ciphertext rejection --- src/server/responses/core.ts | 19 ++- tests/responses-opaque-blob-recovery.test.ts | 142 +++++++++++++++++- ...thought-signature-credential-scope.test.ts | 22 ++- 3 files changed, 176 insertions(+), 7 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 0199203755..c6a6842679 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -550,9 +550,17 @@ function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean { const record = payload as { code?: unknown; error?: unknown }; if (record.error && typeof record.error === "object" && !Array.isArray(record.error)) { - const error = record.error as { type?: unknown; code?: unknown }; - if (error.type === "invalid_request_error" && error.code === "invalid_encrypted_content") { - return true; + const error = record.error as { type?: unknown; code?: unknown; message?: unknown }; + if (error.type === "invalid_request_error") { + if (error.code === "invalid_encrypted_content") return true; + if ( + (error.code === null || error.code === undefined) + && typeof error.message === "string" + && error.message.startsWith("The encrypted content ") + && error.message.endsWith( + " could not be verified. Reason: Encrypted content could not be decrypted or parsed.", + ) + ) return true; } } @@ -569,8 +577,9 @@ function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean { * * The outbound-body check is intentional: the inbound transcript may contain a proxy envelope or * compaction blob that the adapter already lowered, in which case a replay would be byte-identical. - * OpenAI exposes a dedicated nested code. xAI's code is generic, so its two concrete decoder error - * identities are also required; unrelated invalid-argument prose must never gain a hidden resend. + * OpenAI usually exposes a dedicated nested code; ChatGPT also emits one exact code-less + * unverifiable-ciphertext message. xAI's code is generic, so its two concrete decoder error + * identities are also required. Unrelated error prose must never gain a hidden resend. */ export function shouldAttemptOpaqueBlobRecovery(args: { status: number; diff --git a/tests/responses-opaque-blob-recovery.test.ts b/tests/responses-opaque-blob-recovery.test.ts index b547d322a3..f85d043a8c 100644 --- a/tests/responses-opaque-blob-recovery.test.ts +++ b/tests/responses-opaque-blob-recovery.test.ts @@ -1,10 +1,12 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { clearReasoningReplayCacheForTests } from "../src/responses/reasoning-replay-cache"; import { OPAQUE_COMPACTION_NOTE } from "../src/responses/compaction"; import { resetThoughtSignatureReplayForTests } from "../src/responses/thought-signature-replay"; +import * as authContextModule from "../src/codex/auth-context"; +import { saveCodexAccountCredential } from "../src/codex/account-store"; import { handleResponses, shouldAttemptOpaqueBlobRecovery, @@ -22,6 +24,14 @@ const OPENAI_BLOB_ERROR = JSON.stringify({ code: "invalid_encrypted_content", }, }); +const CHATGPT_UNVERIFIABLE_BLOB_ERROR = JSON.stringify({ + error: { + message: "The encrypted content 6871-test-ef-0 could not be verified. Reason: Encrypted content could not be decrypted or parsed.", + type: "invalid_request_error", + param: "input", + code: null, + }, +}); const XAI_DECODE_ERROR = JSON.stringify({ code: "invalid-argument", error: "Could not decode the compaction blob: invalid payload", @@ -148,6 +158,10 @@ describe("opaque blob recovery trigger", () => { test("accepts OpenAI and both xAI opaque-state rejection identities", () => { expect(shouldAttemptOpaqueBlobRecovery(base)).toBe(true); + expect(shouldAttemptOpaqueBlobRecovery({ + ...base, + errorBody: CHATGPT_UNVERIFIABLE_BLOB_ERROR, + })).toBe(true); expect(shouldAttemptOpaqueBlobRecovery({ ...base, errorBody: XAI_DECODE_ERROR })).toBe(true); expect(shouldAttemptOpaqueBlobRecovery({ ...base, errorBody: XAI_DECRYPT_ERROR })).toBe(true); }); @@ -166,10 +180,136 @@ describe("opaque blob recovery trigger", () => { })).toBe(false); expect(shouldAttemptOpaqueBlobRecovery({ ...base, adapterName: "openai-chat" })).toBe(false); expect(shouldAttemptOpaqueBlobRecovery({ ...base, alreadyAttempted: true })).toBe(false); + expect(shouldAttemptOpaqueBlobRecovery({ + ...base, + errorBody: JSON.stringify({ + error: { + type: "invalid_request_error", + code: null, + message: "The encrypted content 6871-test-ef-0 could not be verified. Reason: Signature expired.", + }, + }), + })).toBe(false); }); }); describe("opaque blob recovery through /v1/responses", () => { + test("#2247 strips reasoning and compaction ciphertext before a pooled thread moves accounts", async () => { + const outbound: Array<{ accountId: string | null; body: Record }> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + outbound.push({ + accountId: headers.get("chatgpt-account-id"), + body: JSON.parse(String(init?.body)) as Record, + }); + return success(`resp-${outbound.length}`); + }) as typeof fetch; + for (const account of [ + { id: "pool-a", accessToken: "shared-test-token-a", chatgptAccountId: "workspace-a" }, + { id: "pool-b", accessToken: "shared-test-token-b", chatgptAccountId: "workspace-b" }, + ]) { + saveCodexAccountCredential(account.id, { + accessToken: account.accessToken, + refreshToken: `${account.id}-refresh-token`, + expiresAt: Date.now() + 300_000, + chatgptAccountId: account.chatgptAccountId, + }); + } + const authSpy = spyOn(authContextModule, "resolveCodexAuthContext") + .mockResolvedValueOnce({ + kind: "pool", + accountId: "pool-a", + writerGeneration: 11, + generation: 1, + accessToken: "shared-test-token-a", + chatgptAccountId: "workspace-a", + }) + .mockResolvedValueOnce({ + kind: "pool", + accountId: "pool-b", + writerGeneration: 12, + generation: 1, + accessToken: "shared-test-token-b", + chatgptAccountId: "workspace-b", + }); + const poolConfig = { + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }, + }, + codexAccounts: [ + { id: "pool-a", email: "a@example.test", chatgptAccountId: "workspace-a", isMain: false }, + { id: "pool-b", email: "b@example.test", chatgptAccountId: "workspace-b", isMain: false }, + ], + } as OcxConfig; + const poolRequest = () => new Request("http://localhost/v1/responses", { + method: "POST", + headers: { + "content-type": "application/json", + "x-codex-parent-thread-id": "thread-2247-pool-switch", + }, + body: JSON.stringify({ + model: "gpt-5.6-sol", + stream: false, + store: false, + input: reasoningReplayInput(), + }), + }); + + try { + for (let turn = 0; turn < 2; turn += 1) { + const response = await handleResponses(poolRequest(), poolConfig, { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + } + expect(authSpy).toHaveBeenCalledTimes(2); + } finally { + authSpy.mockRestore(); + } + + expect(outbound).toHaveLength(2); + expect(outbound.map(entry => entry.accountId)).toEqual(["workspace-a", "workspace-b"]); + expect(hasBlob(outbound[0]!.body)).toBe(true); + const firstInput = outbound[0]!.body.input as Array>; + expect(firstInput[1]?.encrypted_content).toBe(BLOB); + expect(firstInput[2]).toEqual({ type: "compaction", encrypted_content: BLOB }); + expect(hasBlob(outbound[1]!.body)).toBe(false); + const secondInput = outbound[1]!.body.input as Array>; + expect(secondInput[1]).toEqual({ + type: "reasoning", + content: [], + summary: [{ type: "summary_text", text: "prior reasoning" }], + }); + expect(secondInput[2]).toEqual({ + type: "message", + role: "user", + content: [{ type: "input_text", text: OPAQUE_COMPACTION_NOTE }], + }); + }); + + test("#2247 retries the reported ChatGPT unverifiable-ciphertext rejection once", async () => { + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 + ? rejection(CHATGPT_UNVERIFIABLE_BLOB_ERROR) + : success("resp-2247-recovered"); + }) as typeof fetch; + + const response = await handleResponses(request(), config(), { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + + expect(outbound).toHaveLength(2); + expect(hasBlob(outbound[0]!)).toBe(true); + expect(hasBlob(outbound[1]!)).toBe(false); + }); + test("rebuilds once without the rejected blob and preserves surrounding items", async () => { const outbound: Array> = []; globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { diff --git a/tests/thought-signature-credential-scope.test.ts b/tests/thought-signature-credential-scope.test.ts index ae86c25e8f..0d722afb19 100644 --- a/tests/thought-signature-credential-scope.test.ts +++ b/tests/thought-signature-credential-scope.test.ts @@ -86,6 +86,27 @@ describe("#1926 durable credential scope", () => { expect(lookupReplayThoughtSignature("call_w", scopeFor("credential:aaa"))).toBe(SIG); }); + test("Codex pool account identities survive reload without crossing account slots", async () => { + const salt = thoughtSignatureReplaySalt(); + const poolA = durableReplayCredentialIdentity("codex", "pool-a", undefined, salt); + const poolB = durableReplayCredentialIdentity("codex", "pool-b", undefined, salt); + expect(poolA).toBeDefined(); + expect(poolB).toBeDefined(); + expect(poolA).not.toBe(poolB); + + rememberThoughtSignatureForReplay("call_pool", SIG, scopeFor(poolA, "thread-pool")); + await flushThoughtSignatureReplayForTests(); + resetThoughtSignatureReplayForTests(); + + const reloadedSalt = thoughtSignatureReplaySalt(); + const reloadedPoolA = durableReplayCredentialIdentity("codex", "pool-a", undefined, reloadedSalt); + const reloadedPoolB = durableReplayCredentialIdentity("codex", "pool-b", undefined, reloadedSalt); + expect(reloadedPoolA).toBe(poolA); + expect(reloadedPoolB).toBe(poolB); + expect(lookupReplayThoughtSignature("call_pool", scopeFor(reloadedPoolB, "thread-pool"))).toBeUndefined(); + expect(lookupReplayThoughtSignature("call_pool", scopeFor(reloadedPoolA, "thread-pool"))).toBe(SIG); + }); + test("salt is minted once, persisted, and produces stable full-width identities", () => { const salt = thoughtSignatureReplaySalt(); expect(salt).toBeDefined(); @@ -118,4 +139,3 @@ describe("#1926 durable credential scope", () => { expect(snapshot.entries.length).toBe(1); }); }); -