diff --git a/src/oauth/index.ts b/src/oauth/index.ts index afc14a6621..b4f578ac0d 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -24,7 +24,7 @@ import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, i import { logOAuthEvent } from "./log"; import { captureConfigGeneration, sweepExpiredOnWrite, type GenerationContext } from "../lib/state-store-sweeper"; import { retainedUtf8Bytes } from "../lib/admission"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; export { CODEX_HEALTH_AUTH_FAILED_NOTE, CODEX_HEALTH_MANAGEMENT_API_UNAVAILABLE_NOTE, @@ -52,6 +52,8 @@ import { codexAccountNamespaceProviderCollisionError } from "../codex/account-na const REFRESH_SKEW_MS = 60_000; export interface OAuthAccessSnapshot { provider: string; + /** Stable pseudonymous subject for account-bound continuation; absent without an immutable account id. */ + credentialSubjectHash?: string; accountId: string; generation: string; accessToken: string; @@ -299,6 +301,13 @@ export class OAuthLoginRequiredError extends Error { } function accessSnapshot(provider: string, accountId: string, cred: OAuthCredentials): OAuthAccessSnapshot { + // Email is not an immutable upstream subject: multiple accounts may share or later reuse it. + // Fail closed unless the provider supplied its stable account id. Preserve the exact stored + // subject because the credential store also matches it exactly; trimming only here would make + // two distinct slots share one continuation owner. + const stableCredentialSubject = cred.accountId?.trim() + ? ["account-id", cred.accountId] + : undefined; const storedKiroRouting = { ...(cred.kiro?.profileArn ? { profileArn: cred.kiro.profileArn } : {}), ...(cred.kiro?.apiRegion ? { apiRegion: cred.kiro.apiRegion } : {}), @@ -306,6 +315,16 @@ function accessSnapshot(provider: string, accountId: string, cred: OAuthCredenti }; return { provider, + ...(stableCredentialSubject + ? { + credentialSubjectHash: createHash("sha256") + .update("opencodex-oauth-credential-subject\0") + .update(provider) + .update("\0") + .update(JSON.stringify(stableCredentialSubject)) + .digest("hex"), + } + : {}), accountId, generation: credentialGeneration(cred), accessToken: cred.access, diff --git a/src/responses/provider-continuation.ts b/src/responses/provider-continuation.ts new file mode 100644 index 0000000000..32a607e0e3 --- /dev/null +++ b/src/responses/provider-continuation.ts @@ -0,0 +1,22 @@ +import type { OcxProviderContinuationOwner } from "../types"; + +const bounded = (value: unknown, max: number): value is string => + typeof value === "string" && value.length > 0 && value.length <= max; + +/** Validate proxy-authored continuation ownership before trusting persisted state. */ +export function isValidProviderContinuationOwner( + value: unknown, +): value is OcxProviderContinuationOwner { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const owner = value as Record; + return owner.version === 1 + && bounded(owner.providerName, 256) + && typeof owner.providerDestinationIdentity === "string" + && /^destination:[0-9a-f]{64}$/.test(owner.providerDestinationIdentity) + && bounded(owner.adapterName, 128) + && bounded(owner.modelId, 512) + && typeof owner.credentialIdentity === "string" + && /^(key|oauth|codex|oauth-account|forward-account):[0-9a-f]{64}$/.test( + owner.credentialIdentity, + ); +} diff --git a/src/responses/reasoning-replay-cache.ts b/src/responses/reasoning-replay-cache.ts index f09930475c..e982933196 100644 --- a/src/responses/reasoning-replay-cache.ts +++ b/src/responses/reasoning-replay-cache.ts @@ -101,6 +101,13 @@ function credentialHeaderOverrides(headers: Record | undefined): )); } +/** True when provider headers change the physical credential/account boundary. */ +export function reasoningReplayHasCredentialHeaderOverrides( + headers: Record | undefined, +): boolean { + return credentialHeaderOverrides(headers).length > 0; +} + /** Produce a non-reversible process-local identity for an exact upstream destination. */ export function reasoningReplayDestinationIdentity(baseUrl: string | undefined): string | undefined { if (!nonEmpty(baseUrl)) return undefined; diff --git a/src/responses/spill-store.ts b/src/responses/spill-store.ts index dbf5502018..c762551c40 100644 --- a/src/responses/spill-store.ts +++ b/src/responses/spill-store.ts @@ -19,6 +19,7 @@ import { join } from "node:path"; import { getConfigDir } from "../config"; import { forgetEphemeralSecretPath, forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl"; import type { OcxProviderContinuationState } from "../types"; +import { isValidProviderContinuationOwner } from "./provider-continuation"; export const RESPONSE_SPILL_VERSION = 1; export const RESPONSE_SPILL_DIR_NAME = "responses-state-spill"; @@ -287,7 +288,11 @@ function validPayload(value: unknown, responseId: string): value is ResponseSpil } if (payload.providers !== undefined) { if (!payload.providers || typeof payload.providers !== "object" || Array.isArray(payload.providers)) return false; - for (const providerState of Object.values(payload.providers)) { + const providers = payload.providers as Record; + if (providers.__ocxOwner !== undefined + && !isValidProviderContinuationOwner(providers.__ocxOwner)) return false; + for (const [provider, providerState] of Object.entries(providers)) { + if (provider === "__ocxOwner") continue; if (!providerState || typeof providerState !== "object" || Array.isArray(providerState)) return false; } } diff --git a/src/responses/state.ts b/src/responses/state.ts index a08ed1012e..6a12f293ea 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -1059,6 +1059,17 @@ export function previousResponseReplayPrefixLength(body: unknown): number { return replayedInputPrefixLengths.get(body) ?? 0; } +/** Copy proxy-private replay provenance to an internal clone with the same materialized input. */ +export function copyPreviousResponseReplayProvenance(source: unknown, target: unknown): void { + if (!source || typeof source !== "object" || Array.isArray(source)) return; + if (!target || typeof target !== "object" || Array.isArray(target)) return; + const prefixLength = replayedInputPrefixLengths.get(source); + if (!prefixLength) return; + const input = (target as { input?: unknown }).input; + if (!Array.isArray(input) || prefixLength > input.length) return; + replayedInputPrefixLengths.set(target, prefixLength); +} + /** True when a stale or foreign previous_response_id was removed from this exact request body. */ export function previousResponseScopeMismatch(body: unknown): boolean { return !!body && typeof body === "object" && replayScopeMismatches.has(body as object); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 2df5160984..8361f65a92 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1,4 +1,5 @@ import type { Server } from "bun"; +import { createHash } from "node:crypto"; import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge"; import { formatPassthroughUpstreamError } from "./passthrough-error"; import { checkInputAdmission } from "./input-admission"; @@ -12,14 +13,18 @@ import { import { parseRequest } from "../../responses/parser"; import { bindReasoningReplayScope, + reasoningReplayCredentialIdentity, reasoningReplayCodexCredentialIdentity, reasoningReplayDestinationIdentity, + reasoningReplayHasCredentialHeaderOverrides, reasoningReplayKeyCredentialIdentity, reasoningReplayOAuthCredentialIdentity, } from "../../responses/reasoning-replay-cache"; +import { isValidProviderContinuationOwner } from "../../responses/provider-continuation"; import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction"; import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses"; import { + copyPreviousResponseReplayProvenance, expandPreviousResponseInput, markBodyNonPersistable, previousResponseProviderState, @@ -56,7 +61,7 @@ import { injectionDebugLog } from "../../lib/injection-debug-log"; import { resolveClientRetryAfter } from "../../lib/retry-after"; import { enrichOpenCodeZenRateLimitMessage } from "../../providers/opencode-zen-rate-limit"; import { modelInList, namespacedToolName } from "../../types"; -import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types"; +import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationOwner, OcxProviderContinuationState, OcxUsage } from "../../types"; import { forceRefreshOAuthAccessSnapshot, getOAuthCredentialApiBaseUrl, @@ -81,6 +86,7 @@ import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenA import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images"; import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventQueue } from "../../adapters/run-turn-queue"; +import { resolveCursorToken } from "../../adapters/cursor/live-transport"; import { applyCodexAuthContextToProvider, CodexAccountCooldownError, @@ -292,12 +298,135 @@ export function codexLogAccountId(authCtx: CodexAuthContext): string | null { return authCtx.kind === "pool" || authCtx.kind === "main-pool" ? authCtx.accountId : null; } +function continuationIdentity(domain: string, material: string): string { + return createHash("sha256").update(domain).update("\0").update(material).digest("hex"); +} + +function providerContinuationOwnerForRoute(args: { + parsed: OcxParsedRequest; + providerName: string; + provider: OcxProviderConfig; + adapterName: string; + oauthCredentialSnapshot?: Pick; + codexAuthContext?: CodexAuthContext; + forwardHeaders?: Headers; +}): OcxProviderContinuationOwner | undefined { + const { parsed, providerName, provider, adapterName } = args; + const authMode = provider.authMode ?? "key"; + let credentialIdentity: string | undefined; + if (authMode === "oauth") { + const credentialSubjectHash = args.oauthCredentialSnapshot?.credentialSubjectHash?.trim(); + if (!credentialSubjectHash) return undefined; + const accountMaterial = JSON.stringify([providerName, credentialSubjectHash]); + // Credential-bearing header overrides are part of the physical identity. Keep them behind + // the existing process-local HMAC. Without overrides, only a verified credential subject is + // restart-stable across ordinary access-token refreshes; a reusable local slot is not identity. + credentialIdentity = reasoningReplayHasCredentialHeaderOverrides(provider.headers) + ? reasoningReplayCredentialIdentity("oauth", accountMaterial, provider.headers) + : `oauth-account:${continuationIdentity("provider-continuation-oauth-account", accountMaterial)}`; + } else if (authMode === "forward") { + const poolContext = args.codexAuthContext?.kind === "pool" + || args.codexAuthContext?.kind === "main-pool" + ? args.codexAuthContext + : undefined; + // Direct-forward headers are caller controlled and expose no verified stable subject. + // Pool contexts are internal selections: bind both the local slot and real ChatGPT account, + // so __main__ profile replacement cannot retain another account's continuation. + if (!poolContext?.accountId?.trim() || !poolContext.chatgptAccountId?.trim()) return undefined; + const accountMaterial = JSON.stringify([poolContext.accountId.trim(), poolContext.chatgptAccountId.trim()]); + credentialIdentity = reasoningReplayHasCredentialHeaderOverrides(provider.headers) + ? reasoningReplayCredentialIdentity("codex", accountMaterial, provider.headers) + : `forward-account:${continuationIdentity("provider-continuation-forward-account", accountMaterial)}`; + } else if (authMode === "local") { + return undefined; + } else { + // Reuse the existing process-local keyed HMAC. It permits safe same-process continuation, + // but intentionally fails closed after restart instead of persisting a secret verifier. + credentialIdentity = reasoningReplayKeyCredentialIdentity(provider); + if (!credentialIdentity) return undefined; + } + + if (!credentialIdentity) return undefined; + const canonicalBaseUrl = provider.baseUrl.trim().replace(/\/+$/, ""); + if (!canonicalBaseUrl) return undefined; + const kiroContext = parsed._kiroAuthContext; + const destinationMaterial = JSON.stringify([ + canonicalBaseUrl, + provider.responsesPath ?? "", + kiroContext?.profileArn ?? "", + kiroContext?.apiRegion ?? "", + kiroContext?.ssoRegion ?? "", + ]); + return { + version: 1, + providerName, + providerDestinationIdentity: `destination:${continuationIdentity("provider-continuation-destination", destinationMaterial)}`, + adapterName, + modelId: parsed.modelId, + credentialIdentity, + }; +} + +type ContinuationOwnerRead = + | { kind: "missing" } + | { kind: "invalid" } + | { kind: "valid"; owner: OcxProviderContinuationOwner }; + +function readProviderContinuationOwner(state: OcxProviderContinuationState | undefined): ContinuationOwnerRead { + if (!state || state.__ocxOwner === undefined) return { kind: "missing" }; + const owner = state.__ocxOwner; + if (!isValidProviderContinuationOwner(owner)) return { kind: "invalid" }; + return { kind: "valid", owner: { ...owner } }; +} + +function providerContinuationPayload( + state: OcxProviderContinuationState | undefined, +): OcxProviderContinuationState | undefined { + if (!state) return undefined; + const cloned = structuredClone(state); + delete cloned.__ocxOwner; + // Owner metadata alone is not provider continuation. Keep the restored payload absent instead + // of activating an empty object when an immutable snapshot captured only the ownership fence. + return Object.keys(cloned).length > 0 ? cloned : undefined; +} + +function sameProviderContinuationOwner( + left: OcxProviderContinuationOwner, + right: OcxProviderContinuationOwner, +): boolean { + return left.version === right.version + && left.providerName === right.providerName + && left.providerDestinationIdentity === right.providerDestinationIdentity + && left.adapterName === right.adapterName + && left.modelId === right.modelId + && left.credentialIdentity === right.credentialIdentity; +} + +function bindProviderContinuationForRoute( + parsed: OcxParsedRequest, + currentOwner: OcxProviderContinuationOwner | undefined, +): void { + const candidate = parsed._providerContinuationCandidate; + const storedOwner = readProviderContinuationOwner(candidate); + const mayRestore = storedOwner.kind === "valid" + && !!currentOwner + && sameProviderContinuationOwner(storedOwner.owner, currentOwner); + const restored = mayRestore ? providerContinuationPayload(candidate) : undefined; + if (restored) parsed._providerContinuation = restored; + else delete parsed._providerContinuation; + const cursorConversationId = restored?.cursor?.conversationId; + if (cursorConversationId) parsed._cursorConversationId = cursorConversationId; + else delete parsed._cursorConversationId; + if (currentOwner) parsed._providerContinuationOwner = { ...currentOwner }; + else delete parsed._providerContinuationOwner; +} + function bindRouteReasoningReplayScope(args: { parsed: OcxParsedRequest; providerName: string; provider: OcxProviderConfig; adapterName: string; - oauthCredentialSnapshot?: Pick; + oauthCredentialSnapshot?: Pick; codexAuthContext?: CodexAuthContext; forwardHeaders?: Headers; }): void { @@ -330,6 +459,46 @@ function bindRouteReasoningReplayScope(args: { credentialIdentity = reasoningReplayKeyCredentialIdentity(provider); } const providerDestinationIdentity = reasoningReplayDestinationIdentity(provider.baseUrl); + const continuationOwner = providerContinuationOwnerForRoute(args); + if (adapterName === "cursor") { + let cursorCredentialIdentity = continuationOwner?.credentialIdentity ?? credentialIdentity; + if (!cursorCredentialIdentity) { + try { + cursorCredentialIdentity = `cursor-token:${continuationIdentity( + "cursor-route-token", + resolveCursorToken(provider, args.forwardHeaders), + )}`; + } catch { + // Keep the adapter's existing missing-token failure path. In particular, do not + // collapse ownerless local routes into a shared deterministic conversation scope. + delete parsed._cursorIdentityScope; + } + } + // Cursor derives a stable conversation id from the client thread. Namespace that derivation + // by the final physical route owner, not merely the token, so rejecting a stored continuation + // cannot recreate the same provider-private id for another destination/model/account. + if (cursorCredentialIdentity) { + const owner = continuationOwner ?? { + version: 1, + providerName, + providerDestinationIdentity: providerDestinationIdentity ?? "unbound-destination", + adapterName, + modelId: parsed.modelId, + credentialIdentity: cursorCredentialIdentity, + } satisfies OcxProviderContinuationOwner; + parsed._cursorIdentityScope = `cursor-route:${continuationIdentity( + "cursor-route-owner", + JSON.stringify([ + owner.version, + owner.providerName, + owner.providerDestinationIdentity, + owner.adapterName, + owner.modelId, + owner.credentialIdentity, + ]), + )}`; + } + } bindReasoningReplayScope( parsed._reasoningReplayScope, credentialIdentity && providerDestinationIdentity @@ -342,6 +511,7 @@ function bindRouteReasoningReplayScope(args: { } : undefined, ); + bindProviderContinuationForRoute(parsed, continuationOwner); } function isFixedCodexAccount(authCtx: CodexAuthContext): boolean { @@ -807,6 +977,12 @@ export interface HandleResponsesOptions { stripClaudeMainAuthForNoncanonicalForward?: boolean; /** Internal recursion guard; callers outside this module must not set it. */ comboAttempt?: boolean; + /** Internal combo handoff for one parent-validated continuation snapshot. */ + comboReplaySnapshot?: { + sourceBody: unknown; + previousResponseInputExpanded: boolean; + providerContinuation: OcxProviderContinuationState | undefined; + }; /** Internal combo handoff: allow a later same-provider model after a reset-derived 429/402. */ deferCodexResetDerivedCooldown?: boolean; /** 030-owned handoff when a child consumed the original failure under bounds. */ @@ -1263,7 +1439,12 @@ export async function handleComboResponses( // Expand previous_response_id before image policy and child dispatch so a // continuation that only references prior images still fails closed when // imageInput is disabled (and so targets see the full replayed input). - const body = expandPreviousResponseInput(rawBody); + const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined; + const body = expandPreviousResponseInput(rawBody, inboundClientThreadId); + const scopeMismatch = previousResponseScopeMismatch(body); + if (scopeMismatch) { + console.warn("[opencodex] dropped a previous_response_id with a mismatched client task scope; continuing fresh"); + } if (previousResponseReplayFailure(body)) { return formatErrorResponse( 400, @@ -1290,11 +1471,14 @@ export async function handleComboResponses( if (combo.imageInput === "disabled" && comboRequestHasImageInput(body)) { return formatErrorResponse(400, "invalid_request_error", `Combo "${comboId}" does not accept image input`); } - // Expansion already materialised prior input. Drop the id so the child - // handleResponses path does not expand again and double-prepend history. - if (body !== rawBody && body && typeof body === "object" && !Array.isArray(body)) { - delete (body as Record).previous_response_id; - } + const comboReplaySnapshot = { + sourceBody: body, + previousResponseInputExpanded: body !== rawBody + && typeof (body as { previous_response_id?: unknown }).previous_response_id === "string", + providerContinuation: !scopeMismatch && body !== rawBody && requestedPreviousId + ? previousResponseProviderState(requestedPreviousId) + : undefined, + }; const adoptFailedChildLog = (childLog: RequestLogContext): void => { // Attempts remain the complete physical history; the logical row mirrors the most recent // failed target so an exhausted combo still has useful top-level reasoning diagnostics. @@ -1401,6 +1585,7 @@ export async function handleComboResponses( response = await handleResponses(childRequest, config, childLog, { ...options, comboAttempt: true, + comboReplaySnapshot, deferCodexResetDerivedCooldown, // Attempt-relative TTFT is recorded HERE (not via childLog.firstOutputMs — a later // Object.assign(logCtx, childLog) would overwrite the request-relative value). @@ -1656,19 +1841,24 @@ async function handleResponsesInner( ); const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined; const originalBody = body; - body = expandPreviousResponseInput(body, inboundClientThreadId); - if (previousResponseScopeMismatch(body)) { - console.warn("[opencodex] dropped a previous_response_id with a mismatched client task scope; continuing fresh"); - } - if (previousResponseReplayFailure(body)) { - return formatErrorResponse( - 400, - "previous_response_not_found", - "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.", - ); + if (options.comboReplaySnapshot) { + copyPreviousResponseReplayProvenance(options.comboReplaySnapshot.sourceBody, body); + } else { + body = expandPreviousResponseInput(body, inboundClientThreadId); + if (previousResponseScopeMismatch(body)) { + console.warn("[opencodex] dropped a previous_response_id with a mismatched client task scope; continuing fresh"); + } + if (previousResponseReplayFailure(body)) { + return formatErrorResponse( + 400, + "previous_response_not_found", + "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.", + ); + } } - const previousResponseInputExpanded = body !== originalBody - && typeof (body as { previous_response_id?: unknown }).previous_response_id === "string"; + const previousResponseInputExpanded = options.comboReplaySnapshot?.previousResponseInputExpanded + ?? (body !== originalBody + && typeof (body as { previous_response_id?: unknown }).previous_response_id === "string"); // Spawn-message compatibility (both directions): agent_message task payloads ride in // encrypted_content slots as plaintext. Rewrite them to input_text on the RAW body BEFORE @@ -1691,8 +1881,10 @@ async function handleResponsesInner( parsed = parseRequest(body); toolBridgeMaps = buildToolBridgeMaps(parsed, translatorBudget); if (previousResponseInputExpanded) parsed._previousResponseInputExpanded = true; - parsed._providerContinuation = previousResponseProviderState(parsed.previousResponseId); - parsed._cursorConversationId = parsed._providerContinuation?.cursor?.conversationId; + const providerContinuationCandidate = options.comboReplaySnapshot + ? options.comboReplaySnapshot.providerContinuation + : previousResponseProviderState(parsed.previousResponseId); + if (providerContinuationCandidate) parsed._providerContinuationCandidate = providerContinuationCandidate; if (inboundClientThreadId) { parsed._clientThreadId = inboundClientThreadId; parsed._reasoningReplayScope = { clientThreadId: inboundClientThreadId }; @@ -1883,6 +2075,8 @@ async function handleResponsesInner( const kept: Array = [ "_previousResponseInputExpanded", "_providerContinuation", + "_providerContinuationCandidate", + "_providerContinuationOwner", "_cursorConversationId", "_clientThreadId", "_reasoningReplayScope", @@ -2048,7 +2242,10 @@ async function handleResponsesInner( const isOAuth401ReplayProvider = (route.providerName === "xai" || route.providerName === "github-copilot" || route.providerName === "kiro") && route.provider.authMode === "oauth"; let sentOAuthSnapshot: OAuthAccessSnapshot | undefined; - let replayOAuthCredentialSnapshot: Pick | undefined; + let replayOAuthCredentialSnapshot: Pick< + OAuthAccessSnapshot, + "accountId" | "generation" | "credentialSubjectHash" + > | undefined; let anthropicPoolAccountId: string | null = null; let anthropicPoolFailovers = 0; const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth" @@ -2087,6 +2284,9 @@ async function handleResponsesInner( replayOAuthCredentialSnapshot = { accountId: resolved.accountId, generation: resolved.generation, + ...(resolved.credentialSubjectHash + ? { credentialSubjectHash: resolved.credentialSubjectHash } + : {}), }; if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved; route.provider = { ...route.provider, apiKey: resolved.accessToken }; @@ -2149,6 +2349,9 @@ async function handleResponsesInner( codexAuthContext: authCtx, forwardHeaders: selectedForwardHeaders, }); + if (!logCtx.conversationId && parsed._cursorConversationId) { + logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId); + } logCtx.providerAdapter = adapter.name; // Ordinary requests receive one durable attempt only after their final initial // adapter is resolved. Combo children own their attempt and retries keep it. @@ -2246,24 +2449,22 @@ async function handleResponsesInner( emitted?: OcxProviderContinuationState, ): OcxProviderContinuationState | undefined => { const cursorConversationId = parsed._cursorConversationId; - const inherited = parsed._providerContinuation; - if (!emitted && !inherited && !cursorConversationId) return undefined; - return { - ...(inherited ?? {}), - ...(emitted ?? {}), - ...((inherited?.kiro || emitted?.kiro) - ? { kiro: { ...(inherited?.kiro ?? {}), ...(emitted?.kiro ?? {}) } } - : {}), - ...(cursorConversationId - ? { - cursor: { - ...(inherited?.cursor ?? {}), - ...(emitted?.cursor ?? {}), - conversationId: cursorConversationId, - }, - } - : {}), - }; + const inherited = providerContinuationPayload(parsed._providerContinuation); + const emittedPayload = providerContinuationPayload(emitted); + if (!emittedPayload && !inherited && !cursorConversationId) return undefined; + const merged: OcxProviderContinuationState = { ...(inherited ?? {}) }; + for (const [provider, value] of Object.entries(emittedPayload ?? {})) { + const prior = merged[provider]; + merged[provider] = prior && value + ? { ...prior, ...value } + : value; + } + if (cursorConversationId) { + merged.cursor = { ...(merged.cursor ?? {}), conversationId: cursorConversationId }; + } + return parsed._providerContinuationOwner + ? { ...merged, __ocxOwner: { ...parsed._providerContinuationOwner } } + : merged; }; // Remote compaction v2 on a ROUTED model: Codex sent `compaction_trigger` and requires exactly @@ -3713,6 +3914,9 @@ async function handleResponsesInner( replayOAuthCredentialSnapshot = { accountId: refreshed.accountId, generation: refreshed.generation, + ...(refreshed.credentialSubjectHash + ? { credentialSubjectHash: refreshed.credentialSubjectHash } + : {}), }; if (route.providerName === "kiro") { parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) }; @@ -4100,6 +4304,14 @@ async function handleResponsesInner( resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), config.cacheRetention, ); + bindRouteReasoningReplayScope({ + parsed: nextParsed, + providerName: route.providerName, + provider: route.provider, + adapterName: activeAdapter.name, + }); + // Response persistence closes over the outer parsed request; keep its owner binding in + // sync with the terminal-guard clone that will build the rotated continuation request. bindRouteReasoningReplayScope({ parsed, providerName: route.providerName, diff --git a/src/types.ts b/src/types.ts index 24c8faa6aa..e927961226 100644 --- a/src/types.ts +++ b/src/types.ts @@ -66,6 +66,10 @@ export interface OcxParsedRequest { _kiroAuthContext?: Pick; /** Provider-private continuation metadata resolved from the Responses previous_response_id chain. */ _providerContinuation?: OcxProviderContinuationState; + /** Stored continuation candidate, activated only after the physical route owner is known. */ + _providerContinuationCandidate?: OcxProviderContinuationState; + /** Current physical owner attached to newly persisted provider continuation state. */ + _providerContinuationOwner?: OcxProviderContinuationOwner; /** * The hosted `{type:"web_search", ...}` tool config, stashed when Codex enables web search. Routed * (non-OpenAI) providers can't run it server-side, so the proxy re-exposes it as a function tool and @@ -318,11 +322,24 @@ export interface OcxRequestOptions { export type OcxMessagePhase = "commentary" | "final_answer"; +/** Durable, non-secret owner of provider-private continuation state. */ +export interface OcxProviderContinuationOwner { + [field: string]: string | number; + version: 1; + providerName: string; + providerDestinationIdentity: string; + adapterName: string; + modelId: string; + credentialIdentity: string; +} + /** * Provider-private state that must follow a locally expanded `previous_response_id` chain. * Kept out of public Responses output and persisted only in the bounded local continuation cache. */ export interface OcxProviderContinuationState { + /** Proxy-authored owner metadata; stripped before provider adapters receive the state. */ + __ocxOwner?: OcxProviderContinuationOwner; cursor?: { conversationId?: string; checkpointUsable?: boolean; diff --git a/tests/responses-state.test.ts b/tests/responses-state.test.ts index 3084c44858..47c07c8f62 100644 --- a/tests/responses-state.test.ts +++ b/tests/responses-state.test.ts @@ -773,6 +773,36 @@ describe("Responses previous_response_id state", () => { expect(expanded.input.at(-1)).toMatchObject({ type: "function_call_output", call_id: "call_1" }); }); + test("validates reserved continuation ownership separately from provider spill state", () => { + const owner = { + version: 1 as const, + providerName: "kiro", + providerDestinationIdentity: `destination:${"a".repeat(64)}`, + adapterName: "kiro", + modelId: "gpt-5.6-sol", + credentialIdentity: `oauth-account:${"b".repeat(64)}`, + }; + const valid = writeResponseSpillDurably("resp_valid_spill_owner", { + createdAt: Date.now(), + items: ["valid"], + providers: { __ocxOwner: owner, kiro: { conversationId: "kiro-valid" } }, + }); + expect(readResponseSpill("resp_valid_spill_owner", valid).ok).toBe(true); + + const invalid = writeResponseSpillDurably("resp_invalid_spill_owner", { + createdAt: Date.now(), + items: ["invalid"], + providers: { + __ocxOwner: { ...owner, version: 2 }, + kiro: { conversationId: "must-not-load" }, + } as never, + }); + expect(readResponseSpill("resp_invalid_spill_owner", invalid)).toEqual({ + ok: false, + reason: "corrupt", + }); + }); + test("replays a durable spill after simulated process restart", async () => { setResponseStateByteCapForTests(1_024); rememberResponseState( @@ -1647,6 +1677,14 @@ describe("Responses previous_response_id state", () => { { model: "kiro/gpt-5.6-sol", input: "hello" }, first, { + __ocxOwner: { + version: 1, + providerName: "kiro", + providerDestinationIdentity: `destination:${"a".repeat(64)}`, + adapterName: "kiro", + modelId: "gpt-5.6-sol", + credentialIdentity: `oauth-account:${"b".repeat(64)}`, + }, cursor: { conversationId: "cursor_conv_2" }, kiro: { conversationId: "kiro_conv_2" }, }, @@ -1655,6 +1693,14 @@ describe("Responses previous_response_id state", () => { clearResponseStateMemoryForTests(); expect(previousResponseProviderState(first.id as string)).toEqual({ + __ocxOwner: { + version: 1, + providerName: "kiro", + providerDestinationIdentity: `destination:${"a".repeat(64)}`, + adapterName: "kiro", + modelId: "gpt-5.6-sol", + credentialIdentity: `oauth-account:${"b".repeat(64)}`, + }, cursor: { conversationId: "cursor_conv_2", checkpointUsable: true }, kiro: { conversationId: "kiro_conv_2" }, }); diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index ca46ada550..dd9e2443c5 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, mock, setDefaultTimeout, test } from "bun:test"; import { logsFromApiBody } from "./helpers/logs-api"; import { managementFetch as fetch, ManagementRequest as Request } from "./helpers/management-auth"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -9,13 +9,13 @@ import { clearComboTargetCooldowns, isComboTargetInCooldown, } from "../src/combos"; -import { readConfigDiagnostics, saveConfig } from "../src/config"; +import { getConfigDir, readConfigDiagnostics, saveConfig } from "../src/config"; import type { ProviderAdapter } from "../src/adapters/base"; import { handleManagementAPI } from "../src/server/management-api"; -import { saveCredential } from "../src/oauth/store"; +import { getAccountSet, saveCredential } from "../src/oauth/store"; import { XAI_OAUTH_DISCOVERY_URL } from "../src/oauth/xai"; import { XAI_GROK_CLI_BASE_URL } from "../src/providers/xai-transport"; -import type { AdapterEvent, OcxConfig, OcxProviderConfig } from "../src/types"; +import type { AdapterEvent, OcxConfig, OcxProviderConfig, OcxProviderContinuationState } from "../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; import { clearRequestLogsForTests, hydrateRequestLogsFromDisk, type RequestLogContext } from "../src/server/request-log"; import { responseWithDeferredRequestLog } from "../src/server/relay"; @@ -29,6 +29,13 @@ import { import { startServer } from "../src/server"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; +import { removeTreeWithRetry } from "./helpers/remove-tree"; +import { + clearResponseStateForTests, + flushResponseState, + responseStatePersistPendingForTests, +} from "../src/responses/state"; +import { clearCursorThreadContinuityForTests } from "../src/adapters/cursor/thread-continuity"; // Full-suite Windows load: startServer + combo rename/delete management flows exceed the // default 5s per-test budget (same flake class as 810fa115 / claude-management-api). @@ -54,9 +61,16 @@ mock.module("../src/server/adapter-resolve", () => ({ // tests can drive the genuine continuation/persistence policy without a live socket. return createCursorAdapter(provider, { createTransport: customCursorTransportFactory }); } - if (provider.adapter === "test-run-turn") { + if ( + provider.adapter === "test-run-turn" + || provider.adapter === "test-kiro" + || provider.adapter === "test-owned" + || provider.note === "test-kiro-oauth" + ) { const adapter: ProviderAdapter = { - name: "test-run-turn", + name: provider.adapter === "test-kiro" || provider.note === "test-kiro-oauth" + ? "kiro" + : provider.adapter, buildRequest: () => ({ url: provider.baseUrl, method: "POST", headers: {}, body: "" }), async *parseStream(): AsyncGenerator { yield { type: "error", message: "test runTurn adapter does not use parseStream" }; @@ -132,19 +146,25 @@ beforeEach(() => { customUsageEstimate = undefined; customCursorTransportFactory = undefined; clearRequestLogsForTests(); + clearResponseStateForTests(); + clearCursorThreadContinuityForTests(); }); afterEach(async () => { + for (const server of servers.splice(0)) await server.stop(true); + await flushResponseState(); + expect(responseStatePersistPendingForTests()).toBe(false); + clearResponseStateForTests(); + clearCursorThreadContinuityForTests(); globalThis.fetch = originalFetch; Date.now = originalNow; - for (const server of servers.splice(0)) await server.stop(true); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousCursorToken === undefined) delete process.env.OPENCODEX_CURSOR_TEST_TOKEN; else process.env.OPENCODEX_CURSOR_TEST_TOKEN = previousCursorToken; isolatedCodexHome?.restore(); isolatedCodexHome = null; - if (testDir) rmSync(testDir, { recursive: true, force: true }); + if (testDir) removeTreeWithRetry(testDir); clearComboSelectionState(); clearComboTargetCooldowns(); clearCodexUpstreamHealth(); @@ -1396,13 +1416,702 @@ describe("server combo failover 030 activation matrix", () => { expect(response.status).toBe(200); expect(bodies).toHaveLength(1); const child = bodies[0]!; - // Parent already expanded; child must not keep previous_response_id (would double-prepend). + // The child expands the local continuation exactly once and the Chat wire omits the local id. expect(child.previous_response_id).toBeUndefined(); const inputText = JSON.stringify(child.input ?? child.messages ?? child); expect(inputText.split("earlier text")).toHaveLength(2); expect(inputText.split("next turn")).toHaveLength(2); }); + test("combo continuation expansion respects the client task scope", async () => { + const { rememberResponseState } = await import("../src/responses/state"); + rememberResponseState( + { model: "combo/free", input: "legacy private history" }, + { + id: "resp_combo_legacy_unscoped", + status: "completed", + output: [{ type: "message", role: "assistant", content: "legacy reply" }], + }, + ); + rememberResponseState( + { model: "combo/free", input: "scoped private history" }, + { + id: "resp_combo_scoped", + status: "completed", + output: [{ type: "message", role: "assistant", content: "scoped reply" }], + }, + undefined, + { clientThreadId: "combo-task" }, + ); + const bodies: Array> = []; + const a = serve(async request => { + bodies.push(await request.json() as Record); + return chatSuccess("continued", "m1"); + }); + const config = comboConfig({ a: provider("openai-chat", baseUrl(a), "key-a") }); + const headers = { "x-codex-parent-thread-id": "combo-task" }; + + const legacyResponse = await post(config, { + previous_response_id: "resp_combo_legacy_unscoped", + input: "fresh scoped input", + }, {}, headers); + const scopedResponse = await post(config, { + previous_response_id: "resp_combo_scoped", + input: "continue scoped task", + }, {}, headers); + + expect(legacyResponse.status).toBe(200); + expect(scopedResponse.status).toBe(200); + expect(bodies).toHaveLength(2); + expect(JSON.stringify(bodies[0])).not.toContain("legacy private history"); + expect(JSON.stringify(bodies[0])).toContain("fresh scoped input"); + expect(JSON.stringify(bodies[1])).toContain("scoped private history"); + expect(JSON.stringify(bodies[1])).toContain("continue scoped task"); + }); + + test("combo child preserves replay provenance for compaction and generated guidance", async () => { + const { rememberResponseState } = await import("../src/responses/state"); + const { multiAgentGuidanceText, PROACTIVE_MULTI_AGENT_MODE_TEXT } = await import("../src/server/responses/collaboration"); + const guidance = `${PROACTIVE_MULTI_AGENT_MODE_TEXT}`; + const tools = ["spawn_agent", "send_input"].map(name => ({ + type: "function", + name, + namespace: "multi_agent_v1", + description: "Collaborate on work", + parameters: { type: "object", properties: {} }, + })); + rememberResponseState( + { + model: "combo/free", + input: [ + { type: "context_compaction" }, + { + type: "message", + role: "developer", + content: [{ type: "input_text", text: guidance }], + }, + { type: "message", role: "user", content: "prior task" }, + ], + reasoning: { effort: "max" }, + tools, + }, + { + id: "resp_combo_replay_provenance", + status: "completed", + output: [{ + id: "msg_combo_replay_provenance", + type: "message", + role: "assistant", + content: "prior answer", + }], + }, + undefined, + { clientThreadId: "combo-provenance-task" }, + ); + + let observed: { + replayPrefixLength: number; + contextCompactionBoundary: boolean | undefined; + generatedGuidance: string | null; + taggedGuidance: string[]; + } | undefined; + const guidanceOptions = { multiAgentGuidanceEnabled: true }; + const config = comboConfig({ + a: provider("test-run-turn", "https://a.test/v1", "key-a"), + }); + Object.assign(config, guidanceOptions); + customRunTurn = async (parsed, _incoming, emit) => { + const rawInput = (parsed._rawBody as { input?: unknown[] } | undefined)?.input ?? []; + const taggedGuidance = rawInput.flatMap(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) return []; + const record = item as Record; + if (record.type !== "message" || record.role !== "developer" || !Array.isArray(record.content)) return []; + return record.content.flatMap(part => !!part && typeof part === "object" + && !Array.isArray(part) + && (part as Record).type === "input_text" + && typeof (part as Record).text === "string" + && ((part as Record).text as string).startsWith("") + && ((part as Record).text as string).endsWith("") + ? [(part as Record).text as string] + : []); + }); + observed = { + replayPrefixLength: parsed._replayPrefixLen ?? 0, + contextCompactionBoundary: parsed._contextCompactionBoundary, + generatedGuidance: await multiAgentGuidanceText(parsed, guidanceOptions), + taggedGuidance, + }; + emit({ type: "text_delta", text: "continued" }); + emit({ type: "done" }); + }; + + const response = await post(config, { + previous_response_id: "resp_combo_replay_provenance", + input: [{ type: "message", role: "user", content: "current turn" }], + reasoning: { effort: "max" }, + tools, + }, {}, { "x-codex-parent-thread-id": "combo-provenance-task" }); + + expect(response.status).toBe(200); + expect(observed).toEqual({ + replayPrefixLength: expect.any(Number), + contextCompactionBoundary: undefined, + generatedGuidance: guidance, + taggedGuidance: [guidance], + }); + expect(observed!.replayPrefixLength).toBeGreaterThan(0); + }); + + test("combo failover dispatches the one parent-validated continuation snapshot", async () => { + const { clearResponseStateForTests, rememberResponseState } = await import("../src/responses/state"); + rememberResponseState( + { model: "combo/free", input: [{ role: "user", content: "stable prior history" }] }, + { + id: "resp_combo_stable_snapshot", + status: "completed", + output: [{ type: "message", role: "assistant", content: "stable prior answer" }], + }, + ); + const a = serve(() => { + clearResponseStateForTests(); + return Response.json({ error: { message: "retry" } }, { status: 503 }); + }); + let backupParsed: { + previousResponseId?: string; + replayPrefixLength: number; + rawInput: unknown[]; + } | undefined; + customRunTurn = async (parsed, _incoming, emit) => { + backupParsed = { + previousResponseId: parsed.previousResponseId, + replayPrefixLength: parsed._replayPrefixLen ?? 0, + rawInput: (parsed._rawBody as { input?: unknown[] } | undefined)?.input ?? [], + }; + emit({ type: "text_delta", text: "continued" }); + emit({ type: "done" }); + }; + const config = comboConfig({ + a: provider("openai-chat", baseUrl(a), "key-a"), + b: provider("test-run-turn", "https://b.test/v1", "key-b"), + }); + + const response = await post(config, { + previous_response_id: "resp_combo_stable_snapshot", + input: [{ role: "user", content: "stable current turn" }], + }); + + expect(response.status).toBe(200); + expect(backupParsed?.previousResponseId).toBe("resp_combo_stable_snapshot"); + expect(backupParsed?.replayPrefixLength).toBeGreaterThan(0); + const requestText = JSON.stringify(backupParsed?.rawInput); + expect(backupParsed?.rawInput).toHaveLength(3); + expect(requestText.split("stable prior history")).toHaveLength(2); + expect(requestText.split("stable prior answer")).toHaveLength(2); + expect(requestText.split("stable current turn")).toHaveLength(2); + }); + + test("combo keeps an explicitly empty provider-state snapshot across failover", async () => { + const { previousResponseProviderState, rememberResponseState } = await import("../src/responses/state"); + customRunTurn = async (_parsed, _incoming, emit) => { + emit({ type: "text_delta", text: "seed" }); + emit({ type: "done", providerState: { kiro: { conversationId: "late-owned-state" } } }); + }; + const config = comboConfig({ + b: provider("test-owned", "https://provider-b.test/v1", "key-b"), + }, [{ provider: "b", model: "m2" }]); + const seed = await post(config, { input: "seed owner" }); + expect(seed.status).toBe(200); + const seedJson = await seed.json() as { id: string }; + const ownedState = previousResponseProviderState(seedJson.id); + expect(ownedState?.__ocxOwner?.providerName).toBe("b"); + + config.providers.a = provider("test-owned", "https://provider-a.test/v1", "key-a"); + config.combos!.free!.targets = [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + ]; + let backupObserved: string | undefined; + customRunTurn = async (parsed, _incoming, emit) => { + if (parsed.modelId === "m1") { + rememberResponseState( + { model: "combo/free", input: "late state" }, + { + id: "resp_combo_late_provider_state", + status: "completed", + output: [{ type: "message", role: "assistant", content: "late" }], + }, + ownedState, + { force: true }, + ); + emit({ type: "error", message: "retry elsewhere", status: 503, retryable: true }); + return; + } + backupObserved = parsed._providerContinuation?.kiro?.conversationId; + emit({ type: "text_delta", text: "backup" }); + emit({ type: "done" }); + }; + + const response = await post(config, { + previous_response_id: "resp_combo_late_provider_state", + input: "continue", + }); + + expect(response.status).toBe(200); + expect(backupObserved).toBeUndefined(); + }); + + test("combo response state deep-merges provider-private payloads generically", async () => { + const { previousResponseProviderState } = await import("../src/responses/state"); + let turn = 0; + customRunTurn = async (_parsed, _incoming, emit) => { + turn += 1; + emit({ type: "text_delta", text: `turn-${turn}` }); + emit({ + type: "done", + providerState: turn === 1 + ? { future: { stable: "keep", changed: "old" } } + : { future: { changed: "new" } }, + }); + }; + const config = comboConfig({ + a: provider("test-owned", "https://provider-a.test/v1", "key-a"), + }, [{ provider: "a", model: "m1" }]); + + const first = await post(config, { input: "seed future provider state" }); + expect(first.status).toBe(200); + const firstJson = await first.json() as { id: string }; + const second = await post(config, { + previous_response_id: firstJson.id, + input: "update future provider state", + }); + expect(second.status).toBe(200); + const secondJson = await second.json() as { id: string }; + + const stored = previousResponseProviderState(secondJson.id); + expect(stored?.future).toEqual({ stable: "keep", changed: "new" }); + expect(stored?.__ocxOwner?.providerName).toBe("a"); + }); + + test("combo child retains the local id without inheriting unbound provider state", async () => { + const { rememberResponseState } = await import("../src/responses/state"); + rememberResponseState( + { model: "combo/free", input: "prior target turn" }, + { + id: "resp_combo_unbound_provider_state", + status: "completed", + output: [{ type: "message", role: "assistant", content: "prior target answer" }], + }, + { + cursor: { conversationId: "cursor_owned_by_another_target" }, + kiro: { conversationId: "kiro_owned_by_another_target" }, + }, + ); + let observed: { + previousResponseId?: string; + providerContinuation: unknown; + cursorConversationId: unknown; + } | undefined; + customRunTurn = async (parsed, _incoming, emit) => { + observed = { + previousResponseId: parsed.previousResponseId, + providerContinuation: parsed._providerContinuation, + cursorConversationId: parsed._cursorConversationId, + }; + emit({ type: "text_delta", text: "continued" }); + emit({ type: "done" }); + }; + const config = comboConfig({ + a: provider("test-run-turn", "https://a.test/v1", "key-a"), + }); + + const response = await post(config, { + previous_response_id: "resp_combo_unbound_provider_state", + input: [{ role: "user", content: "continue" }], + }); + + expect(response.status).toBe(200); + expect(observed?.previousResponseId).toBe("resp_combo_unbound_provider_state"); + expect(observed?.providerContinuation).toBeUndefined(); + expect(observed?.cursorConversationId).toBeUndefined(); + }); + + test("combo rejects malformed provider-continuation owner metadata", async () => { + const { rememberResponseState } = await import("../src/responses/state"); + rememberResponseState( + { model: "combo/free", input: "prior target turn" }, + { + id: "resp_combo_malformed_provider_owner", + status: "completed", + output: [{ type: "message", role: "assistant", content: "prior target answer" }], + }, + { + __ocxOwner: { + version: 2, + providerName: "a", + providerDestinationIdentity: `destination:${"a".repeat(64)}`, + adapterName: "kiro", + modelId: "m1", + credentialIdentity: `key:${"b".repeat(64)}`, + }, + kiro: { conversationId: "must-not-restore" }, + } as unknown as OcxProviderContinuationState, + ); + let observed: string | undefined; + customRunTurn = async (parsed, _incoming, emit) => { + observed = parsed._providerContinuation?.kiro?.conversationId; + emit({ type: "text_delta", text: "continued" }); + emit({ type: "done", providerState: { kiro: { conversationId: "fresh" } } }); + }; + const config = comboConfig({ + a: provider("test-kiro", "https://kiro-a.test/v1", "key-a"), + }); + + const response = await post(config, { + previous_response_id: "resp_combo_malformed_provider_owner", + input: "continue", + }); + + expect(response.status).toBe(200); + expect(observed).toBeUndefined(); + }); + + test("same Kiro combo target and credential retain the provider conversation id", async () => { + const seen: Array = []; + customRunTurn = async (parsed, _incoming, emit) => { + const conversationId = parsed._providerContinuation?.kiro?.conversationId; + seen.push(conversationId); + emit({ type: "text_delta", text: "continued" }); + emit({ + type: "done", + providerState: { kiro: { conversationId: conversationId ?? "kiro-owned-conversation" } }, + }); + }; + const config = comboConfig({ + a: provider("test-kiro", "https://kiro-a.test/v1", "key-a"), + }); + + const first = await post(config, { store: false, input: "first" }); + expect(first.status).toBe(200); + const firstJson = await first.json() as { id: string }; + const second = await post(config, { + store: false, + previous_response_id: firstJson.id, + input: "second", + }); + + expect(second.status).toBe(200); + expect(seen).toEqual([undefined, "kiro-owned-conversation"]); + }); + + test("same Cursor combo target without a parent-thread header retains its conversation id", async () => { + const seen: string[] = []; + customCursorTransportFactory = () => ({ + async *run(request) { + seen.push(request.conversationId); + yield { type: "text", text: "cursor ok" }; + yield { type: "done", usage: { inputTokens: 10, outputTokens: 2, estimated: true } }; + }, + writeClient() {}, + close() {}, + }); + const config = comboConfig( + { cursortest: provider("cursor", "https://api2.cursor.sh", "fake-cursor-token") }, + [{ provider: "cursortest", model: "composer-2" }], + ); + + const first = await post(config, { store: false, input: "first" }); + expect(first.status).toBe(200); + const firstJson = await first.json() as { id: string }; + const second = await post(config, { + store: false, + previous_response_id: firstJson.id, + input: "second", + }); + + expect(second.status).toBe(200); + expect(seen).toHaveLength(2); + expect(seen[1]).toBe(seen[0]); + }); + + test("combo failover to another provider does not inherit provider continuation state", async () => { + const seen: Array<{ model: string; conversationId?: string }> = []; + customRunTurn = async (parsed, _incoming, emit) => { + const conversationId = parsed._providerContinuation?.kiro?.conversationId; + seen.push({ model: parsed.modelId, ...(conversationId ? { conversationId } : {}) }); + emit({ type: "text_delta", text: "first" }); + emit({ type: "done", providerState: { kiro: { conversationId: "kiro-provider-a" } } }); + }; + const config = comboConfig({ + a: provider("test-kiro", "https://kiro-a.test/v1", "key-a"), + }); + const first = await post(config, { store: false, input: "first" }); + expect(first.status).toBe(200); + const firstJson = await first.json() as { id: string }; + + config.providers.b = provider("test-owned", "https://provider-b.test/v1", "key-b"); + config.combos!.free!.targets = [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + ]; + customRunTurn = async (parsed, _incoming, emit) => { + const conversationId = parsed._providerContinuation?.kiro?.conversationId; + seen.push({ model: parsed.modelId, ...(conversationId ? { conversationId } : {}) }); + if (parsed.modelId === "m1") { + emit({ type: "error", message: "retry elsewhere", status: 503, retryable: true }); + return; + } + emit({ type: "text_delta", text: "backup" }); + emit({ type: "done", providerState: { kiro: { conversationId: "provider-b" } } }); + }; + + const second = await post(config, { + store: false, + previous_response_id: firstJson.id, + input: "second", + }); + + expect(second.status).toBe(200); + expect(seen.slice(1)).toEqual([ + { model: "m1", conversationId: "kiro-provider-a" }, + { model: "m2" }, + ]); + }); + + test("same provider with a different credential does not inherit provider continuation state", async () => { + const seen: Array = []; + customRunTurn = async (parsed, _incoming, emit) => { + const conversationId = parsed._providerContinuation?.kiro?.conversationId; + seen.push(conversationId); + emit({ type: "text_delta", text: "continued" }); + emit({ + type: "done", + providerState: { kiro: { conversationId: conversationId ?? "credential-one-conversation" } }, + }); + }; + const config = comboConfig({ + a: provider("test-kiro", "https://kiro-a.test/v1", "credential-one"), + }); + const first = await post(config, { store: false, input: "first" }); + expect(first.status).toBe(200); + const firstJson = await first.json() as { id: string }; + + config.providers.a!.apiKey = "credential-two"; + const second = await post(config, { + store: false, + previous_response_id: firstJson.id, + input: "second", + }); + + expect(second.status).toBe(200); + expect(seen).toEqual([undefined, undefined]); + }); + + test("Kiro OAuth continuation follows the account across refresh but not account replacement", async () => { + const seen: Array = []; + const { + clearResponseStateForTests, + clearResponseStateMemoryForTests, + flushResponseState, + } = await import("../src/responses/state"); + clearResponseStateForTests(); + customRunTurn = async (parsed, _incoming, emit) => { + const conversationId = parsed._providerContinuation?.kiro?.conversationId; + seen.push(conversationId); + emit({ type: "text_delta", text: "continued" }); + emit({ + type: "done", + providerState: { kiro: { conversationId: conversationId ?? "kiro-oauth-conversation" } }, + }); + }; + // xAI supplies the real OAuth store/snapshot path while the mock adapter exposes + // Kiro provider-state semantics without entering Kiro's fixed-endpoint capability gate. + await saveCredential("xai", { + access: "xai-access-one", + refresh: "xai-refresh-one", + expires: Date.now() + 3_600_000, + accountId: "acct-xai-a", + source: "oauth", + }); + const config = comboConfig({ + xai: provider("openai-chat", "https://api.x.ai/v1", "unused", { + authMode: "oauth", + note: "test-kiro-oauth", + }), + }, [{ provider: "xai", model: "m1" }]); + + const first = await post(config, { store: false, input: "first" }); + expect(first.status).toBe(200); + const firstJson = await first.json() as { id: string }; + await flushResponseState(); + const persisted = readFileSync(join(getConfigDir(), "responses-state.json"), "utf8"); + expect(persisted).not.toContain("xai-access-one"); + expect(persisted).not.toContain("xai-refresh-one"); + expect(persisted).not.toContain("acct-xai-a"); + clearResponseStateMemoryForTests(); + + await saveCredential("xai", { + access: "xai-access-two", + refresh: "xai-refresh-two", + expires: Date.now() + 3_600_000, + accountId: "acct-xai-a", + source: "oauth", + }); + const second = await post(config, { + store: false, + previous_response_id: firstJson.id, + input: "second", + }); + expect(second.status).toBe(200); + const secondJson = await second.json() as { id: string }; + + config.providers.xai!.headers = { authorization: "Bearer oauth-header-secret" }; + const changedHeader = await post(config, { + store: false, + previous_response_id: secondJson.id, + input: "changed header", + }); + expect(changedHeader.status).toBe(200); + const changedHeaderJson = await changedHeader.json() as { id: string }; + await flushResponseState(); + const headerSnapshot = readFileSync(join(getConfigDir(), "responses-state.json"), "utf8"); + expect(headerSnapshot).not.toContain("oauth-header-secret"); + + await saveCredential("xai", { + access: "xai-access-three", + refresh: "xai-refresh-three", + expires: Date.now() + 3_600_000, + accountId: "acct-xai-b", + source: "oauth", + }); + const replacedAccount = await post(config, { + store: false, + previous_response_id: changedHeaderJson.id, + input: "replaced account", + }); + + expect(replacedAccount.status).toBe(200); + expect(seen).toEqual([undefined, "kiro-oauth-conversation", undefined, undefined]); + }); + + test("OAuth continuation owners distinguish exact stored account ids", async () => { + const seen: Array = []; + customRunTurn = async (parsed, _incoming, emit) => { + const conversationId = parsed._providerContinuation?.kiro?.conversationId; + seen.push(conversationId); + emit({ type: "text_delta", text: "continued" }); + emit({ + type: "done", + providerState: { kiro: { conversationId: conversationId ?? "exact-oauth-subject" } }, + }); + }; + const config = comboConfig({ + xai: provider("openai-chat", "https://api.x.ai/v1", "unused", { + authMode: "oauth", + note: "test-kiro-oauth", + }), + }, [{ provider: "xai", model: "m1" }]); + + await saveCredential("xai", { + access: "exact-subject-access-one", + refresh: "exact-subject-refresh-one", + expires: Date.now() + 3_600_000, + accountId: "acct-exact-subject", + source: "oauth", + }); + const first = await post(config, { store: false, input: "first" }); + expect(first.status).toBe(200); + const firstJson = await first.json() as { id: string }; + + await saveCredential("xai", { + access: "exact-subject-access-two", + refresh: "exact-subject-refresh-two", + expires: Date.now() + 3_600_000, + accountId: " acct-exact-subject ", + source: "oauth", + }); + expect(getAccountSet("xai")?.accounts).toHaveLength(2); + const second = await post(config, { + store: false, + previous_response_id: firstJson.id, + input: "second", + }); + + expect(second.status).toBe(200); + expect(seen).toEqual([undefined, undefined]); + }); + + test("OAuth credentials without immutable account ids do not inherit provider continuation state", async () => { + const seen: Array = []; + const { + clearResponseStateForTests, + clearResponseStateMemoryForTests, + flushResponseState, + } = await import("../src/responses/state"); + clearResponseStateForTests(); + customRunTurn = async (parsed, _incoming, emit) => { + const conversationId = parsed._providerContinuation?.kiro?.conversationId; + seen.push(conversationId); + emit({ type: "text_delta", text: "continued" }); + emit({ + type: "done", + providerState: { kiro: { conversationId: conversationId ?? "identityless-oauth-conversation" } }, + }); + }; + const config = comboConfig({ + xai: provider("openai-chat", "https://api.x.ai/v1", "unused", { + authMode: "oauth", + note: "test-kiro-oauth", + }), + }, [{ provider: "xai", model: "m1" }]); + const cases: Array<{ name: string; email?: string }> = [ + { name: "identityless" }, + { name: "email-only", email: "shared-account@example.test" }, + ]; + for (const testCase of cases) { + clearResponseStateForTests(); + seen.length = 0; + await saveCredential("xai", { + access: `${testCase.name}-access-one`, + refresh: `${testCase.name}-refresh-one`, + expires: Date.now() + 3_600_000, + source: "oauth", + ...(testCase.email ? { email: testCase.email } : {}), + }); + const firstSlotId = getAccountSet("xai")?.activeAccountId; + expect(firstSlotId).toBeDefined(); + + const first = await post(config, { store: false, input: "first" }); + expect(first.status).toBe(200); + const firstJson = await first.json() as { id: string }; + await flushResponseState(); + clearResponseStateMemoryForTests(); + + await saveCredential("xai", { + access: `${testCase.name}-access-two`, + refresh: `${testCase.name}-refresh-two`, + expires: Date.now() + 3_600_000, + source: "oauth", + ...(testCase.email ? { email: testCase.email } : {}), + }); + expect(getAccountSet("xai")?.activeAccountId).toBe(firstSlotId); + const second = await post(config, { + store: false, + previous_response_id: firstJson.id, + input: "second", + }); + + expect(second.status).toBe(200); + expect(seen).toEqual([undefined, undefined]); + await flushResponseState(); + const persisted = readFileSync(join(getConfigDir(), "responses-state.json"), "utf8"); + expect(persisted).not.toContain(`${testCase.name}-access-one`); + expect(persisted).not.toContain(`${testCase.name}-refresh-one`); + expect(persisted).not.toContain(`${testCase.name}-access-two`); + expect(persisted).not.toContain(`${testCase.name}-refresh-two`); + if (testCase.email) expect(persisted).not.toContain(testCase.email); + } + }); + test("disabled image input rejects an image restored from previous_response_id before dispatch", async () => { const { rememberResponseState } = await import("../src/responses/state"); rememberResponseState( @@ -2000,10 +2709,19 @@ describe("cursor conversation continuity across store:false chains", () => { }); } - async function postCursor(config: OcxConfig, raw: Record): Promise { + async function postCursor( + config: OcxConfig, + raw: Record, + clientThreadId?: string, + bearerToken?: string, + ): Promise { return handleResponses(new Request("http://localhost/v1/responses", { method: "POST", - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + ...(clientThreadId ? { "x-codex-parent-thread-id": clientThreadId } : {}), + ...(bearerToken ? { authorization: `Bearer ${bearerToken}` } : {}), + }, body: JSON.stringify({ stream: false, store: false, ...raw }), }), config, { model: "", provider: "" }, {}); } @@ -2020,6 +2738,32 @@ describe("cursor conversation continuity across store:false chains", () => { }; } + test("ownerless legacy Cursor state fails closed before adapter dispatch", async () => { + const { rememberResponseState } = await import("../src/responses/state"); + rememberResponseState( + { model: "cursortest/composer-2", input: "legacy" }, + { + id: "resp_cursor_ownerless_legacy", + status: "completed", + output: [{ type: "message", role: "assistant", content: "legacy reply" }], + }, + { cursor: { conversationId: "legacy-cursor-conversation" } }, + { force: true }, + ); + const seen: string[] = []; + customCursorTransportFactory = fakeCursorTransportFactory(seen); + + const response = await postCursor(cursorConfig(), { + model: "cursortest/composer-2", + previous_response_id: "resp_cursor_ownerless_legacy", + input: "continue", + }); + + expect(response.status).toBe(200); + expect(seen).toHaveLength(1); + expect(seen[0]).not.toBe("legacy-cursor-conversation"); + }); + test("store:false chain reuses the SAME cursor conversationId (native model)", async () => { const seen: string[] = []; customCursorTransportFactory = fakeCursorTransportFactory(seen); @@ -2043,6 +2787,102 @@ describe("cursor conversation continuity across store:false chains", () => { expect(seen[1]).toBe(seen[0]); }); + test("owner mismatch rekeys Cursor parent-thread continuity to the new route", async () => { + const seen: string[] = []; + customCursorTransportFactory = fakeCursorTransportFactory(seen); + const config = cursorConfig(); + const threadId = "cursor-route-owner-thread"; + + const first = await postCursor(config, { + model: "cursortest/composer-2", + input: "first", + }, threadId); + expect(first.status).toBe(200); + const firstJson = await first.json() as { id: string }; + + config.providers.cursortest!.baseUrl = "https://cursor-other.example/v1"; + const second = await postCursor(config, { + model: "cursortest/composer-2", + previous_response_id: firstJson.id, + input: "second", + }, threadId); + expect(second.status).toBe(200); + + const third = await postCursor(config, { + model: "cursortest/composer-2", + input: [ + { role: "user", content: "first" }, + { role: "assistant", content: "cursor ok" }, + { role: "user", content: "third" }, + ], + }, threadId); + expect(third.status).toBe(200); + + expect(seen).toHaveLength(3); + expect(seen[1]).not.toBe(seen[0]); + expect(seen[2]).toBe(seen[1]); + }); + + test("ownerless local Cursor parent-thread continuity is scoped by route and bearer token", async () => { + const seen: string[] = []; + customCursorTransportFactory = fakeCursorTransportFactory(seen); + const config = cursorConfig(); + config.providers.cursortest = { + ...config.providers.cursortest!, + authMode: "local", + }; + delete config.providers.cursortest.apiKey; + const threadId = "cursor-ownerless-token-thread"; + + const first = await postCursor(config, { + model: "cursortest/composer-2", + input: "first", + }, threadId, "cursor-token-a"); + expect(first.status).toBe(200); + + config.providers.cursortest!.baseUrl = "https://cursor-local-other.example/v1"; + const second = await postCursor(config, { + model: "cursortest/composer-2", + input: [ + { role: "user", content: "first" }, + { role: "assistant", content: "cursor ok" }, + { role: "user", content: "second" }, + ], + }, threadId, "cursor-token-a"); + expect(second.status).toBe(200); + + const third = await postCursor(config, { + model: "cursortest/composer-2", + input: [ + { role: "user", content: "first" }, + { role: "assistant", content: "cursor ok" }, + { role: "user", content: "second" }, + { role: "assistant", content: "cursor ok" }, + { role: "user", content: "third" }, + ], + }, threadId, "cursor-token-b"); + expect(third.status).toBe(200); + + const fourth = await postCursor(config, { + model: "cursortest/composer-2", + input: [ + { role: "user", content: "first" }, + { role: "assistant", content: "cursor ok" }, + { role: "user", content: "second" }, + { role: "assistant", content: "cursor ok" }, + { role: "user", content: "third" }, + { role: "assistant", content: "cursor ok" }, + { role: "user", content: "fourth" }, + ], + }, threadId, "cursor-token-b"); + expect(fourth.status).toBe(200); + + expect(seen).toHaveLength(4); + expect(seen[1]).not.toBe(seen[0]); + expect(seen[2]).not.toBe(seen[1]); + expect(seen[3]).toBe(seen[2]); + }); + test("external-model toolResult continuation preserves and persists the same id", async () => { const seen: string[] = []; customCursorTransportFactory = fakeCursorTransportFactory(seen);