diff --git a/scripts/test-layout/layout-replay.json b/scripts/test-layout/layout-replay.json new file mode 100644 index 00000000000..3b8ab38bea1 --- /dev/null +++ b/scripts/test-layout/layout-replay.json @@ -0,0 +1,5 @@ +{ + "responses-replay-metadata-principal.test.ts": "responses", + "responses-combo-replay-principal.test.ts": "responses", + "responses-translated-replay-principal.test.ts": "responses" +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f26d909ca71..790bce45aec 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1,7 +1,7 @@ { "version": 1, "root": "tests", - "explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-adapters.json", "layout-jev.json"], + "explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json"], "explicit": { "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "service-proxy-env.test.ts": "service", "openai-chat-standalone-bare-block.test.ts": "adapters/openai", "owner-registry-isolation.test.ts": "config", diff --git a/src/responses/reasoning-replay-cache.ts b/src/responses/reasoning-replay-cache.ts index 213fe24cd9f..a718ac69764 100644 --- a/src/responses/reasoning-replay-cache.ts +++ b/src/responses/reasoning-replay-cache.ts @@ -143,8 +143,9 @@ function tupleForServingIdentity( function keyFor(callId: string, scope: OcxReasoningReplayScopeRef | undefined): string | undefined { const identity = tupleForIdentity(scope?.current); - if (!nonEmpty(callId) || !nonEmpty(scope?.clientThreadId) || !identity) return undefined; + if (!nonEmpty(callId) || !nonEmpty(scope?.clientPrincipalId) || !nonEmpty(scope.clientThreadId) || !identity) return undefined; return JSON.stringify([ + scope.clientPrincipalId, scope.clientThreadId, ...identity, callId, @@ -166,18 +167,21 @@ function sweepExpiredServingIdentities(at: number): void { function servingIdentityFor( scope: OcxReasoningReplayScopeRef | undefined, -): { threadId: string; identity: string } | undefined { +): { key: string; identity: string } | undefined { const threadId = scope?.clientThreadId; const identityTuple = tupleForServingIdentity(scope?.current); if (!nonEmpty(threadId) || !identityTuple) return undefined; - return { threadId, identity: JSON.stringify(identityTuple) }; + // Preserve physical safeguards for keyless callers in a separate namespace. A caller's + // thread name cannot select an authenticated principal's serving or rejection history. + const key = JSON.stringify([nonEmpty(scope?.clientPrincipalId) ? scope.clientPrincipalId : null, threadId]); + return { key, identity: JSON.stringify(identityTuple) }; } function opaqueBlobRejectionKeyFor( scope: OcxReasoningReplayScopeRef | undefined, ): string | undefined { const current = servingIdentityFor(scope); - return current ? JSON.stringify([current.threadId, current.identity]) : undefined; + return current ? JSON.stringify([current.key, current.identity]) : undefined; } function deleteOpaqueBlobRejection(key: string): void { @@ -224,7 +228,7 @@ export function reasoningReplayServingIdentityChange( if (!current) return undefined; const at = now(); sweepExpiredServingIdentities(at); - const previous = servingIdentities.get(current.threadId); + const previous = servingIdentities.get(current.key); if (previous === undefined || previous.identity === current.identity) return undefined; const before = JSON.parse(previous.identity) as ReasoningReplayIdentityTuple; const after = JSON.parse(current.identity) as ReasoningReplayIdentityTuple; @@ -254,7 +258,7 @@ export function reasoningReplayItemStoreChanged( if (!current) return false; const at = now(); sweepExpiredServingIdentities(at); - const previous = servingIdentities.get(current.threadId); + const previous = servingIdentities.get(current.key); if (previous === undefined || previous.identity === current.identity) return false; const storeOwner = (identity: string): string => { const [, destination, , , credential] = JSON.parse(identity) as ReasoningReplayIdentityTuple; @@ -271,16 +275,16 @@ export function commitReasoningReplayServingIdentity( if (!current) return; const at = now(); sweepExpiredServingIdentities(at); - const previous = servingIdentities.get(current.threadId); - const { threadId, identity } = current; - const bytes = Buffer.byteLength(JSON.stringify([threadId, identity]), "utf8"); + const previous = servingIdentities.get(current.key); + const { key, identity } = current; + const bytes = Buffer.byteLength(JSON.stringify([key, identity]), "utf8"); if (bytes > MAX_TOTAL_BYTES) { - deleteServingIdentity(threadId); + deleteServingIdentity(key); return; } - if (previous) deleteServingIdentity(threadId); - servingIdentities.set(threadId, { identity, bytes, at }); + if (previous) deleteServingIdentity(key); + servingIdentities.set(key, { identity, bytes, at }); servingIdentityTotalBytes += bytes; while ( (servingIdentityTotalBytes > MAX_TOTAL_BYTES || servingIdentities.size > MAX_ENTRIES) @@ -517,13 +521,13 @@ export function reasoningReplayKeyCredentialIdentity( return reasoningReplayCredentialIdentity("key", apiKey, provider.headers); } -/** Replace only the holder snapshot so parsed-request copies observe rotations. */ +/** Bind physical identity independently of the principal required for plaintext cache access. */ export function bindReasoningReplayScope( scope: OcxReasoningReplayScopeRef | undefined, identity: OcxReasoningReplayIdentity | undefined, ): void { if (!scope) return; - if (!identity || !keyFor("binding-check", { ...scope, current: identity })) { + if (!identity || !nonEmpty(scope.clientThreadId) || !tupleForIdentity(identity)) { delete scope.current; return; } diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index d473c98b391..e506db2e812 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -72,7 +72,7 @@ import { assertRouteAllowedByScope, resolveAdmissionModelScope, } from "./admission-model-scope"; -import type { DataPlaneAdmission } from "./auth-cors"; +import { resolveContextPrincipal, type DataPlaneAdmission } from "./auth-cors"; import { tryClaimNativeMainProfileForTurn } from "../codex/native-main-admission"; import { createTranslatorBudget, @@ -506,6 +506,8 @@ async function handleChatCompletionsWithBudget( // #1686: the Chat surface translates its body and replays here, so the admission fact has // to ride along or a bearer-admitted Chat caller would still be refused by Direct. ...(logIds?.admission ? { admission: logIds.admission } : {}), + // The bridge request carries no caller credential, so resolve the replay principal here. + replayPrincipal: { clientPrincipalId: resolveContextPrincipal(req, config, logIds?.admission) }, abortSignal: req.signal, // Body is Responses-shaped by now, but the client spoke Chat Completions. inboundWire: "chat", @@ -520,6 +522,9 @@ async function handleChatCompletionsWithBudget( ...(envelope && nativeChatCombos ? { protocolSource: createNativeChatComboSource({ req, config, envelope, requestedModel, requestedStream: stream, translatorBudget, + // Children run without logIds; the request's admission rides the source so their + // replay scope resolves the same caller principal as every other surface. + ...(logIds?.admission ? { admission: logIds.admission } : {}), }), } : {}), // Terminal vision-describe marker (roadmap 180): the bridge rebuilds diff --git a/src/server/chat-native-reasoning.ts b/src/server/chat-native-reasoning.ts index 3cb90a15e13..1c60f1aa963 100644 --- a/src/server/chat-native-reasoning.ts +++ b/src/server/chat-native-reasoning.ts @@ -7,11 +7,17 @@ import { reasoningReplayConversationIdFromResponsesRequest, sessionIdHeaderFromR type Rec = Record; -export function nativeChatReplayScope(headers: Headers): OcxReasoningReplayScopeRef | undefined { +export function nativeChatReplayScope( + headers: Headers, + clientPrincipalId: string | undefined, +): OcxReasoningReplayScopeRef | undefined { const clientThreadId = reasoningReplayConversationIdFromResponsesRequest({ threadIdHeader: headers.get("thread-id"), sessionIdHeader: sessionIdHeaderFromRequest(headers), }); - return clientThreadId ? { clientThreadId } : undefined; + return clientThreadId ? { + clientThreadId, + ...(clientPrincipalId ? { clientPrincipalId } : {}), + } : undefined; } /** Rebind before EVERY build: a key rotation must not retrieve another credential's reasoning. */ @@ -42,7 +48,8 @@ export function nativeChatReasoningCapture( const maxBytes = 256 * 1024; let bytes = 0; let callCount = 0; - let disabled = !scope?.current || !chatReasoningReplayPolicy(provider, modelId).preserve; + let disabled = !scope?.clientPrincipalId || !scope.current + || !chatReasoningReplayPolicy(provider, modelId).preserve; const discard = () => { budget.releaseRetained(bytes, accounting); bytes = 0; diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index b529ea2b089..6001433b998 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -24,6 +24,7 @@ import { } from "../lib/errors"; import { reportDispatchSends, type RequestExecutionBudget, type SingleUseDispatchPermit } from "../lib/request-execution-budget"; import type { AdmissionLease } from "../lib/admission"; +import { resolveContextPrincipal, type DataPlaneAdmission } from "./auth-cors"; import { readBoundedResponseBody } from "../lib/bounded-body"; import { redactSecretString } from "../lib/redact"; import { resolveClientRetryAfter } from "../lib/retry-after"; @@ -179,7 +180,7 @@ export interface HandleNativeChatOptions { req: Request; config: OcxConfig; logCtx: RequestLogContext; - logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease }; + logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission }; route: RouteResult; chatBody: Rec; requestedModel: string; @@ -209,6 +210,12 @@ export interface NativeChatExecution extends HandleNativeChatOptions { onFirstOutput?: () => void; /** The lease a streamed body holds; defaults to `logIds.turnAdmissionLease`. */ turnAdmissionLease?: AdmissionLease; + /** + * The request's resolved data-plane admission, when the caller carries it without `logIds`. + * A combo child runs on the parent's log row, so it receives the admitted identity here or + * its reasoning replay scope can never establish a caller principal (PF-07). + */ + admission?: DataPlaneAdmission; } /** @@ -246,6 +253,8 @@ export function createNativeChatComboSource(input: { requestedModel: string; requestedStream: boolean; translatorBudget: TranslatorBudget; + /** The request's data-plane admission; native children inherit it for replay scope. */ + admission?: DataPlaneAdmission; }): ComboProtocolSource { return { inbound: "chat", @@ -264,6 +273,7 @@ export function createNativeChatComboSource(input: { comboDispatchPermit: child.comboDispatchPermit, onFirstOutput: child.onFirstOutput, ...(child.turnAdmissionLease ? { turnAdmissionLease: child.turnAdmissionLease } : {}), + ...(input.admission ? { admission: input.admission } : {}), }, child.attemptHandle), }; } @@ -340,7 +350,8 @@ export async function runNativeChatAttempt( const physicalBudget = sendBudget ?? createInferenceSendBudget(req, logCtx); let activeAdapter: ProviderAdapter = createOpenAIChatAdapter(activeProvider); let activeRequest: AdapterRequest; - const replayScope = nativeChatReplayScope(req.headers); + const replayScope = nativeChatReplayScope(req.headers, + resolveContextPrincipal(req, config, execution.admission ?? logIds?.admission)); let retainedRequestBytes = 0; const releaseRetainedRequest = () => { if (retainedRequestBytes === 0) return; diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 5c6d54d665f..2b39f1ee4b3 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -100,6 +100,7 @@ import { isApiAuthRequired, isDataPlaneAdmissionSecret, isProxyAdmissionSecret, + resolveContextPrincipal, type RequestPolicyView, type DataPlaneAdmission, } from "./auth-cors"; @@ -1117,6 +1118,8 @@ async function handleClaudeMessagesWithBudget( // Routing keeps Claude-only sidecar overrides; admission policy must follow the live owner. codexAuthPolicy: config, ...(logIds?.admission ? { admission: logIds.admission } : {}), + // The translated request never forwards the caller credential; resolve the principal first. + replayPrincipal: { clientPrincipalId: resolveContextPrincipal(req, config, logIds?.admission) }, ...(logIds?.turnAdmissionLease ? { turnAdmissionLease: logIds.turnAdmissionLease } : {}), abortSignal: req.signal, promptCacheKeyIsSharedCohort: cacheKeySource === "system", diff --git a/src/server/responses/anthropic-thinking-recovery.ts b/src/server/responses/anthropic-thinking-recovery.ts index 4bf6005bfa4..ee30bf39d9c 100644 --- a/src/server/responses/anthropic-thinking-recovery.ts +++ b/src/server/responses/anthropic-thinking-recovery.ts @@ -130,6 +130,7 @@ export function createAnthropicThinkingRecovery( || response.status !== "completed" || !Array.isArray(response.output) || response.output.length === 0 || !rejectedScope || rejectedScope.clientThreadId !== parsed._reasoningReplayScope?.clientThreadId + || rejectedScope.clientPrincipalId !== parsed._reasoningReplayScope?.clientPrincipalId || JSON.stringify(rejectedScope.current) !== JSON.stringify(parsed._reasoningReplayScope?.current)) return; remembered = true; rememberReasoningReplayOpaqueBlobRejection(rejectedScope, rejectedPayloads); diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 169ce00a0a6..28cb3fbc718 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -129,6 +129,7 @@ import { ForwardAdmissionCredentialError, contextPrincipalIdOf, hasForwardableCodexBearer, + resolveContextPrincipal, validateForwardAdmissionCredential, } from "../auth-cors"; import type { DataPlaneAdmission } from "../auth-cors"; @@ -1002,7 +1003,12 @@ export async function handleResponsesCompact( cursorConversationId: gatewayReplayParsed._cursorConversationId, sessionIdHeader: sessionIdHeaderFromRequest(req.headers), }); - if (clientThreadId) gatewayReplayParsed._reasoningReplayScope = { clientThreadId }; + if (clientThreadId) { + gatewayReplayParsed._reasoningReplayScope = { + clientThreadId, + clientPrincipalId: resolveContextPrincipal(req, config, admission), + }; + } } let compactUrl = gatewayTransport ? `${gatewayTransport.url.replace(/\/+$/, "")}/compact` : `${base}/responses/compact`; const compactTargetKey = `${route.providerName}|${route.modelId}|compact`; @@ -1723,7 +1729,9 @@ export async function handleResponsesCompact( // The routed compaction turn is a handoff inside the same logical request, so it draws the // REMAINDER. Minting here is what let a native attempt spend three sends and the routed // fallback spend four more. - const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, sendBudget, compactionRecoveryKind: "compaction-v1", compactionRoutingOverride: options.compactionRoutingOverride, ...(admission ? { admission } : {}) }); + // internalReq keeps only FORWARD_HEADERS, so the caller principal is resolved from req. + const replayPrincipal = { clientPrincipalId: resolveContextPrincipal(req, config, admission) }; + const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, sendBudget, compactionRecoveryKind: "compaction-v1", compactionRoutingOverride: options.compactionRoutingOverride, replayPrincipal, ...(admission ? { admission } : {}) }); if (req.signal.aborted) { void response.body?.cancel(req.signal.reason).catch(() => undefined); return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request"); diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index bc4452a279e..b487e295a57 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -120,6 +120,7 @@ import { createComboProtocolLanes, dispatchNativeComboChild } from "./core-combo import { createJevModelInvoker } from "./jev-model-invoke"; import { comboRequestedEffortLabel } from "./combo-requested-effort"; import { clientWireOf } from "../inference/client-wire"; +import { resolveContextPrincipal } from "../auth-cors"; /** * Sends one combo target may run on its own before the ladder moves on. A target is a whole @@ -383,6 +384,10 @@ export async function executeComboResponses( if (!combo) { return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboId}`); } + // Resolve against the original request once; every bridge candidate receives the same trusted + // result after its provisional Authorization is removed, including an explicitly keyless result. + const replayPrincipal = options.replayPrincipal + ?? { clientPrincipalId: resolveContextPrincipal(req, config, options.admission) }; // PF-07: present only for a Chat combo with `nativeChatCombos` on; otherwise every child // takes the bridge below exactly as before. const protocolLanes = createComboProtocolLanes({ @@ -459,6 +464,7 @@ export async function executeComboResponses( const unavailable = mandatoryResponsesReasoningReplayUnavailable({ body, clientThreadId: reasoningReplayConversationId, + clientPrincipalId: replayPrincipal.clientPrincipalId, providerName: route.providerName, provider: route.provider, adapterName: route.provider.adapter, @@ -1059,6 +1065,7 @@ export async function executeComboResponses( }, }) : await requestDispatchers.handleResponses(childRequest, config, childLog, { ...options, + replayPrincipal, // A bridge child is a concrete route; the native source belongs to this loop only. ...(options.protocolSource ? { protocolSource: undefined } : {}), // After the spread: the child must run on THIS target's ladder, not on the holder the diff --git a/src/server/responses/core-opaque-recovery.ts b/src/server/responses/core-opaque-recovery.ts index fab2957ba90..0a829ebee94 100644 --- a/src/server/responses/core-opaque-recovery.ts +++ b/src/server/responses/core-opaque-recovery.ts @@ -547,6 +547,7 @@ export async function attemptOpaqueBlobRecovery( const rejectedScope = args.parsed._reasoningReplayScope ? { clientThreadId: args.parsed._reasoningReplayScope.clientThreadId, + clientPrincipalId: args.parsed._reasoningReplayScope.clientPrincipalId, ...(args.parsed._reasoningReplayScope.current ? { current: { ...args.parsed._reasoningReplayScope.current } } : {}), diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index 3ee37545491..b8f237828c5 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -128,6 +128,11 @@ export interface HandleResponsesOptions { * which is why it refused the whole env_key flow instead of serving it. */ admission?: DataPlaneAdmission; + /** + * Internal caller identity captured before Combo strips provisional credentials. An existing + * holder with no principal preserves keyless replay; never populate it from client data. + */ + replayPrincipal?: Readonly<{ clientPrincipalId: string | undefined }>; /** Called at most once after the complete client body is read and accepted for dispatch. */ onRequestBodyRead?: () => void; /** Internal handoff for retry wrappers that must reuse the already-accounted request body. */ diff --git a/src/server/responses/core-replay.ts b/src/server/responses/core-replay.ts index 567b0cbd42d..98249a6745d 100644 --- a/src/server/responses/core-replay.ts +++ b/src/server/responses/core-replay.ts @@ -344,6 +344,7 @@ function routeReasoningReplayIdentity(args: { export function mandatoryResponsesReasoningReplayUnavailable(args: { body: unknown; clientThreadId: string | undefined; + clientPrincipalId?: string; providerName: string; provider: OcxProviderConfig; adapterName: string; @@ -359,7 +360,7 @@ export function mandatoryResponsesReasoningReplayUnavailable(args: { ) return false; const current = routeReasoningReplayIdentity(args); - return reasoningReplayServingIdentityChanged({ clientThreadId, current }); + return reasoningReplayServingIdentityChanged({ clientThreadId, clientPrincipalId: args.clientPrincipalId, current }); } diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 2d9aa72ad91..9830fef7e09 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -606,7 +606,9 @@ export async function prepareResponsesRequest( // would become enough to read another caller's retained search result. Without a principal // bridgeSearchReplayScope yields no scope, so nothing is recorded or restored for it. The // field is always rewritten so an absent principal also clears one a reused holder carried. - const clientPrincipalId = resolveContextPrincipal(req, config, options.admission); + const clientPrincipalId = options.replayPrincipal + ? options.replayPrincipal.clientPrincipalId + : resolveContextPrincipal(req, config, options.admission); parsed._reasoningReplayScope = { ...parsed._reasoningReplayScope, clientPrincipalId }; } // Prefer a pre-populated id (routed Claude) over Responses headers that may be diff --git a/structure/transports/anthropic-thinking-recovery.md b/structure/transports/anthropic-thinking-recovery.md index c82cbf29a73..3af910ad9b8 100644 --- a/structure/transports/anthropic-thinking-recovery.md +++ b/structure/transports/anthropic-thinking-recovery.md @@ -29,9 +29,9 @@ the unsent repair uncharged. Recovery preserves the original thinking classifica Only a delivery-validated completed response with actual text/tool output and an observed successful terminal can memoize the rejection. Errors, incomplete/empty responses, missing -terminals, cancellation and changed serving identity cannot memoize it. The existing thread and -durable-serving-identity key and five-minute TTL in `src/responses/reasoning-replay-cache.ts` are -unchanged. Selective memos retain at most 256 payload digests within the existing cache byte cap; +terminals, cancellation and changed principal or serving identity cannot memoize it. The key in +`src/responses/reasoning-replay-cache.ts` includes the principal, thread and durable serving identity; +keyless callers have a separate namespace. The five-minute TTL and 256-payload digest cap remain; merged sets that cannot fit fall back to the existing blanket policy. `src/server/responses/core-replay.ts` binds a memo only for the current serving scope; a serving-identity change retains its independent blanket strip. No prefix hashes or local prefix validator participate in replay admission. diff --git a/structure/transports/native-reasoning-retention.md b/structure/transports/native-reasoning-retention.md index 38851fb44fa..d37b581bf42 100644 --- a/structure/transports/native-reasoning-retention.md +++ b/structure/transports/native-reasoning-retention.md @@ -12,6 +12,21 @@ `src/types/request.ts` declares trusted parsed policy fields; `src/responses/reasoning-replay-cache.ts` reports known durable identity deltas without widening cache keys. `src/adapters/openai-responses/passthrough.ts` applies reasoning and compaction stripping independently. +Native Chat's retained plaintext `reasoning_content` replay cells require an authenticated caller +principal in addition to the conversation, route, credential, model, adapter, and tool call. Native +Chat and Responses resolve that principal from data-plane admission before capture or replay; a +bridge Combo carries the resolved principal, including its absence, through internal child options +after stripping provisional caller Authorization. The Chat Completions bridge, Claude Messages, and +the routed Responses compaction handoff likewise resolve it from the admitted original request and +hand it to `handleResponses` through its options, because the internal request they build may not +carry the caller's opencodex key. Client headers and bodies cannot assert this +identity. A keyless caller cannot retrieve another principal's plaintext reasoning. This rule describes +the plaintext replay cache only. Process-local opaque serving and rejection memos also separate +authenticated principals, with a distinct keyless namespace that preserves physical route-change +and rejection safeguards. Keyless callers share a principal namespace and thread slot, but serving +route changes still compare physical identity and rejection memos remain bound to the durable +physical identity. Google's durable signature bindings retain their own identity and validation rules. + `src/server/responses/request-prepare.ts` snapshots the resolved policy into trusted parsed state. `src/server/responses/core-replay.ts` applies it to the selected concrete route, including each fresh Combo candidate. Retention is eligible only on the canonical ChatGPT Responses forward destination, with an unchanged provider, adapter and durable endpoint. A known model change requires `modelSwitch`; a known credential change requires `accountSwitch`; simultaneous changes require both. It does not widen the serving identity used for reasoning caches or strict plaintext Combo eligibility. Unknown or expired serving provenance retains the existing destination-only behavior rather than inventing a compatibility match. Account retention is experimental forwarding permission, not evidence that the backend accepts or reuses another caller's reasoning. `src/server/responses/account-change-state.ts` continues removing account-bound continuation references and foreign reasoning item ids; uploaded-file refusal stays independent. Opaque-blob rejection recovery and its rejection memo override retention. When retention actually kept ciphertext across a change, `src/server/responses/passthrough-dispatch.ts` also inspects the zero-output stream prelude, so a refusal ChatGPT sends inside a 200 stream (a Codex WebSocket `error` frame or `response.failed`) takes the same single sanitized rebuild as an HTTP 4xx refusal. Previously stripped ciphertext is never restored in a later recovery leg. Claude-native per-blob replay tags remain authoritative: retention never permits a blob whose durable endpoint or generation-bearing credential fails its tag check. diff --git a/tests/adapters/bridge-raw-reasoning-hidden.test.ts b/tests/adapters/bridge-raw-reasoning-hidden.test.ts index 2091b3de861..e33b4f37e8a 100644 --- a/tests/adapters/bridge-raw-reasoning-hidden.test.ts +++ b/tests/adapters/bridge-raw-reasoning-hidden.test.ts @@ -34,6 +34,7 @@ async function collectSse(stream: ReadableStream): Promise<{ event?: } const REPLAY_SCOPE: OcxReasoningReplayScopeRef = { + clientPrincipalId: "principal-hidden-replay", clientThreadId: "hidden-replay-thread", current: { providerName: "routed", diff --git a/tests/adapters/bridge-reasoning-replay-batch.test.ts b/tests/adapters/bridge-reasoning-replay-batch.test.ts index f1d3a282cdd..18db5f43f78 100644 --- a/tests/adapters/bridge-reasoning-replay-batch.test.ts +++ b/tests/adapters/bridge-reasoning-replay-batch.test.ts @@ -19,6 +19,7 @@ import type { AdapterEvent, OcxReasoningReplayScopeRef } from "../../src/types"; const REASONING = "I need to inspect files before answering."; const SCOPE: OcxReasoningReplayScopeRef = { + clientPrincipalId: "principal-batch", clientThreadId: "thread-batch", current: { providerName: "opencode-zen", diff --git a/tests/adapters/google/google-signature-history-roundtrip.test.ts b/tests/adapters/google/google-signature-history-roundtrip.test.ts index 9695622d191..8cdc3674082 100644 --- a/tests/adapters/google/google-signature-history-roundtrip.test.ts +++ b/tests/adapters/google/google-signature-history-roundtrip.test.ts @@ -17,10 +17,11 @@ import { flushThoughtSignatureReplayForTests, forgetThoughtSignatureForReplay, lookupReplayThoughtSignature, + rememberExtraContentForReplay, rememberThoughtSignatureForReplay, resetThoughtSignatureReplayForTests, } from "../../../src/responses/thought-signature-replay"; -import { durableReplayDestinationIdentity } from "../../../src/responses/reasoning-replay-cache"; +import { bindReasoningReplayScope, durableReplayDestinationIdentity } from "../../../src/responses/reasoning-replay-cache"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../../src/lib/windows-secret-acl"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxReasoningReplayScopeRef } from "../../../src/types"; import { withTestTranslatorBudget } from "../../helpers/translator-budget"; @@ -335,8 +336,17 @@ describe("#1735 thought signature survives history replay", () => { expect(part?.thoughtSignature).toBe(SIGNATURE); }); - test("a custom_tool_call replay is re-signed from the proxy-side store", async () => { - rememberThoughtSignatureForReplay("call_custom_1", SIGNATURE_B, scopeFor()); + test("keyless physical binding restores a freeform tool signature from the durable store", async () => { + const captured: OcxReasoningReplayScopeRef = { clientThreadId: "thread-a" }; + bindReasoningReplayScope(captured, scopeFor().current); + expect(captured.clientPrincipalId).toBeUndefined(); + expect(captured.current).toEqual(scopeFor().current); + await rememberExtraContentForReplay("call_custom_1", { google: { thoughtSignature: SIGNATURE_B } }, captured); + await flushThoughtSignatureReplayForTests(); + resetThoughtSignatureReplayForTests(); + __resetAntigravityReplayCache(); + const replay: OcxReasoningReplayScopeRef = { clientThreadId: "thread-a" }; + bindReasoningReplayScope(replay, scopeFor().current); const parsed = parseRequestScoped({ model: MODEL, input: [ @@ -345,7 +355,7 @@ describe("#1735 thought signature survives history replay", () => { { type: "custom_tool_call_output", call_id: "call_custom_1", output: "/workspace" }, ], tools: [{ type: "function", name: "shell_command", description: "run", parameters: { type: "object" } }], - }); + }, replay); const request = await createGoogleAdapter(provider).buildRequest(parsed); const part = modelParts(request.body as string).find(candidate => "functionCall" in candidate); expect(part?.thoughtSignature).toBe(SIGNATURE_B); diff --git a/tests/adapters/reasoning-replay-identity.test.ts b/tests/adapters/reasoning-replay-identity.test.ts index 4472d920fc3..44b8c789fba 100644 --- a/tests/adapters/reasoning-replay-identity.test.ts +++ b/tests/adapters/reasoning-replay-identity.test.ts @@ -61,26 +61,68 @@ async function drain(events: AsyncIterable, replayScope: OcxReason } } +function authenticatedScope( + overrides: Partial> = {}, +): OcxReasoningReplayScopeRef { + return { ...scope(overrides), clientPrincipalId: "principal-a" }; +} + describe("reasoning replay provider and credential identity", () => { beforeEach(() => clearReasoningReplayCacheForTests()); afterEach(() => clearReasoningReplayCacheForTests()); test("the same tuple replays but provider, destination, wire, model, or credential changes miss", () => { - const original = scope(); + const original = authenticatedScope(); rememberReasoningForCall(CALL_ID, REASONING, original); - expect(peekReasoningForCall(CALL_ID, scope())).toBe(REASONING); + expect(peekReasoningForCall(CALL_ID, authenticatedScope())).toBe(REASONING); for (const changed of [ - scope({ providerName: "provider-b" }), - scope({ providerDestinationIdentity: "destination:provider-b" }), - scope({ adapterName: "openai-responses" }), - scope({ modelId: "deepseek-v4" }), - scope({ credentialIdentity: "key:physical-b" }), + authenticatedScope({ providerName: "provider-b" }), + authenticatedScope({ providerDestinationIdentity: "destination:provider-b" }), + authenticatedScope({ adapterName: "openai-responses" }), + authenticatedScope({ modelId: "deepseek-v4" }), + authenticatedScope({ credentialIdentity: "key:physical-b" }), ]) { expect(peekReasoningForCall(CALL_ID, changed)).toBeUndefined(); } }); + test("binding the same physical identity isolates plaintext by principal and refuses keyless writes", () => { + const principalA: OcxReasoningReplayScopeRef = { clientThreadId: THREAD, clientPrincipalId: "principal-a" }; + const principalB: OcxReasoningReplayScopeRef = { clientThreadId: THREAD, clientPrincipalId: "principal-b" }; + const keyless: OcxReasoningReplayScopeRef = { clientThreadId: THREAD }; + for (const holder of [principalA, principalB, keyless]) bindReasoningReplayScope(holder, scope().current); + rememberReasoningForCall(CALL_ID, REASONING, principalA); + expect(peekReasoningForCall(CALL_ID, principalA)).toBe(REASONING); + expect(peekReasoningForCall(CALL_ID, principalB)).toBeUndefined(); + expect(peekReasoningForCall(CALL_ID, keyless)).toBeUndefined(); + rememberReasoningForCall(CALL_ID, "keyless overwrite", keyless); + expect(peekReasoningForCall(CALL_ID, keyless)).toBeUndefined(); + expect(peekReasoningForCall(CALL_ID, principalA)).toBe(REASONING); + rememberReasoningForCall(CALL_ID, "principal b reasoning", principalB); + expect(peekReasoningForCall(CALL_ID, principalB)).toBe("principal b reasoning"); + expect(peekReasoningForCall(CALL_ID, principalA)).toBe(REASONING); + }); + + test("keyless physical binding retains serving changes and rejection memos but rejects incomplete identity", () => { + const holder: OcxReasoningReplayScopeRef = { clientThreadId: THREAD }; + const identity = scope().current; + bindReasoningReplayScope(holder, identity); + expect(holder.current).toEqual(identity); + expect(holder.current).not.toBe(identity); + commitReasoningReplayServingIdentity(holder); + rememberReasoningReplayOpaqueBlobRejection(holder); + expect(reasoningReplayOpaqueBlobRejectionMemoized(holder)).toBe(true); + bindReasoningReplayScope(holder, scope({ modelId: "changed-model" }).current); + expect(reasoningReplayServingIdentityChange(holder)).toMatchObject({ modelChanged: true }); + expect(reasoningReplayOpaqueBlobRejectionMemoized(holder)).toBe(false); + bindReasoningReplayScope(holder, scope({ credentialIdentity: "" }).current); + expect(holder.current).toBeUndefined(); + const threadless: OcxReasoningReplayScopeRef = {}; + bindReasoningReplayScope(threadless, scope().current); + expect(threadless.current).toBeUndefined(); + }); + test("serving identity ignores credential generation but reports durable route changes", () => { const firstGeneration = scope({ credentialIdentity: "oauth:slot-a-generation-a", @@ -264,8 +306,8 @@ describe("reasoning replay provider and credential identity", () => { }); test("invalidating a bound holder prevents writes under its stale identity", () => { - const bound = scope(); - const oldIdentity = scope(); + const bound = authenticatedScope(); + const oldIdentity = authenticatedScope(); rememberReasoningForCall(CALL_ID, "old reasoning", oldIdentity); bindReasoningReplayScope(bound, undefined); @@ -388,9 +430,9 @@ describe("reasoning replay provider and credential identity", () => { }); test("a bridge created before credential rotation writes under the holder's current identity", async () => { - const oldScope = scope(); + const oldScope = authenticatedScope(); rememberReasoningForCall(CALL_ID, "old reasoning", oldScope); - const holder = scope(); + const holder = authenticatedScope(); async function* events(): AsyncGenerator { yield { type: "reasoning_raw_delta", text: "new reasoning" }; holder.current = { ...holder.current!, credentialIdentity: "key:physical-b" }; @@ -534,7 +576,7 @@ describe("selective opaque-blob rejection memos", () => { test("falls back to the old blanket entry when a valid set cannot fit beside a near-limit key", () => { const current = scope().current!; const identity = JSON.stringify([current.providerName, current.providerDestinationDurableIdentity, current.adapterName, current.modelId, current.credentialDurableIdentity]); - const overhead = Buffer.byteLength(JSON.stringify(["", identity])); + const overhead = Buffer.byteLength(JSON.stringify([JSON.stringify([null, ""]), identity])); const nearLimit = { ...scope(), clientThreadId: "t".repeat(256 * 1024 - overhead - 1) }; rememberReasoningReplayOpaqueBlobRejection(nearLimit, new Set([digest(1)])); expect(getReasoningReplayOpaqueBlobRejection(nearLimit)).toEqual({ mode: "blanket" }); diff --git a/tests/adapters/reasoning-replay-robustness.test.ts b/tests/adapters/reasoning-replay-robustness.test.ts index 939d1499ff6..ceed29b4c8b 100644 --- a/tests/adapters/reasoning-replay-robustness.test.ts +++ b/tests/adapters/reasoning-replay-robustness.test.ts @@ -15,6 +15,7 @@ import { repoPath } from "../helpers/repo-root"; const REASONING = "I need to inspect files before answering."; const SCOPE: OcxReasoningReplayScopeRef = { + clientPrincipalId: "principal-empty-delta", clientThreadId: "thread-empty-delta", current: { providerName: "opencode-zen", @@ -103,8 +104,9 @@ describe("reasoning replay empty-delta robustness", () => { const moduleUrl = pathToFileURL(repoPath("src/responses/reasoning-replay-cache.ts")).href; const script = [ `const cache = await import(${JSON.stringify(moduleUrl)});`, - `const scope = {clientThreadId:"disk-guard",current:{providerName:"test",providerDestinationIdentity:"destination:test",adapterName:"openai-chat",modelId:"model",credentialIdentity:"key:test"}};`, + `const scope = {clientPrincipalId:"principal-disk-guard",clientThreadId:"disk-guard",current:{providerName:"test",providerDestinationIdentity:"destination:test",adapterName:"openai-chat",modelId:"model",credentialIdentity:"key:test"}};`, `cache.rememberReasoningForCall("call_disk_guard", ${JSON.stringify(REASONING)}, scope);`, + `if (cache.peekReasoningForCall("call_disk_guard", scope) !== ${JSON.stringify(REASONING)}) throw new Error("cache fixture was not admitted");`, ].join("\n"); try { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d6637f3362f..3c95cb89cf8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,6 +1,9 @@ { "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "service-proxy-env.test.ts": "service", "bridge-terminal-refusal.test.ts": "adapters", + "responses-replay-metadata-principal.test.ts": "responses", + "responses-combo-replay-principal.test.ts": "responses", + "responses-translated-replay-principal.test.ts": "responses", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", diff --git a/tests/providers/deepseek-reasoning-replay-gaps.test.ts b/tests/providers/deepseek-reasoning-replay-gaps.test.ts index 74a926fb4a1..24356a26a8c 100644 --- a/tests/providers/deepseek-reasoning-replay-gaps.test.ts +++ b/tests/providers/deepseek-reasoning-replay-gaps.test.ts @@ -32,12 +32,15 @@ import type { const MODEL = "opencode-go/deepseek-v4-flash"; const REASONING = "I need to inspect files before answering."; +const PRINCIPAL = "principal-deepseek-replay-fixture"; function replayScope( clientThreadId = "test-thread", overrides: Partial> = {}, + clientPrincipalId?: string, ): OcxReasoningReplayScopeRef { return { clientThreadId, + ...(clientPrincipalId ? { clientPrincipalId } : {}), current: { providerName: "opencode-go", providerDestinationIdentity: "destination:opencode-zen-go", @@ -48,7 +51,7 @@ function replayScope( }, }; } -const REPLAY_SCOPE = replayScope(); +const REPLAY_SCOPE = replayScope("test-thread", {}, PRINCIPAL); const rememberReasoningForCall = (callId: string, text: string, scope = REPLAY_SCOPE): void => rememberReasoningForCallRaw(callId, text, scope); const peekReasoningForCall = (callId: string, scope = REPLAY_SCOPE): string | undefined => @@ -71,7 +74,7 @@ function configFor(): OcxConfig { function wireFor( input: unknown[], - scope: OcxReasoningReplayScopeRef | null = REPLAY_SCOPE, + scope: OcxReasoningReplayScopeRef | null = replayScope(), ): { messages: Array> } { const parsed = parseRequest({ model: MODEL, input, stream: true }); if (scope !== null) { @@ -174,7 +177,7 @@ describe("issue #950 — tool-call reasoning replay invariant (openai-chat wire) { type: "compaction", encrypted_content: "ocx1:c3VtbWFyeQ==" }, functionCallItem(), functionCallOutputItem(), - ]); + ], REPLAY_SCOPE); const assistant = toolCallAssistant(messages); expect(assistant).toBeDefined(); expect(assistant!["reasoning_content"]).toBe(REASONING); @@ -186,8 +189,8 @@ describe("issue #950 — tool-call reasoning replay invariant (openai-chat wire) // must carry the reasoning recorded for that call id — and keep carrying it // on a retry of the same continuation (peek is non-destructive). rememberReasoningForCall("call_1", REASONING); - const first = toolCallAssistant(wireFor([userMessage(), functionCallOutputItem()]).messages); - const retry = toolCallAssistant(wireFor([userMessage(), functionCallOutputItem()]).messages); + const first = toolCallAssistant(wireFor([userMessage(), functionCallOutputItem()], REPLAY_SCOPE).messages); + const retry = toolCallAssistant(wireFor([userMessage(), functionCallOutputItem()], REPLAY_SCOPE).messages); expect(first).toBeDefined(); expect(first!["reasoning_content"]).toBe(REASONING); expect(retry).toBeDefined(); @@ -195,8 +198,8 @@ describe("issue #950 — tool-call reasoning replay invariant (openai-chat wire) }); test("cross-request replay isolates threads and rejects an unscoped producer/consumer pair", () => { - const threadA = replayScope("thread-a"); - const threadB = replayScope("thread-b"); + const threadA = replayScope("thread-a", {}, PRINCIPAL); + const threadB = replayScope("thread-b", {}, PRINCIPAL); rememberReasoningForCallRaw("call_1", "thread alpha reasoning", threadA); rememberReasoningForCallRaw("call_1", "thread beta reasoning", threadB); const unscopedProducer: AdapterEvent[] = [ @@ -216,9 +219,11 @@ describe("issue #950 — tool-call reasoning replay invariant (openai-chat wire) const alpha = toolCallAssistant(wireFor(input, threadA).messages); const beta = toolCallAssistant(wireFor(input, threadB).messages); const unscoped = toolCallAssistant(wireFor(input, null).messages); + const keyless = toolCallAssistant(wireFor(input, replayScope("thread-a")).messages); expect(alpha?.reasoning_content).toBe("thread alpha reasoning"); expect(beta?.reasoning_content).toBe("thread beta reasoning"); expect(unscoped?.reasoning_content).toBe(" "); + expect(keyless?.reasoning_content).toBe(" "); }); test("GAP D (issue #1193): replay cache MISS on the main assistant path injects a placeholder", () => { @@ -279,13 +284,13 @@ describe("issue #950 — tool-call reasoning replay invariant (openai-chat wire) // requiresReasoningPlaceholderModels: [] for minimax so a cache miss never // fabricates one (chatgpt-codex-connector P2 on #1205). Real recorded // reasoning still replays via preserveReasoningContentModels. - const minimaxWire = (input: unknown[]) => { + const minimaxWire = (input: unknown[], clientPrincipalId?: string) => { const parsed = parseRequest({ model: "minimax/MiniMax-M3", input, stream: true }); const minimaxReplayScope = replayScope("test-thread-minimax", { providerName: "minimax", providerDestinationIdentity: "destination:minimax", modelId: "MiniMax-M3", - }); + }, clientPrincipalId); parsed._clientThreadId = minimaxReplayScope.clientThreadId; parsed._reasoningReplayScope = minimaxReplayScope; const config: OcxConfig = { @@ -330,8 +335,8 @@ describe("issue #950 — tool-call reasoning replay invariant (openai-chat wire) ]).wire.messages; expect(thinkingOnly.some(message => message.role === "assistant")).toBeFalse(); // Cache hit on the same path: the recorded reasoning still replays. - rememberReasoningForCall("call_1", REASONING, missResult.replayScope); - const hit = toolCallAssistant(minimaxWire([userMessage(), functionCallOutputItem()]).wire.messages); + rememberReasoningForCall("call_1", REASONING, { ...missResult.replayScope, clientPrincipalId: PRINCIPAL }); + const hit = toolCallAssistant(minimaxWire([userMessage(), functionCallOutputItem()], PRINCIPAL).wire.messages); expect(hit).toBeDefined(); expect(hit!["reasoning_content"]).toBeUndefined(); expect(hit!["reasoning_details"]).toEqual([ @@ -410,8 +415,8 @@ describe("issue #950 — reasoning replay cache bounds", () => { }); test("conversation scopes isolate entries with the same call id", () => { - const threadA = replayScope("thread-a"); - const threadB = replayScope("thread-b"); + const threadA = replayScope("thread-a", {}, PRINCIPAL); + const threadB = replayScope("thread-b", {}, PRINCIPAL); rememberReasoningForCall("call_1", "thread alpha reasoning", threadA); rememberReasoningForCall("call_1", "thread beta reasoning", threadB); expect(peekReasoningForCall("call_1", threadA)).toBe("thread alpha reasoning"); diff --git a/tests/responses/chat-native-combo.test.ts b/tests/responses/chat-native-combo.test.ts index b53cbe282d7..a2e5d87878c 100644 --- a/tests/responses/chat-native-combo.test.ts +++ b/tests/responses/chat-native-combo.test.ts @@ -17,12 +17,14 @@ import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/c import { clearComboRecallForTests } from "../../src/server/responses/combo-session-recall"; import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; import { clearKeyCooldowns } from "../../src/providers/key-failover"; +import { clearReasoningReplayCacheForTests } from "../../src/responses/reasoning-replay-cache"; import { clearResponseStateForTests, flushResponseState } from "../../src/responses/state"; import { resetProviderRequestPacingForTest } from "../../src/providers/request-pacing"; import { chatErrorStream, chatStream, responsesSuccess } from "../helpers/combo-failover-upstream"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import type { DataPlaneAdmission } from "../../src/server/auth-cors"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; type Rec = Record; @@ -50,6 +52,7 @@ beforeEach(() => { clearComboTargetCooldowns(); clearKeyCooldowns(); clearResponseStateForTests(); + clearReasoningReplayCacheForTests(); }); afterEach(async () => { @@ -66,6 +69,7 @@ afterEach(async () => { clearComboTargetCooldowns(); clearKeyCooldowns(); resetProviderRequestPacingForTest(); + clearReasoningReplayCacheForTests(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; isolatedCodexHome?.restore(); @@ -135,13 +139,21 @@ function comboConfig( const NATIVE_ON: OcxConfig["protocols"] = { rollout: { nativeChatCombos: true } }; -async function send(config: OcxConfig, body: Rec) { +async function send(config: OcxConfig, body: Rec, options: { + headers?: Record; + admission?: DataPlaneAdmission; +} = {}) { const requestId = `pf07-${crypto.randomUUID()}`; const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", ...options.headers }, body: JSON.stringify({ model: "combo/pair", messages: MESSAGES, ...body }), - }), config, { model: "", provider: "" }, { requestId, start: Date.now() }); + }), config, { model: "", provider: "" }, { + requestId, + start: Date.now(), + // What the listener resolved; a loopback bind carries no caller identity of its own. + ...(options.admission ? { admission: options.admission } : {}), + }); const text = await response.text(); const rows = getRequestLogEntries().filter(entry => entry.requestId === requestId); return { response, text, rows }; @@ -195,6 +207,63 @@ describe("native Chat candidates in a combo", () => { }); }); + test("native combo plaintext reasoning replay follows admission without changing parent callbacks", async () => { + const a = upstream((_body, hit) => Response.json({ + id: `chatcmpl-replay-${hit}`, + object: "chat.completion", + model: "m1", + choices: [{ + index: 0, + message: hit === 1 + ? { role: "assistant", content: null, reasoning_content: "private reasoning", tool_calls: [ + { id: "call_replay", type: "function", function: { name: "inspect", arguments: "{}" } }, + ] } + : { role: "assistant", content: "continued" }, + finish_reason: hit === 1 ? "tool_calls" : "stop", + }], + })); + const config = comboConfig( + { a: provider("openai-chat", a.baseUrl) }, + [{ provider: "a", model: "deepseek-v4.1-flash" }], + NATIVE_ON, + ); + const owner: DataPlaneAdmission = { + kind: "configured", keyId: "fixture-owner", source: "dedicated", contextPrincipalId: "principal-owner", + }; + const foreign: DataPlaneAdmission = { + kind: "configured", keyId: "fixture-foreign", source: "dedicated", contextPrincipalId: "principal-foreign", + }; + const thread = { "thread-id": "combo-replay-thread" }; + const first = await send(config, { stream: false }, { headers: thread, admission: owner }); + expect(first.response.status).toBe(200); + expect(first.rows).toHaveLength(1); + expect(first.rows[0]!.spend).toMatchObject({ sends: 1, unresolved: 0 }); + + const history: Rec[] = [ + { role: "user", content: "fixture" }, + { role: "assistant", content: null, tool_calls: [ + { id: "call_replay", type: "function", function: { name: "inspect", arguments: "{}" } }, + ] }, + { role: "tool", tool_call_id: "call_replay", content: "done" }, + { role: "user", content: "continue" }, + ]; + const samePrincipal = await send(config, { stream: false, messages: history }, { headers: thread, admission: owner }); + expect(samePrincipal.response.status).toBe(200); + expect(a.bodies[1]!.messages[1]!.reasoning_content).toBe("private reasoning"); + + const foreignPrincipal = await send(config, { stream: false, messages: history }, { headers: thread, admission: foreign }); + expect(foreignPrincipal.response.status).toBe(200); + expect(a.bodies[2]!.messages[1]!.reasoning_content).toBe(" "); + + const keyless = await send(config, { stream: false, messages: history }, { headers: thread }); + expect(keyless.response.status).toBe(200); + expect(a.bodies[3]!.messages[1]!.reasoning_content).toBe(" "); + // Native children remain on the combo's parent row and retain its send accounting. + expect(keyless.rows).toHaveLength(1); + expect(keyless.rows[0]!.provider).toBe("combo"); + expect(keyless.rows[0]!.spend).toMatchObject({ sends: 1, unresolved: 0 }); + }); + test.each([ ["native", "low", "unset", "high->low"], ["native", "xhigh", "unset", "high->xhigh"], diff --git a/tests/responses/chat-native-reasoning-replay.test.ts b/tests/responses/chat-native-reasoning-replay.test.ts index 2747d6fc33a..58d7c02308a 100644 --- a/tests/responses/chat-native-reasoning-replay.test.ts +++ b/tests/responses/chat-native-reasoning-replay.test.ts @@ -11,6 +11,7 @@ import type { OcxConfig, OcxProviderConfig } from "../../src/types"; type Rec = Record; const REASONING = "Inspect the fixture before answering."; +const ADMISSION_KEY = "fixture-data-key"; const USER = { role: "user", content: "Inspect the fixture." }; const tool = (id: string) => ({ id, type: "function", function: { name: "inspect", arguments: "{}" } }); const TOOLS = [{ type: "function", function: { name: "inspect", parameters: { type: "object", properties: {} } } }]; @@ -84,14 +85,17 @@ function start(providerName: string, model: string, options: { return Response.json({ choices: [{ index: 0, message: { ...ASSISTANT, reasoning_content: REASONING }, finish_reason: "tool_calls" }] }); } }); - saveConfig({ port: 0, defaultProvider: providerName, providers: { [providerName]: { + saveConfig({ port: 0, + apiKeys: [{ id: "fixture-key-id", name: "fixture", key: ADMISSION_KEY, createdAt: "2026-01-01T00:00:00Z" }], + defaultProvider: providerName, providers: { [providerName]: { adapter: "openai-chat", baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`, apiKey: "fixture-key", allowPrivateNetwork: true, models: [model], ...options.provider, } } } as OcxConfig); proxy = startServer(0); return async (messages: Rec[], stream = false, thread: string | null = "fixture-thread", extra: Rec = {}) => { const response = await fetch(new URL("/v1/chat/completions", proxy!.url), { - method: "POST", headers: { "content-type": "application/json", ...(thread ? { "thread-id": thread } : {}) }, + method: "POST", headers: { "content-type": "application/json", "x-opencodex-api-key": ADMISSION_KEY, + ...(thread ? { "thread-id": thread } : {}) }, body: JSON.stringify({ model: `${providerName}/${model}`, stream, messages, tools: TOOLS, ...extra }), }); const text = await response.text(); diff --git a/tests/responses/chat-native-reasoning-scope.test.ts b/tests/responses/chat-native-reasoning-scope.test.ts index c838d1a3a09..eb1295bed48 100644 --- a/tests/responses/chat-native-reasoning-scope.test.ts +++ b/tests/responses/chat-native-reasoning-scope.test.ts @@ -11,8 +11,9 @@ const MODEL = "deepseek-v4.1-flash"; const PROVIDER: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", apiKey: "fixture-key" }; const tool = (id: string) => ({ id, type: "function", function: { name: "inspect", arguments: "{}" } }); const history = (id = "call_a") => ({ messages: [{ role: "assistant", content: null, tool_calls: [tool(id)] }] }); -function scope(thread = "fixture-thread", provider = PROVIDER, providerName = "fixture", model = MODEL) { - const value = nativeChatReplayScope(new Headers({ "thread-id": thread }))!; +function scope(thread = "fixture-thread", provider = PROVIDER, providerName = "fixture", model = MODEL, + principal = "fixture-principal") { + const value = nativeChatReplayScope(new Headers({ "thread-id": thread }), principal)!; bindNativeChatReplayScope(value, provider, providerName, model); return value; } @@ -60,15 +61,19 @@ describe("native Chat reasoning policy and physical replay ownership", () => { for (const foreign of [scope("other-thread"), scope("fixture-thread", PROVIDER, "other-provider"), scope("fixture-thread", { ...PROVIDER, baseUrl: "https://other.invalid/v1" }), scope("fixture-thread", { ...PROVIDER, apiKey: "other-key" }), - scope("fixture-thread", PROVIDER, "fixture", "deepseek-v5.2-flash")]) { + scope("fixture-thread", PROVIDER, "fixture", "deepseek-v5.2-flash"), + scope("fixture-thread", PROVIDER, "fixture", MODEL, "other-principal")]) { expect(reasoning(foreign)).toBe(" "); } bindNativeChatReplayScope(owner, { ...PROVIDER, apiKey: "rotated-key" }, "fixture", MODEL); expect(reasoning(owner)).toBe(" "); expect(reasoning(undefined)).toBe(" "); - expect(nativeChatReplayScope(new Headers())).toBeUndefined(); - const keyless = scope("fixture-thread", { ...PROVIDER, apiKey: undefined }); - expect(keyless.current).toBeUndefined(); + expect(nativeChatReplayScope(new Headers(), "fixture-principal")).toBeUndefined(); + const keyless = nativeChatReplayScope(new Headers({ "thread-id": "fixture-thread" }), undefined)!; + bindNativeChatReplayScope(keyless, PROVIDER, "fixture", MODEL); + // Physical identity remains available to other scope consumers even though a keyless caller + // cannot address the principal-scoped plaintext replay cache. + expect(keyless.current).toMatchObject({ providerName: "fixture", modelId: MODEL }); expect(reasoning(keyless)).toBe(" "); budget.dispose(); }); diff --git a/tests/responses/responses-anthropic-thinking-recovery.test.ts b/tests/responses/responses-anthropic-thinking-recovery.test.ts index 54e2d19db5f..8621ce403b1 100644 --- a/tests/responses/responses-anthropic-thinking-recovery.test.ts +++ b/tests/responses/responses-anthropic-thinking-recovery.test.ts @@ -124,7 +124,7 @@ test("redacted-only history qualifies, and HTTP success alone does not memoize", expect(reasoningReplayOpaqueBlobRejectionMemoized(request._reasoningReplayScope)).toBe(true); }); -for (const variant of ["empty", "no-terminal", "error", "incomplete", "cancelled", "changed-identity", "changed-thread", "failed-fold"] as const) { +for (const variant of ["empty", "no-terminal", "error", "incomplete", "cancelled", "changed-identity", "changed-thread", "changed-principal", "failed-fold"] as const) { test(`does not memoize a ${variant} replacement`, async () => { const { recovery, request, abort } = fixture(); const rejectedScope = structuredClone(request._reasoningReplayScope); @@ -137,6 +137,7 @@ for (const variant of ["empty", "no-terminal", "error", "incomplete", "cancelled if (variant === "cancelled") abort.abort(); if (variant === "changed-identity") request._reasoningReplayScope!.current = { ...request._reasoningReplayScope!.current!, credentialDurableIdentity: "another-credential" }; if (variant === "changed-thread") request._reasoningReplayScope = { ...request._reasoningReplayScope!, clientThreadId: "another-thread" }; + if (variant === "changed-principal") request._reasoningReplayScope = { ...request._reasoningReplayScope!, clientPrincipalId: "another-principal" }; recovery.rememberSuccess({ status: variant === "failed-fold" ? "failed" : "completed", output: [{ type: "message" }] }); expect(reasoningReplayOpaqueBlobRejectionMemoized(rejectedScope)).toBe(false); expect(reasoningReplayOpaqueBlobRejectionMemoized(request._reasoningReplayScope)).toBe(false); diff --git a/tests/responses/responses-combo-replay-principal.test.ts b/tests/responses/responses-combo-replay-principal.test.ts new file mode 100644 index 00000000000..0507538d579 --- /dev/null +++ b/tests/responses/responses-combo-replay-principal.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { clearKeyCooldowns } from "../../src/providers/key-failover"; +import { clearReasoningReplayCacheForTests } from "../../src/responses/reasoning-replay-cache"; +import { clearResponseStateForTests, flushResponseState } from "../../src/responses/state"; +import { resetThoughtSignatureReplayForTests } from "../../src/responses/thought-signature-replay"; +import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; +import { resolveContextPrincipal, resolveResponsesApiAuth } from "../../src/server/auth-cors"; +import type { RequestLogContext } from "../../src/server/request-log"; +import { clearComboRecallForTests } from "../../src/server/responses/combo-session-recall"; +import { handleResponses } from "../../src/server/responses/core"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +type Rec = Record; +const MODEL = "deepseek-v4.1-flash"; +const THREAD = "bridge-replay-principal-thread"; +const PRIVATE = "synthetic owner reasoning"; +const CALL = "call_bridge_principal"; +const originalFetch = globalThis.fetch; +const previousHome = process.env.OPENCODEX_HOME; +let home: string | undefined; +let releaseSpend: (() => void) | undefined; +function clearRouteState() { + clearComboSelectionState(); clearComboTargetCooldowns(); clearComboRecallForTests(); clearKeyCooldowns(); +} +beforeEach(() => { + home = undefined; + home = mkdtempSync(join(tmpdir(), "ocx-bridge-replay-principal-")); + process.env.OPENCODEX_HOME = home; + releaseSpend = acquireOwnedSpendHome(); + clearRouteState(); clearReasoningReplayCacheForTests(); clearResponseStateForTests(); + resetThoughtSignatureReplayForTests(); +}); +afterEach(async () => { + if (!home) return; + releaseSpend?.(); releaseSpend = undefined; + closeRequestHistoryIndex(); await flushResponseState(); clearResponseStateForTests(); + globalThis.fetch = originalFetch; + clearRouteState(); clearReasoningReplayCacheForTests(); resetThoughtSignatureReplayForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +test.each([false, true])("bridge Combo retains bearer-only loopback principal through child dispatch (failover=%s)", async failover => { + const target = failover ? "second" : "first"; + const row = (name: string): OcxProviderConfig => ({ + adapter: "openai-chat", baseUrl: `https://${name}.example.test/v1`, authMode: "key", + apiKey: `synthetic-provider-${name}`, models: [MODEL], + }); + const config: OcxConfig = { + port: 0, hostname: "127.0.0.1", defaultProvider: "first", + apiKeys: ["owner", "foreign"].map(id => ({ id, name: id, key: `synthetic-caller-${id}`, createdAt: "2026-01-01" })), + providers: { first: row("first"), second: row("second") }, + combos: { pair: { strategy: "failover", targets: [ + { provider: "first", model: MODEL }, ...(failover ? [{ provider: "second", model: MODEL }] : []), + ] } }, + }; + const outbound: Array<{ provider: string; body: Rec }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + expect(["first.example.test", "second.example.test"]).toContain(url.hostname); + const provider = url.hostname.split(".")[0]!; + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBe(`Bearer synthetic-provider-${provider}`); + expect(headers.get("authorization")).not.toContain("synthetic-caller"); + const body = JSON.parse(String(init?.body)) as Rec; + outbound.push({ provider, body }); + if (failover && provider === "first") { + return Response.json({ error: { code: "model_not_found", message: "synthetic missing model" } }, { status: 404 }); + } + const continuation = body.messages.some((message: Rec) => message.role === "tool"); + return Response.json({ id: `chatcmpl-${outbound.length}`, object: "chat.completion", model: MODEL, + choices: [{ index: 0, message: continuation ? { role: "assistant", content: "continued" } + : { role: "assistant", content: null, reasoning_content: PRIVATE, tool_calls: [ + { id: CALL, type: "function", function: { name: "inspect", arguments: "{}" } }, + ] }, finish_reason: continuation ? "stop" : "tool_calls" }], + usage: { prompt_tokens: 2, completion_tokens: 2, total_tokens: 4 }, + }); + }) as typeof fetch; + let ownerPrincipal: string | undefined; + const post = async (caller: "owner" | "foreign" | undefined, seed = false) => { + // Exercise the first candidate and the actual failover each time, without a previous lane pin/cooldown. + clearRouteState(); + const before = outbound.length; + const req = new Request("http://localhost/v1/responses", { method: "POST", headers: { + "content-type": "application/json", "thread-id": THREAD, + ...(caller ? { authorization: `Bearer synthetic-caller-${caller}` } : {}), + ...(caller !== "owner" && ownerPrincipal ? { "x-opencodex-client-principal-id": ownerPrincipal } : {}), + }, body: JSON.stringify({ model: "combo/pair", stream: false, store: false, + tools: [{ type: "function", name: "inspect", parameters: { type: "object", properties: {} } }], + input: seed ? "inspect the fixture" : [ + { type: "message", role: "user", content: [{ type: "input_text", text: "inspect the fixture" }] }, + { type: "function_call", call_id: CALL, name: "inspect", arguments: "{}" }, + { type: "function_call_output", call_id: CALL, output: "done" }, + { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }, + ], + ...(caller !== "owner" ? { replayPrincipal: { clientPrincipalId: ownerPrincipal }, + _reasoningReplayScope: { clientThreadId: THREAD, clientPrincipalId: ownerPrincipal } } : {}), + }) }); + const admission = resolveResponsesApiAuth(req, { hostname: "127.0.0.1", apiKeys: config.apiKeys }); + expect(admission).toEqual({ kind: "loopback", source: "loopback" }); + const principal = resolveContextPrincipal(req, config, admission!); + if (seed) { expect(principal).toBeDefined(); ownerPrincipal = principal; } + else if (caller === "owner") expect(principal).toBe(ownerPrincipal); + else expect(principal).not.toBe(ownerPrincipal); + const log: RequestLogContext = { model: "", provider: "" }; + const completed: string[] = []; + const response = await handleResponses(req, config, log, { admission: admission!, onResponseComplete: model => completed.push(model) }); + expect(response.status).toBe(200); + await response.text(); + const sends = outbound.slice(before); + expect(sends.map(send => send.provider)).toEqual(failover ? ["first", "second"] : ["first"]); + expect(log.provider).toBe("combo"); + expect(log.attempts).toHaveLength(sends.length); + expect(log.attempts?.map(attempt => attempt.sendCount)).toEqual(sends.map(() => 1)); + expect(completed).toEqual([MODEL]); + return sends.find(send => send.provider === target)!.body.messages.find((message: Rec) => message.role === "assistant" && message.tool_calls)?.reasoning_content; + }; + await post("owner", true); + const same = await post("owner"); + const foreign = await post("foreign"); + const keyless = await post(undefined); + expect(foreign).toBe(" "); + expect(keyless).toBe(" "); + expect(same).toBe(PRIVATE); +}, 20_000); diff --git a/tests/responses/responses-replay-metadata-principal.test.ts b/tests/responses/responses-replay-metadata-principal.test.ts new file mode 100644 index 00000000000..4fc9049446f --- /dev/null +++ b/tests/responses/responses-replay-metadata-principal.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearReasoningReplayCacheForTests, commitReasoningReplayServingIdentity, + getReasoningReplayOpaqueBlobRejection, nativeReasoningTag, + peekReasoningForCall, reasoningReplayServingIdentityChanged, + rememberReasoningForCall, rememberReasoningReplayOpaqueBlobRejection, +} from "../../src/responses/reasoning-replay-cache"; +import { parseRequest } from "../../src/responses/parser"; +import { resetThoughtSignatureReplayForTests } from "../../src/responses/thought-signature-replay"; +import { bindRouteReasoningReplayScope, mandatoryResponsesReasoningReplayUnavailable, + nativeReasoningOwnerForRoute } from "../../src/server/responses/core-replay"; +import { handleResponses } from "../../src/server/responses/core"; +import { resolveResponsesApiAuth } from "../../src/server/auth-cors"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const previousHome = process.env.OPENCODEX_HOME; +const originalFetch = globalThis.fetch; +const THREAD = "synthetic-shared-thread"; +const BLOB = "synthetic-opaque-reasoning"; +let home: string; +let releaseSpend: (() => void) | undefined; +const provider = (name: string): OcxProviderConfig => ({ + adapter: "openai-responses", baseUrl: `https://${name}.example.test/v1`, + authMode: "key", apiKey: `synthetic-${name}`, decodesNativeCompactionBlobs: true, +}); +function bound(principal?: string, name = "first", thread = THREAD) { + const parsed = parseRequest({ model: "model-a", input: "synthetic" }); + parsed._reasoningReplayScope = { clientThreadId: thread, clientPrincipalId: principal }; + bindRouteReasoningReplayScope({ parsed, providerName: name, provider: provider(name), adapterName: "openai-responses" }); + return parsed; +} +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-replay-principal-")); + process.env.OPENCODEX_HOME = home; + clearReasoningReplayCacheForTests(); + resetThoughtSignatureReplayForTests(); +}); +afterEach(() => { + releaseSpend?.(); releaseSpend = undefined; + globalThis.fetch = originalFetch; + clearReasoningReplayCacheForTests(); + resetThoughtSignatureReplayForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +test.each(["principal-b", undefined])("a foreign serving commit cannot alter principal A's provenance (%s)", other => { + const a = bound("principal-a"); + commitReasoningReplayServingIdentity(a._reasoningReplayScope); + commitReasoningReplayServingIdentity(bound(other, "second")._reasoningReplayScope); + expect(bound("principal-a")._stripReasoningEncryptedContent).not.toBe(true); + const changed = bound("principal-a", "second"); + expect(changed._stripReasoningEncryptedContent).toBe(true); + expect(changed._dropForeignReasoningItemIds).toBe(true); + expect(bound(other, "second")._stripReasoningEncryptedContent).not.toBe(true); +}); + +test("keyless physical safeguards remain independent of authenticated callers", () => { + const keyless = bound(); + expect(keyless._reasoningReplayScope?.current).toBeDefined(); + commitReasoningReplayServingIdentity(keyless._reasoningReplayScope); + commitReasoningReplayServingIdentity(bound("principal-a", "second")._reasoningReplayScope); + expect(bound()._stripReasoningEncryptedContent).not.toBe(true); + expect(bound(undefined, "second")._stripReasoningEncryptedContent).toBe(true); + rememberReasoningForCall("call", "private", bound("principal-a")._reasoningReplayScope); + expect(peekReasoningForCall("call", keyless._reasoningReplayScope)).toBeUndefined(); + rememberReasoningForCall("call", "keyless", keyless._reasoningReplayScope); + expect(peekReasoningForCall("call", keyless._reasoningReplayScope)).toBeUndefined(); +}); + +test.each([false, true])("rejection memo stays in its principal namespace (selective=%s)", selective => { + const a = bound("principal-a"); + const fingerprints = selective ? new Set(["a".repeat(64)]) : undefined; + rememberReasoningReplayOpaqueBlobRejection(a._reasoningReplayScope, fingerprints); + expect(getReasoningReplayOpaqueBlobRejection(a._reasoningReplayScope)?.mode).toBe(selective ? "selective" : "blanket"); + expect(getReasoningReplayOpaqueBlobRejection(bound("principal-b")._reasoningReplayScope)).toBeUndefined(); + expect(getReasoningReplayOpaqueBlobRejection(bound()._reasoningReplayScope)).toBeUndefined(); + rememberReasoningReplayOpaqueBlobRejection(bound()._reasoningReplayScope); + expect(getReasoningReplayOpaqueBlobRejection(bound()._reasoningReplayScope)?.mode).toBe("blanket"); + expect(getReasoningReplayOpaqueBlobRejection(bound("principal-b")._reasoningReplayScope)).toBeUndefined(); +}); + +test("principal and thread tuple encoding is unambiguous", () => { + const a = bound('a","b', "first", "c"); + commitReasoningReplayServingIdentity(a._reasoningReplayScope); + expect(reasoningReplayServingIdentityChanged(bound("a", "second", 'b","c')._reasoningReplayScope)).toBe(false); + expect(reasoningReplayServingIdentityChanged(bound('a","b', "second", "c")._reasoningReplayScope)).toBe(true); +}); + +test.each(["principal-a", undefined])("strict Combo eligibility checks its own serving namespace (%s)", principal => { + commitReasoningReplayServingIdentity(bound(principal)._reasoningReplayScope); + const args = { + body: { tools: [{}], input: [{ type: "reasoning", encrypted_content: BLOB }] }, + clientThreadId: THREAD, clientPrincipalId: principal, providerName: "second", + provider: { ...provider("second"), preserveResponsesReasoningContent: true, requiresAdjacentResponsesToolResults: true }, + adapterName: "openai-responses", modelId: "model-a", + }; + expect(mandatoryResponsesReasoningReplayUnavailable(args)).toBe(true); + expect(mandatoryResponsesReasoningReplayUnavailable({ ...args, clientPrincipalId: "foreign-principal" })).toBe(false); +}); + +test("a foreign owner tag still strips a blob regardless of principal metadata", () => { + const parsed = bound("principal-a"); + const raw = { input: [{ type: "reasoning", encrypted_content: BLOB }] }; + parsed._rawBody = raw; + const owner = nativeReasoningOwnerForRoute({ provider: provider("second") }); + const tag = nativeReasoningTag(owner, BLOB); + expect(tag).toBeDefined(); + parsed._nativeReasoningReplay = new Map([[BLOB, tag!]]); + bindRouteReasoningReplayScope({ parsed, providerName: "first", provider: provider("first"), adapterName: "openai-responses" }); + expect(raw.input[0]!.encrypted_content).toBeUndefined(); +}); + +test("HTTP handler keeps recovery memoization for the admitted principal only", async () => { + releaseSpend = acquireOwnedSpendHome(); + const config = { port: 0, hostname: "127.0.0.1", defaultProvider: "first", + apiKeys: ["a", "b"].map(id => ({ id, name: id, key: `synthetic-caller-${id}`, createdAt: "2026-01-01" })), + providers: { first: provider("first") }, + } as OcxConfig; + const sent: boolean[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toStartWith("https://first.example.test/"); + const hasBlob = String(init?.body).includes(BLOB); + sent.push(hasBlob); + if (hasBlob) return Response.json({ error: { message: "The encrypted content could not be verified.", + type: "invalid_request_error", code: "invalid_encrypted_content" } }, { status: 400 }); + return Response.json({ id: `resp-${sent.length}`, object: "response", status: "completed", model: "model-a", output: [] }); + }) as typeof fetch; + for (const caller of ["a", "a", "b", undefined, undefined]) { + const req = new Request("http://localhost/v1/responses", { method: "POST", headers: { + "content-type": "application/json", "thread-id": THREAD, + ...(caller ? { "x-opencodex-api-key": `synthetic-caller-${caller}` } : {}), + }, body: JSON.stringify({ model: "first/model-a", stream: false, store: false, input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "before" }] }, + { type: "reasoning", content: [], summary: [], encrypted_content: BLOB }, + { type: "message", role: "user", content: [{ type: "input_text", text: "after" }] }, + ] }) }); + const admission = resolveResponsesApiAuth(req, { hostname: "127.0.0.1", apiKeys: config.apiKeys }); + expect(admission?.kind).toBe("loopback"); + const response = await handleResponses(req, config, { model: "", provider: "" }, { admission: admission! }); + expect(response.status).toBe(200); + await response.text(); + } + // Same-principal and same-keyless follow-ups strip preflight; each new namespace first recovers once. + expect(sent).toEqual([true, false, false, true, false, true, false, false]); +}); diff --git a/tests/responses/responses-translated-replay-principal.test.ts b/tests/responses/responses-translated-replay-principal.test.ts new file mode 100644 index 00000000000..2e7d1092edd --- /dev/null +++ b/tests/responses/responses-translated-replay-principal.test.ts @@ -0,0 +1,204 @@ +/** + * Translated ingresses (Chat Completions bridge, Claude Messages, routed Responses compaction) + * rebuild an internal Request without the caller's OCX credential. The replay principal must be + * resolved from the admitted client request first, or a bearer-only loopback caller silently + * loses its own retained reasoning. Foreign and keyless callers stay isolated. + */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { clearKeyCooldowns } from "../../src/providers/key-failover"; +import { clearReasoningReplayCacheForTests } from "../../src/responses/reasoning-replay-cache"; +import { clearResponseStateForTests, flushResponseState } from "../../src/responses/state"; +import { resetThoughtSignatureReplayForTests } from "../../src/responses/thought-signature-replay"; +import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; +import { resolveContextPrincipal, type DataPlaneAdmission } from "../../src/server/auth-cors"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import { handleClaudeMessages } from "../../src/server/claude-messages"; +import { clearComboRecallForTests } from "../../src/server/responses/combo-session-recall"; +import { handleResponsesCompact } from "../../src/server/responses/compact"; +import { handleResponses } from "../../src/server/responses/core"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +type Rec = Record; +type Caller = "owner" | "foreign" | undefined; +type Credential = "bearer" | "dedicated"; +/** Set per case: how the caller volunteers its OCX key on loopback. */ +let credential: Credential = "bearer"; +const MODEL = "deepseek-v4.1-flash"; +const PRIVATE = "synthetic owner reasoning"; +const CALL = "call_translated_principal"; +const LOOPBACK: DataPlaneAdmission = { kind: "loopback", source: "loopback" }; +const originalFetch = globalThis.fetch; +const previousHome = process.env.OPENCODEX_HOME; +let home: string | undefined; +let releaseSpend: (() => void) | undefined; +let outbound: Rec[] = []; + +function clearState() { + clearComboSelectionState(); clearComboTargetCooldowns(); clearComboRecallForTests(); clearKeyCooldowns(); + clearReasoningReplayCacheForTests(); resetThoughtSignatureReplayForTests(); +} + +function chatAnswer(body: Rec): Response { + const continuation = body.messages.some((message: Rec) => message.role === "tool"); + const message = continuation ? { role: "assistant", content: "continued" } + : { role: "assistant", content: null, reasoning_content: PRIVATE, tool_calls: [ + { id: CALL, type: "function", function: { name: "inspect", arguments: "{}" } }, + ] }; + const finish = continuation ? "stop" : "tool_calls"; + const usage = { prompt_tokens: 2, completion_tokens: 2, total_tokens: 4 }; + if (body.stream !== true) { + return Response.json({ id: "chatcmpl-fixture", object: "chat.completion", model: MODEL, + choices: [{ index: 0, message, finish_reason: finish }], usage }); + } + const chunk = (delta: Rec, finishReason: string | null, extra: Rec = {}) => + `data: ${JSON.stringify({ id: "chatcmpl-fixture", object: "chat.completion.chunk", model: MODEL, + choices: [{ index: 0, delta, finish_reason: finishReason }], ...extra })}\n\n`; + const tool = message.tool_calls?.map((call, index) => ({ index, ...call })); + return new Response([ + chunk({ role: "assistant", ...(message.reasoning_content ? { reasoning_content: message.reasoning_content } : {}) }, null), + chunk({ ...(message.content ? { content: message.content } : {}), ...(tool ? { tool_calls: tool } : {}) }, null), + chunk({}, finish, { usage }), + "data: [DONE]\n\n", + ].join(""), { headers: { "content-type": "text/event-stream" } }); +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-translated-replay-principal-")); + process.env.OPENCODEX_HOME = home; + releaseSpend = acquireOwnedSpendHome(); + clearState(); clearResponseStateForTests(); + outbound = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + expect(url.hostname).toBe("first.example.test"); + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBe(`Bearer ${config.providers.first!.apiKey}`); + const body = JSON.parse(String(init?.body)) as Rec; + outbound.push(body); + return chatAnswer(body); + }) as typeof fetch; +}); + +afterEach(async () => { + releaseSpend?.(); releaseSpend = undefined; + closeRequestHistoryIndex(); await flushResponseState(); clearResponseStateForTests(); + globalThis.fetch = originalFetch; + clearState(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (home) removeTreeWithRetry(home); +}); + +const config: OcxConfig = { + port: 0, hostname: "127.0.0.1", defaultProvider: "first", + apiKeys: ["owner", "foreign"].map(id => ({ id, name: id, key: `synthetic-caller-${id}`, createdAt: "2026-01-01" })), + providers: { first: { adapter: "openai-chat", baseUrl: "https://first.example.test/v1", authMode: "key", + apiKey: "synthetic-provider-first", models: [MODEL] } }, + combos: { pair: { strategy: "failover", targets: [{ provider: "first", model: MODEL }] } }, +}; + +function headersFor(caller: Caller, thread: string, extra: Record = {}): Record { + // Non-owners also claim the owner's principal in a header; client data never selects it. + const spoof = caller === "owner" ? {} : { "x-opencodex-client-principal-id": OWNER_PRINCIPAL }; + return { "content-type": "application/json", "thread-id": thread, + ...(caller ? credential === "bearer" ? { authorization: `Bearer synthetic-caller-${caller}` } + : { "x-opencodex-api-key": `synthetic-caller-${caller}` } : {}), ...spoof, ...extra }; +} + +const OWNER_PRINCIPAL = resolveContextPrincipal(new Request("http://localhost/", { + headers: { authorization: `Bearer ${config.apiKeys!.find(entry => entry.id === "owner")!.key}` }, +}), config, LOOPBACK)!; + +/** The reasoning the provider received on the assistant tool-call message of the latest send. */ +function replayedReasoning(): unknown { + const last = outbound.at(-1)!; + return last.messages.find((message: Rec) => message.role === "assistant" && message.tool_calls)?.reasoning_content; +} + +const TOOL = { type: "function", function: { name: "inspect", parameters: { type: "object", properties: {} } } }; + +async function chat(caller: Caller, thread: string, seed: boolean) { + const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", headers: headersFor(caller, thread), + body: JSON.stringify({ model: "combo/pair", stream: false, tools: [TOOL], messages: seed + ? [{ role: "user", content: "inspect the fixture" }] + : [ + { role: "user", content: "inspect the fixture" }, + { role: "assistant", content: null, tool_calls: [{ id: CALL, type: "function", function: { name: "inspect", arguments: "{}" } }] }, + { role: "tool", tool_call_id: CALL, content: "done" }, + ] }), + }), config, { model: "", provider: "" }, { requestId: `chat-${crypto.randomUUID()}`, start: Date.now(), admission: LOOPBACK }); + expect(response.status).toBe(200); + await response.text(); +} + +async function claude(caller: Caller, thread: string, seed: boolean) { + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", headers: headersFor(caller, thread, { "anthropic-version": "2023-06-01" }), + body: JSON.stringify({ model: "combo/pair", max_tokens: 256, stream: false, + tools: [{ name: "inspect", input_schema: { type: "object", properties: {} } }], + messages: seed + ? [{ role: "user", content: "inspect the fixture" }] + : [ + { role: "user", content: "inspect the fixture" }, + { role: "assistant", content: [{ type: "tool_use", id: CALL, name: "inspect", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: CALL, content: "done" }] }, + ] }), + }), config, { model: "", provider: "" }, { requestId: `claude-${crypto.randomUUID()}`, start: Date.now(), admission: LOOPBACK }); + expect(response.status).toBe(200); + await response.text(); +} + +const RESPONSES_TOOL = { type: "function", name: "inspect", parameters: { type: "object", properties: {} } }; +const RESPONSES_HISTORY = [ + { type: "message", role: "user", content: [{ type: "input_text", text: "inspect the fixture" }] }, + { type: "function_call", call_id: CALL, name: "inspect", arguments: "{}" }, + { type: "function_call_output", call_id: CALL, output: "done" }, +]; + +async function compact(caller: Caller, thread: string, seed: boolean) { + if (seed) { + // The compaction handoff replays what a normal Responses turn retained for the same caller. + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: headersFor(caller, thread), + body: JSON.stringify({ model: "combo/pair", stream: false, store: false, tools: [RESPONSES_TOOL], input: "inspect the fixture" }), + }), config, { model: "", provider: "" }, { admission: LOOPBACK }); + expect(response.status).toBe(200); + await response.text(); + return; + } + const response = await handleResponsesCompact(new Request("http://localhost/v1/responses/compact", { + method: "POST", headers: headersFor(caller, thread), + body: JSON.stringify({ model: "combo/pair", tools: [RESPONSES_TOOL], input: RESPONSES_HISTORY }), + }), config, { model: "", provider: "" }, undefined, LOOPBACK); + expect(response.status).toBe(200); + await response.text(); +} + +const INGRESSES = [ + ["chat completions bridge combo", chat], + ["claude messages", claude], + ["responses compaction handoff", compact], +] as const; + +test.each(INGRESSES.flatMap(([name, ingress]) => (["bearer", "dedicated"] as const).map(kind => [name, kind, ingress] as const)))( + "%s keeps the %s-key loopback principal for retained reasoning", async (_name, kind, ingress) => { + credential = kind; + const thread = `translated-principal-${crypto.randomUUID()}`; + expect(OWNER_PRINCIPAL).toBeString(); + await ingress("owner", thread, true); + expect(outbound).toHaveLength(1); + await ingress("owner", thread, false); + expect(replayedReasoning()).toBe(PRIVATE); + await ingress("foreign", thread, false); + expect(replayedReasoning()).not.toBe(PRIVATE); + await ingress(undefined, thread, false); + expect(replayedReasoning()).not.toBe(PRIVATE); +}, 20_000); + diff --git a/tests/server/inference-client-encoder-delivery.test.ts b/tests/server/inference-client-encoder-delivery.test.ts index 0c549364408..073dbc7b784 100644 --- a/tests/server/inference-client-encoder-delivery.test.ts +++ b/tests/server/inference-client-encoder-delivery.test.ts @@ -196,6 +196,7 @@ describe("deliverClientEncodedResponse", () => { // has to survive in the proxy-side replay cache instead, which the terminal fold fills here // exactly as the bridge fills it on the Responses path. const scope: OcxReasoningReplayScopeRef = { + clientPrincipalId: "principal-direct-hidden", clientThreadId: "direct-hidden-thread", current: { providerName: "routed", @@ -231,6 +232,7 @@ describe("deliverClientEncodedResponse", () => { // encoding reserves about ten times the text, so a block that fit the live stream overflowed // only there; the delivery then reported success without writing the replay cache. const scope: OcxReasoningReplayScopeRef = { + clientPrincipalId: "principal-direct-hidden-tight-budget", clientThreadId: "direct-hidden-tight-budget", current: { providerName: "routed", diff --git a/tests/server/server-key-failover-e2e.test.ts b/tests/server/server-key-failover-e2e.test.ts index 0961cb7f55e..937589acc3e 100644 --- a/tests/server/server-key-failover-e2e.test.ts +++ b/tests/server/server-key-failover-e2e.test.ts @@ -708,10 +708,11 @@ describe("server 429 key failover (end-to-end)", () => { expect(loadConfig().providers.cancelled?.apiKey).toBe("key-canc-000111222333"); }); - test("reasoning replay misses after a 429 rotates to a different physical key", async () => { + test.each(["authenticated", "keyless"] as const)("reasoning replay misses after a 429 rotates to a different physical key (%s)", async (caller) => { const model = "reasoning-model"; const callId = "call_key_rotation"; const privateReasoning = "reasoning from physical key A"; + const callerKey = "synthetic-reasoning-replay-caller"; const seen: Array<{ auth: string; messages: Array> }> = []; upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, @@ -760,6 +761,9 @@ describe("server 429 key failover (end-to-end)", () => { }); const config: OcxConfig = { port: 0, hostname: "127.0.0.1", defaultProvider: "reasoning-pool", + ...(caller === "authenticated" ? { apiKeys: [{ + id: "reasoning-caller", name: "reasoning-caller", key: callerKey, createdAt: "2026-01-01", + }] } : {}), providers: { "reasoning-pool": { adapter: "openai-chat", @@ -780,6 +784,7 @@ describe("server 429 key failover (end-to-end)", () => { const headers = { "content-type": "application/json", "x-codex-parent-thread-id": "thread-key-rotation", + ...(caller === "authenticated" ? { "x-opencodex-api-key": callerKey } : {}), }; try { const first = await fetch(new URL("/v1/responses", server.url), { @@ -818,7 +823,7 @@ describe("server 429 key failover (end-to-end)", () => { ]); const replayed = seen[1]!.messages.find(message => Array.isArray(message.tool_calls)); const rotated = seen[2]!.messages.find(message => Array.isArray(message.tool_calls)); - expect(replayed?.reasoning_content).toBe(privateReasoning); + expect(replayed?.reasoning_content).toBe(caller === "authenticated" ? privateReasoning : " "); expect(rotated?.reasoning_content).toBe(" "); expect(rotated?.reasoning_content).not.toBe(privateReasoning); } finally {