From e022754ef3586a168070e994844260483a6cd60f Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 22 Oct 2026 01:01:12 -0700 Subject: [PATCH 1/6] fix(chat): scope reasoning replay by caller principal --- src/responses/reasoning-replay-cache.ts | 3 ++- src/server/chat-native-reasoning.ts | 7 +++++-- src/server/chat-native.ts | 5 +++-- structure/transports/native-reasoning-retention.md | 5 +++++ tests/responses/chat-native-reasoning-replay.test.ts | 8 ++++++-- tests/responses/chat-native-reasoning-scope.test.ts | 11 +++++++---- 6 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/responses/reasoning-replay-cache.ts b/src/responses/reasoning-replay-cache.ts index 213fe24cd9f..5cc8b17cf3b 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, diff --git a/src/server/chat-native-reasoning.ts b/src/server/chat-native-reasoning.ts index 3cb90a15e13..387cdcc7fce 100644 --- a/src/server/chat-native-reasoning.ts +++ b/src/server/chat-native-reasoning.ts @@ -7,11 +7,14 @@ 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 && clientPrincipalId ? { clientThreadId, clientPrincipalId } : undefined; } /** Rebind before EVERY build: a key rotation must not retrieve another credential's reasoning. */ diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index b529ea2b089..8597b3b271d 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; @@ -340,7 +341,7 @@ 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, logIds?.admission)); let retainedRequestBytes = 0; const releaseRetainedRequest = () => { if (retainedRequestBytes === 0) return; diff --git a/structure/transports/native-reasoning-retention.md b/structure/transports/native-reasoning-retention.md index 38851fb44fa..0c5af99affe 100644 --- a/structure/transports/native-reasoning-retention.md +++ b/structure/transports/native-reasoning-retention.md @@ -12,6 +12,11 @@ `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. +Process-local reasoning replay cells are keyed by the authenticated caller principal as well as the +conversation, route, credential, model, adapter, and tool call. Native Chat and Responses both +resolve that principal from data-plane admission before capture or replay; keyless callers fail +closed rather than sharing a process-wide replay namespace. + `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/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..ee832da6088 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,13 +61,15 @@ 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(); + expect(nativeChatReplayScope(new Headers(), "fixture-principal")).toBeUndefined(); + expect(nativeChatReplayScope(new Headers({ "thread-id": "fixture-thread" }), undefined)).toBeUndefined(); const keyless = scope("fixture-thread", { ...PROVIDER, apiKey: undefined }); expect(keyless.current).toBeUndefined(); expect(reasoning(keyless)).toBe(" "); From 27dd8ddde05706e0a82eb0e9adad834d2d435ab9 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 22 Oct 2026 20:07:24 +0900 Subject: [PATCH 2/6] fix(replay): preserve physical safeguards across native routes --- src/responses/reasoning-replay-cache.ts | 4 +- src/server/chat-completions.ts | 3 + src/server/chat-native-reasoning.ts | 8 +- src/server/chat-native.ts | 12 ++- src/server/responses/compact.ts | 8 +- .../transports/native-reasoning-retention.md | 11 ++- .../bridge-raw-reasoning-hidden.test.ts | 1 + .../bridge-reasoning-replay-batch.test.ts | 1 + ...google-signature-history-roundtrip.test.ts | 18 ++++- .../reasoning-replay-identity.test.ts | 64 +++++++++++++--- .../reasoning-replay-robustness.test.ts | 4 +- tests/responses/chat-native-combo.test.ts | 75 ++++++++++++++++++- .../chat-native-reasoning-scope.test.ts | 8 +- 13 files changed, 185 insertions(+), 32 deletions(-) diff --git a/src/responses/reasoning-replay-cache.ts b/src/responses/reasoning-replay-cache.ts index 5cc8b17cf3b..8da3c7f881f 100644 --- a/src/responses/reasoning-replay-cache.ts +++ b/src/responses/reasoning-replay-cache.ts @@ -518,13 +518,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 9d1b2dfac1e..11be3c81750 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -512,6 +512,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 387cdcc7fce..1c60f1aa963 100644 --- a/src/server/chat-native-reasoning.ts +++ b/src/server/chat-native-reasoning.ts @@ -14,7 +14,10 @@ export function nativeChatReplayScope( const clientThreadId = reasoningReplayConversationIdFromResponsesRequest({ threadIdHeader: headers.get("thread-id"), sessionIdHeader: sessionIdHeaderFromRequest(headers), }); - return clientThreadId && clientPrincipalId ? { clientThreadId, clientPrincipalId } : undefined; + return clientThreadId ? { + clientThreadId, + ...(clientPrincipalId ? { clientPrincipalId } : {}), + } : undefined; } /** Rebind before EVERY build: a key rotation must not retrieve another credential's reasoning. */ @@ -45,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 8597b3b271d..6001433b998 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -210,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; } /** @@ -247,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", @@ -265,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), }; } @@ -341,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, resolveContextPrincipal(req, config, logIds?.admission)); + 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/responses/compact.ts b/src/server/responses/compact.ts index 169ce00a0a6..d040bf4925d 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`; diff --git a/structure/transports/native-reasoning-retention.md b/structure/transports/native-reasoning-retention.md index 0c5af99affe..4d5e8c35e29 100644 --- a/structure/transports/native-reasoning-retention.md +++ b/structure/transports/native-reasoning-retention.md @@ -12,10 +12,13 @@ `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. -Process-local reasoning replay cells are keyed by the authenticated caller principal as well as the -conversation, route, credential, model, adapter, and tool call. Native Chat and Responses both -resolve that principal from data-plane admission before capture or replay; keyless callers fail -closed rather than sharing a process-wide replay namespace. +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 +keyless caller cannot retrieve another principal's plaintext reasoning. This rule describes the +plaintext replay cache only. Opaque serving and rejection memos and Google's durable signature +bindings use their own physical identity and validation rules; they are not all caller-principal +scoped. `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. 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..4e442fb8fdb 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" }; 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/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-scope.test.ts b/tests/responses/chat-native-reasoning-scope.test.ts index ee832da6088..eb1295bed48 100644 --- a/tests/responses/chat-native-reasoning-scope.test.ts +++ b/tests/responses/chat-native-reasoning-scope.test.ts @@ -69,9 +69,11 @@ describe("native Chat reasoning policy and physical replay ownership", () => { expect(reasoning(owner)).toBe(" "); expect(reasoning(undefined)).toBe(" "); expect(nativeChatReplayScope(new Headers(), "fixture-principal")).toBeUndefined(); - expect(nativeChatReplayScope(new Headers({ "thread-id": "fixture-thread" }), undefined)).toBeUndefined(); - const keyless = scope("fixture-thread", { ...PROVIDER, apiKey: undefined }); - expect(keyless.current).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(); }); From 5bdd28edc2802679e5455b6d1fe164bb9729f52a Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 22 Oct 2026 20:43:55 +0900 Subject: [PATCH 3/6] test(replay): migrate remaining plaintext fixtures to caller scopes --- .../deepseek-reasoning-replay-gaps.test.ts | 31 +++++++++++-------- .../inference-client-encoder-delivery.test.ts | 2 ++ tests/server/server-key-failover-e2e.test.ts | 9 ++++-- 3 files changed, 27 insertions(+), 15 deletions(-) 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/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 { From b1cf901bc47d7b0c42588252189d6857edb5f685 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 22 Oct 2026 22:46:06 +0900 Subject: [PATCH 4/6] fix(replay): scope route metadata to admitted callers --- scripts/test-layout/layout-replay.json | 3 + scripts/test-layout/layout.json | 2 +- src/responses/reasoning-replay-cache.ts | 25 +-- .../responses/anthropic-thinking-recovery.ts | 1 + src/server/responses/core-combo.ts | 2 + src/server/responses/core-opaque-recovery.ts | 1 + src/server/responses/core-replay.ts | 3 +- .../transports/anthropic-thinking-recovery.md | 6 +- .../transports/native-reasoning-retention.md | 7 +- .../reasoning-replay-identity.test.ts | 2 +- tests/fixtures/test-layout-expected.json | 1 + ...ponses-anthropic-thinking-recovery.test.ts | 3 +- ...esponses-replay-metadata-principal.test.ts | 152 ++++++++++++++++++ 13 files changed, 187 insertions(+), 21 deletions(-) create mode 100644 scripts/test-layout/layout-replay.json create mode 100644 tests/responses/responses-replay-metadata-principal.test.ts diff --git a/scripts/test-layout/layout-replay.json b/scripts/test-layout/layout-replay.json new file mode 100644 index 00000000000..dfa55147548 --- /dev/null +++ b/scripts/test-layout/layout-replay.json @@ -0,0 +1,3 @@ +{ + "responses-replay-metadata-principal.test.ts": "responses" +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 5352e76d754..47ced51f4ed 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"], + "explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-replay.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", "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 8da3c7f881f..a718ac69764 100644 --- a/src/responses/reasoning-replay-cache.ts +++ b/src/responses/reasoning-replay-cache.ts @@ -167,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 { @@ -225,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; @@ -255,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; @@ -272,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) 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/core-combo.ts b/src/server/responses/core-combo.ts index 64e8d7943dc..84567c55e20 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -116,6 +116,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 @@ -465,6 +466,7 @@ export async function executeComboResponses( const unavailable = mandatoryResponsesReasoningReplayUnavailable({ body, clientThreadId: reasoningReplayConversationId, + clientPrincipalId: resolveContextPrincipal(req, config, options.admission), providerName: route.providerName, provider: route.provider, adapterName: route.provider.adapter, 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-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/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 4d5e8c35e29..e5f0767d499 100644 --- a/structure/transports/native-reasoning-retention.md +++ b/structure/transports/native-reasoning-retention.md @@ -16,9 +16,10 @@ Native Chat's retained plaintext `reasoning_content` replay cells require an aut 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 keyless caller cannot retrieve another principal's plaintext reasoning. This rule describes the -plaintext replay cache only. Opaque serving and rejection memos and Google's durable signature -bindings use their own physical identity and validation rules; they are not all caller-principal -scoped. +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. Google's durable signature bindings retain their own physical identity +and validation rules. Keyless processes sharing a thread remain indistinguishable to these memos. `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. diff --git a/tests/adapters/reasoning-replay-identity.test.ts b/tests/adapters/reasoning-replay-identity.test.ts index 4e442fb8fdb..44b8c789fba 100644 --- a/tests/adapters/reasoning-replay-identity.test.ts +++ b/tests/adapters/reasoning-replay-identity.test.ts @@ -576,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/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index bcda36ca8b1..5aca8d5cbe7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,6 @@ { "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", "bridge-terminal-refusal.test.ts": "adapters", + "responses-replay-metadata-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/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-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]); +}); From 3cee87369db267704a6042d6ff0800e1531492fa Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 22 Oct 2026 23:51:42 +0900 Subject: [PATCH 5/6] fix(replay): preserve admitted principal across bridge combo children --- scripts/test-layout/layout-replay.json | 3 +- src/server/responses/core-combo.ts | 7 +- src/server/responses/core-options.ts | 5 + src/server/responses/request-prepare.ts | 4 +- .../transports/native-reasoning-retention.md | 11 +- tests/fixtures/test-layout-expected.json | 1 + .../responses-combo-replay-principal.test.ts | 129 ++++++++++++++++++ 7 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 tests/responses/responses-combo-replay-principal.test.ts diff --git a/scripts/test-layout/layout-replay.json b/scripts/test-layout/layout-replay.json index dfa55147548..8b9ca2e3491 100644 --- a/scripts/test-layout/layout-replay.json +++ b/scripts/test-layout/layout-replay.json @@ -1,3 +1,4 @@ { - "responses-replay-metadata-principal.test.ts": "responses" + "responses-replay-metadata-principal.test.ts": "responses", + "responses-combo-replay-principal.test.ts": "responses" } diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 84567c55e20..8f29343f9dd 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -390,6 +390,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({ @@ -466,7 +470,7 @@ export async function executeComboResponses( const unavailable = mandatoryResponsesReasoningReplayUnavailable({ body, clientThreadId: reasoningReplayConversationId, - clientPrincipalId: resolveContextPrincipal(req, config, options.admission), + clientPrincipalId: replayPrincipal.clientPrincipalId, providerName: route.providerName, provider: route.provider, adapterName: route.provider.adapter, @@ -999,6 +1003,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-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/request-prepare.ts b/src/server/responses/request-prepare.ts index 6b33dd11f41..d25295b9ad6 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -601,7 +601,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/native-reasoning-retention.md b/structure/transports/native-reasoning-retention.md index e5f0767d499..74172dbc846 100644 --- a/structure/transports/native-reasoning-retention.md +++ b/structure/transports/native-reasoning-retention.md @@ -15,11 +15,14 @@ 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 -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 +bridge Combo carries the resolved principal, including its absence, through internal child options +after stripping provisional caller Authorization. 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. Google's durable signature bindings retain their own physical identity -and validation rules. Keyless processes sharing a thread remain indistinguishable to these memos. +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. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 5aca8d5cbe7..cd49df1cd5a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,6 +1,7 @@ { "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", "bridge-terminal-refusal.test.ts": "adapters", "responses-replay-metadata-principal.test.ts": "responses", + "responses-combo-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/responses/responses-combo-replay-principal.test.ts b/tests/responses/responses-combo-replay-principal.test.ts new file mode 100644 index 00000000000..b0f9203cd39 --- /dev/null +++ b/tests/responses/responses-combo-replay-principal.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +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 { repoPath } from "../helpers/repo-root"; +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; +let releaseSpend: (() => void) | undefined; +function clearRouteState() { + clearComboSelectionState(); clearComboTargetCooldowns(); clearComboRecallForTests(); clearKeyCooldowns(); +} +beforeEach(() => { + home = mkdtempSync(repoPath(".tmp", "bridge-replay-principal-")); + process.env.OPENCODEX_HOME = home; + releaseSpend = acquireOwnedSpendHome(); + clearRouteState(); clearReasoningReplayCacheForTests(); clearResponseStateForTests(); + resetThoughtSignatureReplayForTests(); +}); +afterEach(async () => { + 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); From f27d3c23cb13310b93a55d51fdd56405bb5d3459 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 00:15:25 +0900 Subject: [PATCH 6/6] test(replay): create combo fixture home independently of checkout state --- tests/responses/responses-combo-replay-principal.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/responses/responses-combo-replay-principal.test.ts b/tests/responses/responses-combo-replay-principal.test.ts index b0f9203cd39..0507538d579 100644 --- a/tests/responses/responses-combo-replay-principal.test.ts +++ b/tests/responses/responses-combo-replay-principal.test.ts @@ -1,5 +1,7 @@ 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"; @@ -12,7 +14,6 @@ import { clearComboRecallForTests } from "../../src/server/responses/combo-sessi import { handleResponses } from "../../src/server/responses/core"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; -import { repoPath } from "../helpers/repo-root"; import { removeTreeWithRetry } from "../helpers/remove-tree"; type Rec = Record; @@ -22,19 +23,21 @@ const PRIVATE = "synthetic owner reasoning"; const CALL = "call_bridge_principal"; const originalFetch = globalThis.fetch; const previousHome = process.env.OPENCODEX_HOME; -let home: string; +let home: string | undefined; let releaseSpend: (() => void) | undefined; function clearRouteState() { clearComboSelectionState(); clearComboTargetCooldowns(); clearComboRecallForTests(); clearKeyCooldowns(); } beforeEach(() => { - home = mkdtempSync(repoPath(".tmp", "bridge-replay-principal-")); + 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;