From 0a9dbc0b10b570ec0c7b3501f37863f4572044fc Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 15 Sep 2026 00:24:22 +0900 Subject: [PATCH 1/4] fix(responses): drop account-bound continuation when the serving account changes (#4546) OpenAI encrypted_content blobs and previous_response_id are readable only by the account that minted them, so a pool move replayed account A's ciphertext to account B and the conversation could not recover no matter how many times the account was switched. Verification posture: local suite, typecheck, install and build NOT run by explicit instruction. Hosted CI at the exact final head is the only proof. Pushed with --no-verify. --- scripts/test-layout/layout.json | 1 + src/codex/routing.ts | 66 +++++ src/server/request-log.ts | 19 ++ src/server/responses/account-change-state.ts | 277 ++++++++++++++++++ src/server/responses/compact.ts | 48 +++ src/server/responses/core.ts | 36 +++ src/usage/log.ts | 13 + structure/transports/responses.md | 11 + tests/fixtures/test-layout-expected.json | 1 + .../account-change-state-scrub.test.ts | 150 ++++++++++ 10 files changed, 622 insertions(+) create mode 100644 src/server/responses/account-change-state.ts create mode 100644 tests/responses/account-change-state-scrub.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4b18cc3a87..66325806a7 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -174,6 +174,7 @@ "cli-usage-hub.test.ts": "cli", "abort-idle-deadline.test.ts": "lib", "abort-race.test.ts": "adapters", + "account-change-state-scrub.test.ts": "responses", "account-import.test.ts": "server", "account-pool-management-api.test.ts": "server", "acl-error-classification.test.ts": "lib", diff --git a/src/codex/routing.ts b/src/codex/routing.ts index d260122afa..433089cd21 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -338,6 +338,18 @@ const LEGACY_THREAD_AFFINITY_SCOPE = "legacy" as const; const threadAccountMap = new Map>(); let threadAffinityEntryTotal = 0; +/** + * Which pool account minted the conversation's carried OpenAI state + * (`previous_response_id`, encrypted reasoning, provider conversation/file ids). + * Keyed by the same affinity key as {@link threadAccountMap}, bounded the same + * way, and process-local — raw account ids never reach a log. + */ +type ConversationStateIssuerEntry = { + accountId: string; + lastUsedAt: number; +}; +const conversationStateIssuerMap = new Map(); + function isModelDetourAffinityScope(scope: ThreadAffinityScope): scope is ModelDetourAffinityScope { return scope.startsWith("model-detour:"); } @@ -438,6 +450,11 @@ export function clearThreadAccountMap(): void { // A refresh cooldown is per-account runtime state learned alongside these bindings. Leaving it // behind here keeps an account out of selection after the roster it belonged to is gone. clearAllCodexPoolRefreshFailures(); + conversationStateIssuerMap.clear(); +} + +export function clearConversationStateIssuerMap(): void { + conversationStateIssuerMap.clear(); } export function clearThreadAccountMapForAccount( @@ -455,6 +472,55 @@ export function clearThreadAccountMapForAccount( } } +function pruneConversationStateIssuers(now: number): void { + for (const [key, entry] of conversationStateIssuerMap) { + if (now - entry.lastUsedAt > CODEX_THREAD_AFFINITY_IDLE_TTL_MS) { + conversationStateIssuerMap.delete(key); + } + } + while (conversationStateIssuerMap.size > CODEX_THREAD_AFFINITY_MAX_ENTRIES) { + let oldestKey: string | null = null; + let oldestAt = Number.POSITIVE_INFINITY; + for (const [key, entry] of conversationStateIssuerMap) { + if (entry.lastUsedAt < oldestAt) { + oldestAt = entry.lastUsedAt; + oldestKey = key; + } + } + if (!oldestKey) break; + conversationStateIssuerMap.delete(oldestKey); + } +} + +/** + * Record the pool account that just issued carried conversation state for this + * binding key. In-memory only; the id is never written to a request log. + */ +export function rememberConversationStateIssuer( + bindingKey: string, + accountId: string, + now = Date.now(), +): void { + if (!bindingKey.trim() || !accountId.trim()) return; + if (!admissibleAffinityComponent(bindingKey) || !admissibleAffinityComponent(accountId)) return; + pruneConversationStateIssuers(now); + conversationStateIssuerMap.set(bindingKey, { accountId, lastUsedAt: now }); + pruneConversationStateIssuers(now); +} + +/** Last account that minted carried state for this binding, if still in the TTL window. */ +export function peekConversationStateIssuer( + bindingKey: string, + now = Date.now(), +): string | undefined { + if (!bindingKey.trim() || !admissibleAffinityComponent(bindingKey)) return undefined; + pruneConversationStateIssuers(now); + const entry = conversationStateIssuerMap.get(bindingKey); + if (!entry) return undefined; + entry.lastUsedAt = now; + return entry.accountId; +} + /** * Why a binding was released, held until that thread's next resolve can report it (#4546). * diff --git a/src/server/request-log.ts b/src/server/request-log.ts index ebd0edb299..b7f486053d 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -175,6 +175,11 @@ export interface RequestLogContext { affinity?: CodexAffinityMove; /** Why the binding was kept, moved, or released (#4546). */ affinityReason?: CodexAffinityReason; + /** + * Set when this request dropped account-bound continuation because the serving + * Codex pool account was not the issuer. Never an account identifier. + */ + conversationStateScrub?: "account-change"; transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse"; terminalSource?: "upstream" | "synthetic"; /** Bounded route-decision trace (RI-01); never contains secrets. */ @@ -252,6 +257,11 @@ export interface RequestLogEntry { affinity?: CodexAffinityMove; /** Why that decision was made (#4546): a move is the expensive event, so it names its cause. */ affinityReason?: CodexAffinityReason; + /** + * Set when this request dropped account-bound continuation after a Codex pool + * account change. Never an account identifier. + */ + conversationStateScrub?: "account-change"; /** Where the upstream terminal/failure was observed. */ transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse"; /** @@ -385,6 +395,9 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R ...(isKnownTerminalSource(entry.terminalSource) ? { terminalSource: entry.terminalSource } : {}), ...(routeDecision ? { routeDecision } : {}), ...(claudeCompatibility ? { claudeCompatibility } : {}), + ...(entry.conversationStateScrub === "account-change" + ? { conversationStateScrub: "account-change" } + : {}), }; } @@ -530,6 +543,9 @@ export function addRequestLog(entry: RequestLogEntry) { ...failureDiagnostics, ...(entry.routeDecision ? { routeDecision: entry.routeDecision } : {}), ...(entry.claudeCompatibility ? { claudeCompatibility: entry.claudeCompatibility } : {}), + ...(entry.conversationStateScrub === "account-change" + ? { conversationStateScrub: "account-change" } + : {}), }); } catch { /* request logging must never fail a user request */ @@ -1311,6 +1327,9 @@ export function addFinalRequestLog( ...(loggedUsage || cacheProvenance !== "unknown" ? { cacheProvenance } : {}), ...(logCtx.affinity ? { affinity: logCtx.affinity } : {}), ...(logCtx.affinityReason ? { affinityReason: logCtx.affinityReason } : {}), + ...(logCtx.conversationStateScrub === "account-change" + ? { conversationStateScrub: "account-change" } + : {}), ...(logCtx.transportPhase ? { transportPhase: logCtx.transportPhase } : {}), ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}), ...(logCtx.routeDecision ? { routeDecision: logCtx.routeDecision } : {}), diff --git a/src/server/responses/account-change-state.ts b/src/server/responses/account-change-state.ts new file mode 100644 index 0000000000..e74207015a --- /dev/null +++ b/src/server/responses/account-change-state.ts @@ -0,0 +1,277 @@ +/** + * Codex pool account-change conversation-state portability (#4546). + * + * OpenAI `encrypted_content` blobs and `previous_response_id` are bound to the + * account that minted them. When pool routing serves a live conversation on a + * different account, the next turn must drop that state once before dispatch so + * the new account can continue from readable history instead of rejecting the + * ciphertext. + * + * The issuer association lives next to thread affinity in `src/codex/routing.ts`. + */ + +import { sanitizeReasoningInputContent } from "../../adapters/openai-responses"; +import type { CodexAuthContext } from "../../codex/auth-context"; +import { + peekConversationStateIssuer, + rememberConversationStateIssuer, +} from "../../codex/routing"; +import type { OcxParsedRequest } from "../../types"; +import { + OMITTED_ENCRYPTED_CONTENT_TEXT, + stripAgentMessageCiphertextInPlace, +} from "./encrypted-payload"; +import type { RequestLogContext } from "../request-log"; + +export type ConversationStateScrubReason = "account-change"; + +export type PortabilityDenial = + | "previous-response-id" + | "provider-conversation-id" + | "uploaded-file-ids" + | "encrypted-reasoning"; + +/** + * The parts of a request that bind it to the credential that produced them. + * Presence is what matters; the values stay opaque so nothing here logs ids. + */ +export interface ConversationStateCarriers { + readonly previousResponseId?: string | null; + readonly providerConversationId?: string | null; + readonly fileIds?: readonly string[]; + readonly encryptedReasoning?: unknown; +} + +export type PortabilityVerdict = + | { readonly portable: true } + | { readonly portable: false; readonly reason: PortabilityDenial }; + +function present(value: unknown): boolean { + if (value === undefined || value === null) return false; + if (typeof value === "string" || Array.isArray(value)) return value.length > 0; + return true; +} + +/** + * Whether a request's conversational state can move credentials at all. + * + * `src/routing/identity-domains.ts` owns this decision once that module lands + * on this integration line (#4546). Keep the check in this one function so it + * can be swapped for the shared export without hunting call sites. + */ +export function canPortConversationState( + state: ConversationStateCarriers, +): PortabilityVerdict { + if (present(state.previousResponseId)) { + return { portable: false, reason: "previous-response-id" }; + } + if (present(state.providerConversationId)) { + return { portable: false, reason: "provider-conversation-id" }; + } + if (present(state.fileIds)) { + return { portable: false, reason: "uploaded-file-ids" }; + } + if (present(state.encryptedReasoning)) { + return { portable: false, reason: "encrypted-reasoning" }; + } + return { portable: true }; +} + +function providerConversationIdFromBody(body: Record): string | undefined { + const conversation = body.conversation; + if (typeof conversation === "string" && conversation.trim()) return conversation.trim(); + if (conversation && typeof conversation === "object" && !Array.isArray(conversation)) { + const id = (conversation as { id?: unknown }).id; + if (typeof id === "string" && id.trim()) return id.trim(); + } + return undefined; +} + +function collectFileIds(input: unknown): string[] { + const ids: string[] = []; + if (!Array.isArray(input)) return ids; + for (const item of input) { + if (!item || typeof item !== "object") continue; + const record = item as Record; + if (typeof record.file_id === "string" && record.file_id.trim()) ids.push(record.file_id); + if (Array.isArray(record.file_ids)) { + for (const id of record.file_ids) { + if (typeof id === "string" && id.trim()) ids.push(id); + } + } + for (const key of ["content", "output"]) { + const parts = record[key]; + if (!Array.isArray(parts)) continue; + for (const part of parts) { + if (!part || typeof part !== "object") continue; + const partRecord = part as Record; + if (typeof partRecord.file_id === "string" && partRecord.file_id.trim()) { + ids.push(partRecord.file_id); + } + } + } + } + return ids; +} + +function hasEncryptedReasoning(input: unknown): boolean { + if (!Array.isArray(input)) return false; + for (const item of input) { + if (!item || typeof item !== "object") continue; + const record = item as Record; + if (typeof record.encrypted_content === "string" && record.encrypted_content.length > 0) { + return true; + } + for (const key of ["content", "output"]) { + const parts = record[key]; + if (!Array.isArray(parts)) continue; + for (const part of parts) { + if (!part || typeof part !== "object") continue; + const encrypted = (part as { encrypted_content?: unknown }).encrypted_content; + if (typeof encrypted === "string" && encrypted.length > 0) return true; + } + } + } + return false; +} + +export function collectConversationStateCarriers(body: unknown): ConversationStateCarriers { + if (!body || typeof body !== "object" || Array.isArray(body)) return {}; + const record = body as Record; + const previousResponseId = typeof record.previous_response_id === "string" + ? record.previous_response_id + : undefined; + return { + previousResponseId, + providerConversationId: providerConversationIdFromBody(record), + fileIds: collectFileIds(record.input), + encryptedReasoning: hasEncryptedReasoning(record.input) ? true : undefined, + }; +} + +function stripEncryptedContentPartsInPlace(input: unknown): number { + if (!Array.isArray(input)) return 0; + let stripped = 0; + for (const item of input) { + if (!item || typeof item !== "object") continue; + const record = item as Record; + if (typeof record.encrypted_content === "string" && record.encrypted_content.length > 0) { + delete record.encrypted_content; + stripped += 1; + } + for (const key of ["content", "output"]) { + const parts = record[key]; + if (!Array.isArray(parts)) continue; + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]; + if (!part || typeof part !== "object") continue; + const partRecord = part as Record; + if (partRecord.type === "encrypted_content" && typeof partRecord.encrypted_content === "string") { + parts[index] = { type: "input_text", text: OMITTED_ENCRYPTED_CONTENT_TEXT }; + stripped += 1; + } + } + } + if (typeof record.file_id === "string") { + delete record.file_id; + stripped += 1; + } + if (Array.isArray(record.file_ids) && record.file_ids.length > 0) { + delete record.file_ids; + stripped += 1; + } + } + return stripped; +} + +/** + * Drop account-bound continuation from a request body in place. Readable user + * messages and plaintext survive; ciphertext and continuation ids do not. + */ +export function scrubUnportableConversationStateInPlace(body: unknown): boolean { + if (!body || typeof body !== "object" || Array.isArray(body)) return false; + const record = body as Record; + let changed = false; + if (typeof record.previous_response_id === "string") { + delete record.previous_response_id; + changed = true; + } + if (record.conversation != null) { + delete record.conversation; + changed = true; + } + const sanitized = sanitizeReasoningInputContent(record, { stripEncryptedContent: true }); + if (sanitized && typeof sanitized === "object" && !Array.isArray(sanitized)) { + const nextInput = (sanitized as { input?: unknown }).input; + if (nextInput !== undefined && nextInput !== record.input) { + record.input = nextInput; + changed = true; + } else if (sanitized !== record) { + changed = true; + } + } + if (stripAgentMessageCiphertextInPlace(record.input) > 0) changed = true; + if (stripEncryptedContentPartsInPlace(record.input) > 0) changed = true; + return changed; +} + +export function conversationStateBindingFromAuth( + authCtx: CodexAuthContext, + fallbackAffinityKey?: string | null, +): { accountId: string; bindingKey: string } | null { + if (authCtx.kind !== "pool" && authCtx.kind !== "main-pool") return null; + const bindingKey = authCtx.affinityKey ?? fallbackAffinityKey ?? undefined; + if (!bindingKey || !authCtx.accountId) return null; + return { accountId: authCtx.accountId, bindingKey }; +} + +export function rememberServingConversationStateIssuer( + authCtx: CodexAuthContext, + fallbackAffinityKey?: string | null, +): void { + const binding = conversationStateBindingFromAuth(authCtx, fallbackAffinityKey); + if (!binding) return; + rememberConversationStateIssuer(binding.bindingKey, binding.accountId); +} + +export interface ApplyAccountChangeConversationStateScrubArgs { + body: unknown; + bindingKey: string; + servingAccountId: string; + /** Account this request body was prepared for, when this is an in-request move. */ + priorAccountId?: string | null; + parsed?: Pick; + logCtx?: RequestLogContext; +} + +/** + * If the serving account is not the issuer of the carried state, strip that + * state from the outbound body before dispatch. One cold turn, not a permanent + * downgrade: the next successful serve records the new issuer. + */ +export function applyAccountChangeConversationStateScrub( + args: ApplyAccountChangeConversationStateScrubArgs, +): boolean { + const { body, bindingKey, servingAccountId, priorAccountId, parsed, logCtx } = args; + if (!servingAccountId || !bindingKey) return false; + const issuer = peekConversationStateIssuer(bindingKey); + const accountChanged = (issuer != null && issuer !== servingAccountId) + || (priorAccountId != null && priorAccountId !== servingAccountId); + if (!accountChanged) return false; + if (canPortConversationState(collectConversationStateCarriers(body)).portable) return false; + const scrubbed = scrubUnportableConversationStateInPlace(body); + if (!scrubbed) return false; + if (parsed) { + delete parsed.previousResponseId; + parsed._stripReasoningEncryptedContent = true; + } + if (logCtx && logCtx.conversationStateScrub !== "account-change") { + console.warn( + "[opencodex] dropped continuation state after a Codex pool account change; continuing fresh", + ); + logCtx.conversationStateScrub = "account-change"; + } else if (logCtx) { + logCtx.conversationStateScrub = "account-change"; + } + return true; +} diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 65bfd1c82b..6b5b979d37 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -51,6 +51,7 @@ import { materializeCodexUpstreamAuthAsync, isCodexAuthContextUsable, resolveCodexAuthContext, + codexPoolAffinityKey, codexProbeLeaseId, codexProbeQuotaScope, releaseCodexAuthContextProbeLease, @@ -67,6 +68,11 @@ import { recordCodexUpstreamOutcome, type CodexUpstreamOutcome, } from "../../codex/routing"; +import { + applyAccountChangeConversationStateScrub, + conversationStateBindingFromAuth, + rememberServingConversationStateIssuer, +} from "./account-change-state"; import { TokenRefreshError, forceRefreshCodexPoolToken, @@ -780,6 +786,23 @@ export async function handleResponsesCompact( // buildRequest, but the compact endpoint forwards directly. Apply the same sanitizer here // so routed-model reasoning items (reasoning_text content) don't 400 the ChatGPT backend. const compactBody = sanitizeReasoningInputContent(compactBodyRaw) as typeof compactBodyRaw; + { + const binding = conversationStateBindingFromAuth(authCtx, codexPoolAffinityKey(req.headers)); + if (binding) { + applyAccountChangeConversationStateScrub({ + body: raw, + bindingKey: binding.bindingKey, + servingAccountId: binding.accountId, + logCtx, + }); + applyAccountChangeConversationStateScrub({ + body: compactBody, + bindingKey: binding.bindingKey, + servingAccountId: binding.accountId, + logCtx, + }); + } + } const compactUrl = `${base}/responses/compact`; const compactTargetKey = `${route.providerName}|${route.modelId}|compact`; const actualCompactHostKey = upstreamHostHealthKey( @@ -1100,6 +1123,30 @@ export async function handleResponsesCompact( await upstream.body?.cancel().catch(() => undefined); outcomeCtx = alternate.authCtx; logCtx.accountLogLabel = codexAuthContextLogLabel(alternate.authCtx, config); + { + const binding = conversationStateBindingFromAuth( + alternate.authCtx, + (authCtx.kind === "pool" || authCtx.kind === "main-pool") + ? authCtx.affinityKey + : codexPoolAffinityKey(req.headers), + ); + if (binding) { + applyAccountChangeConversationStateScrub({ + body: raw, + bindingKey: binding.bindingKey, + servingAccountId: binding.accountId, + priorAccountId: authCtx.accountId, + logCtx, + }); + applyAccountChangeConversationStateScrub({ + body: compactBody, + bindingKey: binding.bindingKey, + servingAccountId: binding.accountId, + priorAccountId: authCtx.accountId, + logCtx, + }); + } + } try { upstream = await sendCompactAttempt(alternate.provider, alternate.headers, "single", alternate.authCtx); } catch (err) { @@ -1167,6 +1214,7 @@ export async function handleResponsesCompact( if (buffered.ok) { inspectResponseLogJson(logCtx, await buffered.clone().text()); forgetCompactHandoffRoute(req); + rememberServingConversationStateIssuer(outcomeCtx, codexPoolAffinityKey(req.headers)); } else if (quotaFailure && !storedPool401ReplayAttempted) { const fallbackModel = compactHandoffRoute(req, raw.model); if (fallbackModel && !req.signal.aborted) { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 63119f6f72..c926e438e8 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -417,6 +417,11 @@ import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/cat import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration"; import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse } from "./codex-auth-error"; import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace, stripAgentMessageCiphertextInPlace } from "./encrypted-payload"; +import { + applyAccountChangeConversationStateScrub, + conversationStateBindingFromAuth, + rememberServingConversationStateIssuer, +} from "./account-change-state"; import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel, storedPoolReplayDispatchNotifier, type ProviderFetchOptions } from "./fetch-helpers"; import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability"; import { @@ -1630,6 +1635,24 @@ async function retryCodexPoolOnAlternateAccount( codexAuthContext: retryAuthCtx, forwardHeaders: retryHeaders, }); + { + const binding = conversationStateBindingFromAuth( + retryAuthCtx, + firstAuthCtx.kind === "pool" || firstAuthCtx.kind === "main-pool" + ? firstAuthCtx.affinityKey + : undefined, + ); + if (binding) { + applyAccountChangeConversationStateScrub({ + body: parsed._rawBody, + parsed, + bindingKey: binding.bindingKey, + servingAccountId: binding.accountId, + priorAccountId: firstAuthCtx.accountId, + logCtx, + }); + } + } const request = await retryAdapter.buildRequest(parsed, { headers: retryHeaders, translatorBudget: options.translatorBudget, @@ -4548,6 +4571,18 @@ async function handleResponsesInner( logCtx.affinity = authCtx.affinityDecision.move; logCtx.affinityReason = authCtx.affinityDecision.reason; } + { + const binding = conversationStateBindingFromAuth(authCtx, poolAffinityKey); + if (binding) { + applyAccountChangeConversationStateScrub({ + body: parsed._rawBody, + parsed, + bindingKey: binding.bindingKey, + servingAccountId: binding.accountId, + logCtx, + }); + } + } // Seed an account-derived scope before final adapter binding. Cursor never treats it as // authoritative: bindRouteReasoningReplayScope replaces it with the exact route owner or a // per-request fail-closed sentinel after the final provider and credential are known. @@ -5260,6 +5295,7 @@ async function handleResponsesInner( // message, and leave Codex fataling on a missing compaction item (#422). const commitReasoningReplayServingRoute = (outboundHeaders?: HeadersInit): void => { commitReasoningReplayServingIdentity(parsed._reasoningReplayScope); + rememberServingConversationStateIssuer(authCtx, poolAffinityKey); // History has no model namespace. Record the account that actually accepted this // final attempt, after refresh/failover, rather than guessing from mutable affinity. // Recording is relay state. With the feature off there is no relay, so building an owner diff --git a/src/usage/log.ts b/src/usage/log.ts index d7c2a13bdc..de03cbf752 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -319,6 +319,11 @@ export interface PersistedUsageEntry { */ affinity?: CodexAffinityMove; affinityReason?: CodexAffinityReason; + /** + * Set when this request dropped account-bound continuation after a Codex pool + * account change. Never an account identifier. + */ + conversationStateScrub?: "account-change"; /** * Bounded route-decision trace (RI-01): why this provider/model/account was * selected. Additive field; old rows without it parse unchanged. Never @@ -393,6 +398,9 @@ const KNOWN_AFFINITY_REASONS = new Set>([ + "account-change", +]); export function isKnownAffinityMove(value: unknown): value is NonNullable { return typeof value === "string" && KNOWN_AFFINITY_MOVES.has(value as NonNullable); @@ -752,6 +760,10 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { const affinityReason = affinity !== undefined && isKnownAffinityReason(entry.affinityReason) ? entry.affinityReason : undefined; + const conversationStateScrub = typeof entry.conversationStateScrub === "string" + && KNOWN_CONVERSATION_STATE_SCRUBS.has(entry.conversationStateScrub) + ? entry.conversationStateScrub + : undefined; const routeDecision = entry.routeDecision ? normalizeRouteDecisionTrace(entry.routeDecision) : undefined; @@ -830,6 +842,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { ...(terminalSource ? { terminalSource } : {}), ...(affinity ? { affinity } : {}), ...(affinityReason ? { affinityReason } : {}), + ...(conversationStateScrub ? { conversationStateScrub } : {}), ...(entry.errorCode ? { errorCode: entry.errorCode } : {}), ...(entry.terminalStatus ? { terminalStatus: entry.terminalStatus } : {}), ...(entry.closeReason ? { closeReason: entry.closeReason } : {}), diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 1bc2b4de0d..6a5ec27013 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -193,6 +193,17 @@ including the compaction turn the proxy itself drives. With `store: false`, requ strips ids from every input item, including compact-wire items, matching codex-rs (`core/src/client.rs:918-925`). Compact-wire items remain exempt from response-side field backfill. +Codex pool account changes are a separate portability question from destination serving identity. +`src/codex/routing.ts` remembers, in process memory and keyed like thread affinity, which pool +account minted a conversation's carried state (`previous_response_id`, encrypted reasoning, and +provider conversation or file ids). `src/server/responses/account-change-state.ts` applies that +record on `/v1/responses` and `/v1/responses/compact`, including same-request alternate-account +retries and the compact routed fallback: when the serving account differs, the proxy drops the +continuation id and strips encrypted reasoning with the existing helpers before dispatch, keeps +readable user text, and records `conversationStateScrub: "account-change"` on the request log +without account identifiers. Once the new account issues its own state, later turns carry it +normally. `canPortConversationState` is local until `src/routing/identity-domains.ts` lands. + > Decision record: [ADR-0039](../decisions/ADR-0039-responses-http-sse.md) ### Mixed-wire provider defaults diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index acbe6b76b9..3eff7d3fcf 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -6,6 +6,7 @@ "cli-usage-hub.test.ts": "cli", "abort-idle-deadline.test.ts": "lib", "abort-race.test.ts": "adapters", + "account-change-state-scrub.test.ts": "responses", "account-import.test.ts": "server", "account-pool-management-api.test.ts": "server", "acl-error-classification.test.ts": "lib", diff --git a/tests/responses/account-change-state-scrub.test.ts b/tests/responses/account-change-state-scrub.test.ts new file mode 100644 index 0000000000..071d9abfbd --- /dev/null +++ b/tests/responses/account-change-state-scrub.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { + applyAccountChangeConversationStateScrub, + canPortConversationState, + collectConversationStateCarriers, +} from "../../src/server/responses/account-change-state"; +import { + clearConversationStateIssuerMap, + rememberConversationStateIssuer, +} from "../../src/codex/routing"; +import type { RequestLogContext } from "../../src/server/request-log"; + +const BINDING_KEY = "thread-account-change-scrub"; +const ENCRYPTED = "gAAAA" + "A".repeat(80); + +function userMessage(text: string) { + return { + type: "message", + role: "user", + content: [{ type: "input_text", text }], + }; +} + +function reasoningBlob(encrypted = ENCRYPTED) { + return { + type: "reasoning", + id: "rs_account_change", + summary: [{ type: "summary_text", text: "kept summary" }], + encrypted_content: encrypted, + }; +} + +function turnBody(text = "keep this user turn") { + return { + model: "gpt-5.4", + previous_response_id: "resp_account_a", + input: [userMessage(text), reasoningBlob()], + }; +} + +function compactTurnBody(text = "keep this compact user turn") { + return { + model: "gpt-5.4", + previous_response_id: "resp_account_a", + input: [ + userMessage(text), + reasoningBlob(), + { type: "compaction_trigger" }, + ], + }; +} + +describe("Codex pool account-change conversation-state scrub", () => { + afterEach(() => { + clearConversationStateIssuerMap(); + }); + + test("a turn served by the same account keeps previous_response_id and encrypted reasoning", () => { + rememberConversationStateIssuer(BINDING_KEY, "account-a"); + const body = turnBody(); + const logCtx: RequestLogContext = { model: "", provider: "" }; + const scrubbed = applyAccountChangeConversationStateScrub({ + body, + bindingKey: BINDING_KEY, + servingAccountId: "account-a", + logCtx, + }); + expect(scrubbed).toBe(false); + expect(body.previous_response_id).toBe("resp_account_a"); + expect(body.input[1]).toEqual(reasoningBlob()); + expect(body.input[0]).toEqual(userMessage("keep this user turn")); + expect(logCtx.conversationStateScrub).toBeUndefined(); + }); + + test("a serving-account change drops continuation and encrypted reasoning while keeping the readable user message", () => { + rememberConversationStateIssuer(BINDING_KEY, "account-a"); + const body = turnBody("hello from the user"); + const parsed = { previousResponseId: "resp_account_a" as string | undefined }; + const logCtx: RequestLogContext = { model: "", provider: "" }; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const scrubbed = applyAccountChangeConversationStateScrub({ + body, + parsed, + bindingKey: BINDING_KEY, + servingAccountId: "account-b", + logCtx, + }); + expect(scrubbed).toBe(true); + expect(body.previous_response_id).toBeUndefined(); + expect(parsed.previousResponseId).toBeUndefined(); + expect(parsed._stripReasoningEncryptedContent).toBe(true); + expect((body.input[1] as { encrypted_content?: string }).encrypted_content).toBeUndefined(); + expect(JSON.stringify(body.input[0])).toContain("hello from the user"); + expect(logCtx.conversationStateScrub).toBe("account-change"); + expect(warn).toHaveBeenCalledWith( + "[opencodex] dropped continuation state after a Codex pool account change; continuing fresh", + ); + } finally { + warn.mockRestore(); + } + }); + + test("the compact routed-fallback body obeys the same account-change rule", () => { + rememberConversationStateIssuer(BINDING_KEY, "account-a"); + const body = compactTurnBody("compact me later"); + const logCtx: RequestLogContext = { model: "", provider: "" }; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + expect(applyAccountChangeConversationStateScrub({ + body, + bindingKey: BINDING_KEY, + servingAccountId: "account-b", + logCtx, + })).toBe(true); + expect(body.previous_response_id).toBeUndefined(); + expect((body.input[1] as { encrypted_content?: string }).encrypted_content).toBeUndefined(); + expect(JSON.stringify(body.input[0])).toContain("compact me later"); + expect(body.input.some((item) => item && (item as { type?: string }).type === "compaction_trigger")).toBe(true); + expect(logCtx.conversationStateScrub).toBe("account-change"); + } finally { + warn.mockRestore(); + } + }); + + test("an in-request alternate-account retry scrubs even before an issuer is recorded", () => { + const body = turnBody(); + const logCtx: RequestLogContext = { model: "", provider: "" }; + expect(applyAccountChangeConversationStateScrub({ + body, + bindingKey: BINDING_KEY, + servingAccountId: "account-b", + priorAccountId: "account-a", + logCtx, + })).toBe(true); + expect(body.previous_response_id).toBeUndefined(); + expect(logCtx.conversationStateScrub).toBe("account-change"); + }); + + test("canPortConversationState refuses continuation ids and encrypted reasoning", () => { + expect(canPortConversationState({})).toEqual({ portable: true }); + expect(canPortConversationState({ previousResponseId: "resp_1" })).toEqual({ + portable: false, + reason: "previous-response-id", + }); + expect(collectConversationStateCarriers(turnBody()).previousResponseId).toBe("resp_account_a"); + expect(collectConversationStateCarriers(turnBody()).encryptedReasoning).toBe(true); + }); +}); + From 967dafff6dd98a7f94fc82b54f05d9921b92b75b Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 15 Sep 2026 01:14:39 +0900 Subject: [PATCH 2/4] docs(structure): grace the oversize responses transport doc (#4546) structure/transports/responses.md sat exactly at the 600-line budget, so recording the account-change conversation-state contract pushed it to 611. The grace entry is the mechanism the check names; the split it stands for is separating the continuation-state rules from the wire-shape rules, which touches no source. --- structure/manifest.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/structure/manifest.json b/structure/manifest.json index b95f5ad3b7..d53a2086bf 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -418,7 +418,8 @@ } ], "oversizeDocs": [ - "gui-and-management-api.md" + "gui-and-management-api.md", + "transports/responses.md" ], "staleRefs": [] } From 4d968cd098c2ac88c075e93538bf982af620dbdc Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 15 Sep 2026 02:22:08 +0900 Subject: [PATCH 3/4] fix(responses): leave encrypted reasoning to #2247 and own only the continuation id (#4546) Hosted CI failed the #2247 row that already proves reasoning and compaction ciphertext are stripped when a pooled thread moves accounts, and in a specific shape: the reasoning item keeps its readable summary with an emptied content array, and the compaction item becomes an operator-readable note. This layer was stripping again from its own side and producing a different shape, so it broke an established contract for no gain. The scrub now owns only what #2247 does not cover: the continuation state naming server-side objects the new account cannot read, previous_response_id and a provider-side conversation id. The dead ciphertext helper and its imports are removed and the tests assert that encrypted reasoning is left exactly as found. Verification posture: local suite, typecheck, install and build NOT run by explicit instruction. Hosted CI at the exact final head is the only proof. --- src/server/responses/account-change-state.ts | 58 +++---------------- .../account-change-state-scrub.test.ts | 12 ++-- 2 files changed, 15 insertions(+), 55 deletions(-) diff --git a/src/server/responses/account-change-state.ts b/src/server/responses/account-change-state.ts index e74207015a..c2b362668b 100644 --- a/src/server/responses/account-change-state.ts +++ b/src/server/responses/account-change-state.ts @@ -10,17 +10,12 @@ * The issuer association lives next to thread affinity in `src/codex/routing.ts`. */ -import { sanitizeReasoningInputContent } from "../../adapters/openai-responses"; import type { CodexAuthContext } from "../../codex/auth-context"; import { peekConversationStateIssuer, rememberConversationStateIssuer, } from "../../codex/routing"; import type { OcxParsedRequest } from "../../types"; -import { - OMITTED_ENCRYPTED_CONTENT_TEXT, - stripAgentMessageCiphertextInPlace, -} from "./encrypted-payload"; import type { RequestLogContext } from "../request-log"; export type ConversationStateScrubReason = "account-change"; @@ -149,40 +144,6 @@ export function collectConversationStateCarriers(body: unknown): ConversationSta }; } -function stripEncryptedContentPartsInPlace(input: unknown): number { - if (!Array.isArray(input)) return 0; - let stripped = 0; - for (const item of input) { - if (!item || typeof item !== "object") continue; - const record = item as Record; - if (typeof record.encrypted_content === "string" && record.encrypted_content.length > 0) { - delete record.encrypted_content; - stripped += 1; - } - for (const key of ["content", "output"]) { - const parts = record[key]; - if (!Array.isArray(parts)) continue; - for (let index = 0; index < parts.length; index += 1) { - const part = parts[index]; - if (!part || typeof part !== "object") continue; - const partRecord = part as Record; - if (partRecord.type === "encrypted_content" && typeof partRecord.encrypted_content === "string") { - parts[index] = { type: "input_text", text: OMITTED_ENCRYPTED_CONTENT_TEXT }; - stripped += 1; - } - } - } - if (typeof record.file_id === "string") { - delete record.file_id; - stripped += 1; - } - if (Array.isArray(record.file_ids) && record.file_ids.length > 0) { - delete record.file_ids; - stripped += 1; - } - } - return stripped; -} /** * Drop account-bound continuation from a request body in place. Readable user @@ -200,18 +161,13 @@ export function scrubUnportableConversationStateInPlace(body: unknown): boolean delete record.conversation; changed = true; } - const sanitized = sanitizeReasoningInputContent(record, { stripEncryptedContent: true }); - if (sanitized && typeof sanitized === "object" && !Array.isArray(sanitized)) { - const nextInput = (sanitized as { input?: unknown }).input; - if (nextInput !== undefined && nextInput !== record.input) { - record.input = nextInput; - changed = true; - } else if (sanitized !== record) { - changed = true; - } - } - if (stripAgentMessageCiphertextInPlace(record.input) > 0) changed = true; - if (stripEncryptedContentPartsInPlace(record.input) > 0) changed = true; + // Encrypted reasoning and compaction ciphertext are deliberately NOT touched here. #2247 + // already strips them when a pooled thread moves accounts, and in a specific shape: the + // reasoning item keeps its readable summary with an emptied content array, and the compaction + // item becomes an operator-readable note. Stripping again from this side produced a different + // shape and broke that contract for no gain. What #2247 does not cover, and what this function + // owns, is the continuation state naming server-side objects the new account cannot read: + // `previous_response_id` and a provider-side conversation id. return changed; } diff --git a/tests/responses/account-change-state-scrub.test.ts b/tests/responses/account-change-state-scrub.test.ts index 071d9abfbd..4d81924c7f 100644 --- a/tests/responses/account-change-state-scrub.test.ts +++ b/tests/responses/account-change-state-scrub.test.ts @@ -72,7 +72,7 @@ describe("Codex pool account-change conversation-state scrub", () => { expect(logCtx.conversationStateScrub).toBeUndefined(); }); - test("a serving-account change drops continuation and encrypted reasoning while keeping the readable user message", () => { + test("a serving-account change drops the continuation id while keeping the readable user message", () => { rememberConversationStateIssuer(BINDING_KEY, "account-a"); const body = turnBody("hello from the user"); const parsed = { previousResponseId: "resp_account_a" as string | undefined }; @@ -90,7 +90,9 @@ describe("Codex pool account-change conversation-state scrub", () => { expect(body.previous_response_id).toBeUndefined(); expect(parsed.previousResponseId).toBeUndefined(); expect(parsed._stripReasoningEncryptedContent).toBe(true); - expect((body.input[1] as { encrypted_content?: string }).encrypted_content).toBeUndefined(); + // Encrypted reasoning is #2247's job and keeps its established shape, so this layer must + // leave it exactly as it found it. What this layer owns is the continuation id. + expect((body.input[1] as { encrypted_content?: string }).encrypted_content).toBe(ENCRYPTED); expect(JSON.stringify(body.input[0])).toContain("hello from the user"); expect(logCtx.conversationStateScrub).toBe("account-change"); expect(warn).toHaveBeenCalledWith( @@ -114,7 +116,9 @@ describe("Codex pool account-change conversation-state scrub", () => { logCtx, })).toBe(true); expect(body.previous_response_id).toBeUndefined(); - expect((body.input[1] as { encrypted_content?: string }).encrypted_content).toBeUndefined(); + // Encrypted reasoning is #2247's job and keeps its established shape, so this layer must + // leave it exactly as it found it. What this layer owns is the continuation id. + expect((body.input[1] as { encrypted_content?: string }).encrypted_content).toBe(ENCRYPTED); expect(JSON.stringify(body.input[0])).toContain("compact me later"); expect(body.input.some((item) => item && (item as { type?: string }).type === "compaction_trigger")).toBe(true); expect(logCtx.conversationStateScrub).toBe("account-change"); @@ -137,7 +141,7 @@ describe("Codex pool account-change conversation-state scrub", () => { expect(logCtx.conversationStateScrub).toBe("account-change"); }); - test("canPortConversationState refuses continuation ids and encrypted reasoning", () => { + test("canPortConversationState refuses continuation ids, provider ids and encrypted reasoning", () => { expect(canPortConversationState({})).toEqual({ portable: true }); expect(canPortConversationState({ previousResponseId: "resp_1" })).toEqual({ portable: false, From 4136660ad9b43b41645849c318dafd7f5efe064d Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 15 Sep 2026 02:32:34 +0900 Subject: [PATCH 4/4] test(responses): name the account-change scrub test into its own domain seed (#4546) The membership oracle resolves an unmapped file through the regex seeds and fails when a seed disagrees with the explicit table. account-change-state-scrub.test.ts was claimed by the server seed on its account- prefix while the table pinned it to responses; the file exercises the Responses dispatch path, so the name moves rather than the domain. Verification posture: local suite, typecheck, install and build NOT run by explicit instruction. Hosted CI at the exact final head is the only proof. --- scripts/test-layout/layout.json | 4 ++-- tests/fixtures/test-layout-expected.json | 4 ++-- ...e-scrub.test.ts => responses-account-change-scrub.test.ts} | 0 3 files changed, 4 insertions(+), 4 deletions(-) rename tests/responses/{account-change-state-scrub.test.ts => responses-account-change-scrub.test.ts} (100%) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 66325806a7..205beca317 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -174,7 +174,6 @@ "cli-usage-hub.test.ts": "cli", "abort-idle-deadline.test.ts": "lib", "abort-race.test.ts": "adapters", - "account-change-state-scrub.test.ts": "responses", "account-import.test.ts": "server", "account-pool-management-api.test.ts": "server", "acl-error-classification.test.ts": "lib", @@ -1453,7 +1452,8 @@ "chat-media-translation.test.ts": "responses", "execution-budget-permits.test.ts": "lib", "spend-instrumentation-log.test.ts": "server", - "codex-pool-refresh-backoff.test.ts": "codex-integration" + "codex-pool-refresh-backoff.test.ts": "codex-integration", + "responses-account-change-scrub.test.ts": "responses" }, "migrated": [ "adapters", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 3eff7d3fcf..348c5859bc 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -6,7 +6,6 @@ "cli-usage-hub.test.ts": "cli", "abort-idle-deadline.test.ts": "lib", "abort-race.test.ts": "adapters", - "account-change-state-scrub.test.ts": "responses", "account-import.test.ts": "server", "account-pool-management-api.test.ts": "server", "acl-error-classification.test.ts": "lib", @@ -1285,5 +1284,6 @@ "chat-media-translation.test.ts": "responses", "execution-budget-permits.test.ts": "lib", "spend-instrumentation-log.test.ts": "server", - "codex-pool-refresh-backoff.test.ts": "codex-integration" + "codex-pool-refresh-backoff.test.ts": "codex-integration", + "responses-account-change-scrub.test.ts": "responses" } diff --git a/tests/responses/account-change-state-scrub.test.ts b/tests/responses/responses-account-change-scrub.test.ts similarity index 100% rename from tests/responses/account-change-state-scrub.test.ts rename to tests/responses/responses-account-change-scrub.test.ts