From ea0b7a097c33db0f384263ec2d3cf9d0e5fe251c Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 19 Aug 2026 09:47:45 +0900 Subject: [PATCH 1/3] =?UTF-8?q?docs(devlog):=20#2064=20RCA=20=E2=80=94=20f?= =?UTF-8?q?ixed=20on=20dev=20by=20#2016,=20version-ancestry=20proven?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../020_remote_reasoning_leak_rca.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 devlog/_plan/260819_triage_execution/020_remote_reasoning_leak_rca.md diff --git a/devlog/_plan/260819_triage_execution/020_remote_reasoning_leak_rca.md b/devlog/_plan/260819_triage_execution/020_remote_reasoning_leak_rca.md new file mode 100644 index 0000000000..95ede138a0 --- /dev/null +++ b/devlog/_plan/260819_triage_execution/020_remote_reasoning_leak_rca.md @@ -0,0 +1,25 @@ +# 020 — #2064 RCA: Remote raw-thinking leak (FIXED-ON-DEV) + +Reported on 2.24.2: Codex Remote paints raw Grok thinking live +(response.reasoning_text.delta), then swaps to the progress line leaving +italic fragments; stored items are summary:[] + content reasoning_text. + +RCA (sol lane + main verification, 2026-08-19): + +- v2.24.2 bridge emitted the raw channel: git show v2.24.2:src/bridge.ts has + response.reasoning_text.delta; fix commit 56752d7c5 (#2007, landed via + PR #2016 merge 891c8284b) is NOT an ancestor of v2.24.2, IS on dev. +- Current dev has no escape path for openai-chat routed models: the bridge + emits summary-channel deltas and summary-shaped items only + (src/bridge.ts:987,1016,591-606); the native-Responses rewrite covers WS + upstream, eager relay, HTTP SSE tee, and JSON reframing legs + (src/server/responses/core.ts:2835-3066, + src/server/responses-reasoning-summary-rewrite.ts:51-110). +- Suites: tests/responses-reasoning-summary-rewrite.test.ts + + tests/bridge.test.ts = 73 pass / 0 fail (fresh). + +Outcome: no new code. Issue #2064 closed as fixed-on-dev with a note asking +for on-device Remote verification on the next release build. Model-side +intermittent reasoning exposure (user caution) stays out of scope — our relay +provably converts the channel on every leg. + From 1d1d8c2bed3d6ec6099733255f1cd4ccb79e2115 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 19 Aug 2026 09:58:36 +0900 Subject: [PATCH 2/3] fix(replay): scope durable thought signatures per credential and bound persist visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The durable thought-signature store keyed entries by thread + destination + model but not by credential, so account A's Gemini signatures could replay under account B on the same destination (#1926 gap 1). Keys now carry a salted-HMAC credential identity (installation-local salt persisted beside the store, full 256-bit output — never an unsalted digest of key material) derived from the persisted OAuth account-slot id, the API key, or the Codex account handle; a scope that cannot produce one fails closed instead of sharing a durable slot. STORE_VERSION 3 -> 4 drops old rows on load (not upgradable — no credential info was recorded). Gap 2: terminal frames could become externally visible before the queued signature persist settled. All async terminal paths (completed, truncation and adapter-EOF incompletes, failed) and both buffered JSON returns now await a bounded (250ms) durability barrier; the sync stall-timeout kill path keeps the pre-existing best-effort behavior, documented in place. Closes #1926 --- src/bridge.ts | 12 ++ src/responses/reasoning-replay-cache.ts | 30 +++++ src/responses/thought-signature-replay.ts | 75 ++++++++++- src/server/responses/core.ts | 43 +++++++ src/types.ts | 6 + ...google-signature-history-roundtrip.test.ts | 2 + ...thought-signature-credential-scope.test.ts | 121 ++++++++++++++++++ 7 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 tests/thought-signature-credential-scope.test.ts diff --git a/src/bridge.ts b/src/bridge.ts index 0a1dc860b3..e5621faa80 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -15,6 +15,7 @@ import { rememberReasoningForCall } from "./responses/reasoning-replay-cache"; import { rememberAndSerializeExtraContent, rememberExtraContentForReplay, + awaitThoughtSignatureDurability, } from "./responses/thought-signature-replay"; import { resolveStallTimeoutSec } from "./stall-timeout"; import { usageDisplayTotalTokens } from "./usage/totals"; @@ -1202,6 +1203,9 @@ export function bridgeToResponsesSSE( if (truncationReasonFor(event.stopReason)) { // Upstream stopped before a normal completion. Surface as incomplete so the // client can distinguish a truncated/filtered turn from a finished one. + // #1926 gap 2: bound the window in which a handed-out thought signature is + // not yet durable before the turn becomes externally terminal. + await awaitThoughtSignatureDurability(); const response = { ...responseSnapshot("incomplete", finishedItems, event.endTurn), usage: responsesUsage(event.usage), @@ -1216,6 +1220,7 @@ export function bridgeToResponsesSSE( emit("response.incomplete", { response }); reportTerminal("incomplete"); } else { + await awaitThoughtSignatureDurability(); const response = { ...responseSnapshot("completed", finishedItems, event.endTurn), usage: responsesUsage(event.usage) }; options?.onCompletedResponse?.(response, event.providerState); options?.onUsage?.(event.usage); @@ -1236,6 +1241,7 @@ export function bridgeToResponsesSSE( if (currentWebSearch) closeCurrentWebSearch("failed", []); flushHiddenReasoningEnvelope(); options?.onUsage?.(event.usage); + await awaitThoughtSignatureDurability(); emit("response.incomplete", { response: { ...responseSnapshot("incomplete", finishedItems, event.endTurn), @@ -1264,6 +1270,7 @@ export function bridgeToResponsesSSE( if (currentWebSearch) closeCurrentWebSearch("failed", []); const failure = adapterFailureFromEvent(event); if (event.usage) options?.onUsage?.(event.usage); + await awaitThoughtSignatureDurability(); emit("response.failed", { response: { ...responseSnapshot("failed", finishedItems), @@ -1325,6 +1332,7 @@ export function bridgeToResponsesSSE( if (currentToolCall) failCurrentToolCall(); if (currentWebSearch) closeCurrentWebSearch("failed", []); options?.onUsage?.(undefined); + await awaitThoughtSignatureDurability(); emit("response.incomplete", { response: { ...responseSnapshot("incomplete", finishedItems), @@ -1365,6 +1373,10 @@ export function bridgeToResponsesSSE( flushHiddenRawReasoning(); if (currentToolCall) failCurrentToolCall(); if (currentWebSearch) closeCurrentWebSearch("failed", []); + // #1926 gap 2 residual: this beat callback is synchronous, so the durability + // barrier is not awaited on the stall-timeout kill path. The in-memory store is + // already updated; only a crash between here and the queued write loses it, + // which is the pre-#1926 status quo for an already-abnormal termination. emit("response.incomplete", { response: { ...responseSnapshot("incomplete", finishedItems), diff --git a/src/responses/reasoning-replay-cache.ts b/src/responses/reasoning-replay-cache.ts index 4d2e49698f..7f1d67c18c 100644 --- a/src/responses/reasoning-replay-cache.ts +++ b/src/responses/reasoning-replay-cache.ts @@ -123,6 +123,36 @@ export function durableReplayDestinationIdentity(baseUrl: string | undefined): s return `destination:${createHash("sha256").update("destination\0").update(canonical).digest("hex")}`; } +/** + * Restart-stable credential identity for the DURABLE thought-signature store (#1926). + * + * Unlike the destination, credential material may be secret (an API key), so a plain + * unsalted digest would turn the store file into an offline verifier for candidate keys. + * The identity is therefore an HMAC under a random salt persisted NEXT TO the store: the + * salt is not a secret escrow (it holds no credential material) but it makes every digest + * useless outside this installation. Full 256-bit output — no truncation. + * + * OAuth accounts use the persisted account-slot id (not the rotating token/generation): + * relinking a slot to a different upstream account keeps the id, but the upstream then + * validates signatures against the new credential and rejects stale ones — the same + * fail-closed backstop the destination identity relies on. Credential-scoped header + * overrides participate so two provider entries sharing one key but different + * authorization headers stay distinct, mirroring the process-local identity. + */ +export function durableReplayCredentialIdentity( + kind: "key" | "oauth" | "codex", + material: string | undefined, + headers: Record | undefined, + salt: Buffer | undefined, +): string | undefined { + if (!nonEmpty(material) || !salt || salt.length < 16) return undefined; + const overrides = credentialHeaderOverrides(headers); + return `credential:${createHmac("sha256", salt) + .update(`credential\0${kind}\0`) + .update(JSON.stringify([material, overrides])) + .digest("hex")}`; +} + /** Produce a non-reversible process-local identity for credential material. */ export function reasoningReplayCredentialIdentity( kind: "key" | "oauth" | "codex", diff --git a/src/responses/thought-signature-replay.ts b/src/responses/thought-signature-replay.ts index b4ffbab837..523b7947cf 100644 --- a/src/responses/thought-signature-replay.ts +++ b/src/responses/thought-signature-replay.ts @@ -20,18 +20,23 @@ * destination, adapter, model and credential — so a signature can only ever be replayed into * the turn that produced it. */ -import { readFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; +import { randomBytes } from "node:crypto"; import { join } from "node:path"; import { atomicWriteFileAsync, getConfigDir } from "../config"; import type { OcxProviderOpaqueToolCallMetadata, OcxReasoningReplayScopeRef } from "../types"; import { isCarryableSignature, responsesExtraContentFromProviderMetadata } from "./provider-opaque-metadata"; const STORE_FILE_NAME = "thought-signature-replay.json"; +const SALT_FILE_NAME = "thought-signature-replay.salt"; /** - * Bumped whenever `keyFor` changes shape. v3 added the durable destination identity, so a - * v2 file's keys can never match and are dropped on load instead of aging out invisibly. + * Bumped whenever `keyFor` changes shape. v3 added the durable destination identity; v4 + * added the salted durable credential identity (#1926), so a v3 file's keys can never + * match and are dropped on load instead of aging out invisibly. v3 rows carried no + * credential information, so they are not upgradable — the next Gemini turn re-accumulates + * its signatures (bounded, best-effort loss identical to the pre-store status quo). */ -const STORE_VERSION = 3; +const STORE_VERSION = 4; /** Bound on remembered entries; real signatures are a few hundred bytes, so this stays small. */ const MAX_ENTRIES = 16_384; @@ -64,6 +69,42 @@ function storePath(): string { return join(getConfigDir(), STORE_FILE_NAME); } +function saltPath(): string { + return join(getConfigDir(), SALT_FILE_NAME); +} + +let cachedSalt: Buffer | undefined; +let saltLoaded = false; + +/** + * Installation-local salt for the durable credential identity (#1926). Created once and + * persisted beside the store; losing it invalidates every stored key (the entries then + * never match and age out), which is safe — signatures re-accumulate per turn. + */ +export function thoughtSignatureReplaySalt(): Buffer | undefined { + if (saltLoaded) return cachedSalt; + saltLoaded = true; + try { + const raw = readFileSync(saltPath()); + if (raw.length >= 16) { + cachedSalt = raw; + return cachedSalt; + } + } catch { + // fall through to mint + } + try { + const minted = randomBytes(32); + writeFileSync(saltPath(), minted, { mode: 0o600 }); + cachedSalt = minted; + } catch { + // Unwritable config dir: no durable credential identity this process; the durable + // store fails closed (keyFor returns undefined) rather than keying under a shared id. + cachedSalt = undefined; + } + return cachedSalt; +} + function nonEmpty(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } @@ -84,6 +125,10 @@ function keyFor(callId: string, scope: OcxReasoningReplayScopeRef | undefined): || !nonEmpty(identity?.providerName) || !nonEmpty(identity?.adapterName) || !nonEmpty(identity?.modelId) + // v4 (#1926): a missing durable credential identity means we cannot isolate this + // entry per credential across restarts. Refusing the key is the fail-closed choice — + // "credential:unknown" would let two different credentials share one durable slot. + || !nonEmpty(identity?.credentialDurableIdentity) ) return undefined; return JSON.stringify([ scope.clientThreadId, @@ -93,6 +138,7 @@ function keyFor(callId: string, scope: OcxReasoningReplayScopeRef | undefined): // reasoning cache's randomBytes-keyed HMAC cannot. Without it, one provider NAME // serving two endpoints shares signatures across both. identity.providerDestinationDurableIdentity ?? "destination:unknown", + identity.credentialDurableIdentity, identity.adapterName, identity.modelId, callId, @@ -265,6 +311,8 @@ export function resetThoughtSignatureReplayForTests(): void { totalBytes = 0; loaded = false; persistChain = Promise.resolve(); + cachedSalt = undefined; + saltLoaded = false; } export function thoughtSignatureReplayCountForTests(): number { @@ -275,3 +323,22 @@ export function thoughtSignatureReplayCountForTests(): number { export function flushThoughtSignatureReplayForTests(): Promise { return persistChain; } + +/** + * Bounded commit barrier (#1926 gap 2). All durable writes serialize onto + * `persistChain`, so awaiting it (with a cap) before a turn's terminal frame becomes + * externally visible bounds the restart window in which a handed-out signature could + * be lost. Bounded BEST EFFORT: on timeout the turn proceeds — availability wins, and + * the miss cost is one turn's re-accumulation (the pre-store status quo). The + * in-memory map is updated synchronously at remember() time, so within one process + * lifetime replay never races this barrier at all. + */ +export function awaitThoughtSignatureDurability(capMs = 250): Promise { + let timer: ReturnType | undefined; + const cap = new Promise(resolve => { + timer = setTimeout(resolve, capMs); + }); + return Promise.race([persistChain, cap]).then(() => { + if (timer !== undefined) clearTimeout(timer); + }); +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 3ba073e20e..f4f452c89a 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -18,9 +18,11 @@ import { reasoningReplayCodexCredentialIdentity, reasoningReplayDestinationIdentity, durableReplayDestinationIdentity, + durableReplayCredentialIdentity, reasoningReplayKeyCredentialIdentity, reasoningReplayOAuthCredentialIdentity, } from "../../responses/reasoning-replay-cache"; +import { awaitThoughtSignatureDurability, thoughtSignatureReplaySalt } from "../../responses/thought-signature-replay"; import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction"; import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses"; import { @@ -326,11 +328,21 @@ function bindRouteReasoningReplayScope(args: { }): void { const { parsed, providerName, provider, adapterName } = args; let credentialIdentity: string | undefined; + let credentialDurableIdentity: string | undefined; + const durableSalt = thoughtSignatureReplaySalt(); if (provider.authMode === "oauth") { credentialIdentity = reasoningReplayOAuthCredentialIdentity( args.oauthCredentialSnapshot, provider.headers, ); + // The persisted account-slot id survives token refresh and restarts; the rotating + // generation deliberately does NOT participate (#1926 design: rotation-safe). + credentialDurableIdentity = durableReplayCredentialIdentity( + "oauth", + args.oauthCredentialSnapshot?.accountId, + provider.headers, + durableSalt, + ); } else if (provider.authMode === "forward") { const poolContext = args.codexAuthContext?.kind === "pool" || args.codexAuthContext?.kind === "main-pool" @@ -349,8 +361,26 @@ function bindRouteReasoningReplayScope(args: { writerGeneration: poolContext?.writerGeneration, headers: provider.headers, }); + // Durable identity requires a STABLE account handle. A bearer alone is rotating + // material; without an account id the durable store fails closed (audit blocker 2). + const codexDurableHandle = poolContext?.accountId + ?? poolContext?.chatgptAccountId + ?? args.forwardHeaders?.get("chatgpt-account-id") + ?? undefined; + credentialDurableIdentity = durableReplayCredentialIdentity( + "codex", + codexDurableHandle ?? undefined, + provider.headers, + durableSalt, + ); } else if (provider.authMode !== "local") { credentialIdentity = reasoningReplayKeyCredentialIdentity(provider); + credentialDurableIdentity = durableReplayCredentialIdentity( + "key", + nonEmptyProviderApiKey(provider), + provider.headers, + durableSalt, + ); } const providerDestinationIdentity = reasoningReplayDestinationIdentity(provider.baseUrl); bindReasoningReplayScope( @@ -363,11 +393,18 @@ function bindRouteReasoningReplayScope(args: { adapterName, modelId: parsed.modelId, credentialIdentity, + ...(credentialDurableIdentity ? { credentialDurableIdentity } : {}), } : undefined, ); } +function nonEmptyProviderApiKey(provider: OcxProviderConfig): string | undefined { + return typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0 + ? provider.apiKey + : undefined; +} + function isFixedCodexAccount(authCtx: CodexAuthContext): boolean { return (authCtx.kind === "pool" || authCtx.kind === "main-pool") && authCtx.fixedAccount === true; @@ -3616,6 +3653,10 @@ async function handleResponsesInner( responseStateOptions(adapterNeedsForcedContinuation(adapter.name)), ); } + // #1926 gap 2: the buffered path queued its signature persists inside + // buildResponseJSON; bound the durability window before the JSON becomes + // externally visible. + await awaitThoughtSignatureDurability(); return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } }); } @@ -4461,6 +4502,8 @@ async function handleResponsesInner( responseStateOptions(activeAdapter.name === "kiro"), ); } + // #1926 gap 2: same buffered-path durability bound as the primary branch. + await awaitThoughtSignatureDurability(); return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } }); } diff --git a/src/types.ts b/src/types.ts index 4abda9912d..77c88be200 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,12 @@ export interface OcxReasoningReplayIdentity { modelId: string; /** Opaque process-local credential identity; never a raw token or API key. */ credentialIdentity: string; + /** + * Salted-HMAC credential identity that survives restarts, for the durable + * thought-signature store (#1926). Absent when no durable identity could be + * derived — the durable store then refuses to key the entry (fail closed). + */ + credentialDurableIdentity?: string; } /** diff --git a/tests/google-signature-history-roundtrip.test.ts b/tests/google-signature-history-roundtrip.test.ts index d7bf8ecf0d..43c19014ea 100644 --- a/tests/google-signature-history-roundtrip.test.ts +++ b/tests/google-signature-history-roundtrip.test.ts @@ -54,6 +54,8 @@ function scopeFor( adapterName: "google", modelId, credentialIdentity: `cred-${providerName}`, + // v4 (#1926): the durable store fails closed without a durable credential identity. + credentialDurableIdentity: `credential:test-${providerName}`, }, }; } diff --git a/tests/thought-signature-credential-scope.test.ts b/tests/thought-signature-credential-scope.test.ts new file mode 100644 index 0000000000..ae86c25e8f --- /dev/null +++ b/tests/thought-signature-credential-scope.test.ts @@ -0,0 +1,121 @@ +/** + * #1926: the durable thought-signature store must isolate entries per CREDENTIAL, not + * just per destination, and must not expose signatures across a v3 -> v4 upgrade or an + * unscoped credential. Companion: the terminal-barrier bound on persist visibility. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + awaitThoughtSignatureDurability, + flushThoughtSignatureReplayForTests, + lookupReplayThoughtSignature, + rememberThoughtSignatureForReplay, + resetThoughtSignatureReplayForTests, + thoughtSignatureReplaySalt, +} from "../src/responses/thought-signature-replay"; +import { durableReplayCredentialIdentity, durableReplayDestinationIdentity } from "../src/responses/reasoning-replay-cache"; + +const SIG = "CiQAx-credential-scope-signature-0123456789abcdef"; + +function scopeFor(credential: string | undefined, threadId = "thread-a") { + return { + clientThreadId: threadId, + current: { + providerName: "google", + providerDestinationIdentity: "dest-google", + providerDestinationDurableIdentity: durableReplayDestinationIdentity("https://generativelanguage.googleapis.com"), + adapterName: "google", + modelId: "gemini-3.6-flash", + credentialIdentity: "cred-process-local", + ...(credential ? { credentialDurableIdentity: credential } : {}), + }, + }; +} + +describe("#1926 durable credential scope", () => { + let previousHome: string | undefined; + let testDir: string; + + beforeEach(() => { + resetThoughtSignatureReplayForTests(); + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-tsig-scope-")); + process.env.OPENCODEX_HOME = testDir; + }); + + afterEach(async () => { + await flushThoughtSignatureReplayForTests(); + resetThoughtSignatureReplayForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(testDir, { recursive: true, force: true }); + }); + + test("two credentials on one destination never share a signature", () => { + rememberThoughtSignatureForReplay("call_x", SIG, scopeFor("credential:aaa")); + expect(lookupReplayThoughtSignature("call_x", scopeFor("credential:aaa"))).toBe(SIG); + expect(lookupReplayThoughtSignature("call_x", scopeFor("credential:bbb"))).toBeUndefined(); + }); + + test("a scope without a durable credential identity fails closed (no store, no lookup)", () => { + const { result } = rememberThoughtSignatureForReplay("call_y", SIG, scopeFor(undefined)); + expect(result).toBe("unscoped"); + expect(lookupReplayThoughtSignature("call_y", scopeFor(undefined))).toBeUndefined(); + // And it cannot be read back under ANY concrete credential either. + expect(lookupReplayThoughtSignature("call_y", scopeFor("credential:aaa"))).toBeUndefined(); + }); + + test("v3 store rows are dropped on load (not upgradable without credential info)", async () => { + rememberThoughtSignatureForReplay("call_z", SIG, scopeFor("credential:aaa")); + await flushThoughtSignatureReplayForTests(); + const storeFile = join(testDir, "thought-signature-replay.json"); + const snapshot = JSON.parse(readFileSync(storeFile, "utf8")) as { version: number; entries: unknown[] }; + expect(snapshot.version).toBe(4); + // Regress the file to v3 and reload: entries must be dropped wholesale. + writeFileSync(storeFile, JSON.stringify({ ...snapshot, version: 3 })); + resetThoughtSignatureReplayForTests(); + expect(lookupReplayThoughtSignature("call_z", scopeFor("credential:aaa"))).toBeUndefined(); + }); + + test("v4 rows survive a reload under the same scope", async () => { + rememberThoughtSignatureForReplay("call_w", SIG, scopeFor("credential:aaa")); + await flushThoughtSignatureReplayForTests(); + resetThoughtSignatureReplayForTests(); + expect(lookupReplayThoughtSignature("call_w", scopeFor("credential:aaa"))).toBe(SIG); + }); + + test("salt is minted once, persisted, and produces stable full-width identities", () => { + const salt = thoughtSignatureReplaySalt(); + expect(salt).toBeDefined(); + const again = thoughtSignatureReplaySalt(); + expect(again).toBe(salt); + const id1 = durableReplayCredentialIdentity("key", "sk-secret", undefined, salt); + const id2 = durableReplayCredentialIdentity("key", "sk-secret", undefined, salt); + const other = durableReplayCredentialIdentity("key", "sk-other", undefined, salt); + expect(id1).toBe(id2); + expect(id1).not.toBe(other); + // Full 256-bit hex — no truncated verifier material. + expect(id1).toMatch(/^credential:[0-9a-f]{64}$/); + // Different header overrides are different credentials. + const withHeader = durableReplayCredentialIdentity("key", "sk-secret", { authorization: "Bearer x" }, salt); + expect(withHeader).not.toBe(id1); + }); + + test("durable identity is refused without a usable salt or material", () => { + const salt = thoughtSignatureReplaySalt(); + expect(durableReplayCredentialIdentity("key", undefined, undefined, salt)).toBeUndefined(); + expect(durableReplayCredentialIdentity("key", "sk-secret", undefined, undefined)).toBeUndefined(); + }); + + test("terminal barrier resolves after the queued persist settles (bounded)", async () => { + rememberThoughtSignatureForReplay("call_b", SIG, scopeFor("credential:aaa")); + await awaitThoughtSignatureDurability(); + // After the barrier the snapshot is on disk in the normal case. + const storeFile = join(testDir, "thought-signature-replay.json"); + const snapshot = JSON.parse(readFileSync(storeFile, "utf8")) as { entries: unknown[] }; + expect(snapshot.entries.length).toBe(1); + }); +}); + From 9697e89ffdff3afab54a6483b3c1d0e262f7e221 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 19 Aug 2026 10:07:03 +0900 Subject: [PATCH 3/3] fix(replay): wire durable lookup at adapter serialization; trust only pool account handles; harden salt perms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security-review fold-back: (1) the parser's durable lookup ran before the credential scope was bound, so the store was write-only at runtime — the google adapter now falls back to the durable store at serialization time, when the scope identity exists (regression-pinned); (2) the codex-forward durable handle no longer accepts the client-supplied chatgpt-account-id header (trusted pool context only; direct-forward fails closed); (3) the salt file re-asserts 0600 on every load. --- src/adapters/google.ts | 9 ++++++- src/responses/thought-signature-replay.ts | 5 +++- src/server/responses/core.ts | 8 +++--- ...google-signature-history-roundtrip.test.ts | 27 +++++++++++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 9a71dc9ea4..9043d1f929 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -26,6 +26,7 @@ import { identifyRoutedModel } from "./identity"; import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay"; import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models"; import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; +import { lookupReplayThoughtSignature } from "../responses/thought-signature-replay"; import { isTranslatorBudgetExceededError, retainTranslatedEventBatch, @@ -215,7 +216,13 @@ function messagesToGeminiFormat( const part: Record = { functionCall }; // Prefer the metadata that travelled with this exact call; fall back to the legacy // field for callers that have not been migrated. Never merge or synthesize. - const signature = tc.providerMetadata?.google?.thoughtSignature ?? tc.thoughtSignature; + // Final fallback (#1926): the durable store, read AT SERIALIZATION TIME. The + // Responses parser runs before the route/credential scope is bound, so its + // parse-time lookup can never hit; by the time this adapter serializes, the + // credential-scoped identity is bound and the durable lookup is meaningful. + const signature = tc.providerMetadata?.google?.thoughtSignature + ?? tc.thoughtSignature + ?? lookupReplayThoughtSignature(tc.id, parsed._reasoningReplayScope); if (isLikelyRealThoughtSignature(signature)) part.thoughtSignature = signature; parts.push(part); } diff --git a/src/responses/thought-signature-replay.ts b/src/responses/thought-signature-replay.ts index 523b7947cf..7df65d25e6 100644 --- a/src/responses/thought-signature-replay.ts +++ b/src/responses/thought-signature-replay.ts @@ -20,7 +20,7 @@ * destination, adapter, model and credential — so a signature can only ever be replayed into * the turn that produced it. */ -import { readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, readFileSync, writeFileSync } from "node:fs"; import { randomBytes } from "node:crypto"; import { join } from "node:path"; import { atomicWriteFileAsync, getConfigDir } from "../config"; @@ -87,6 +87,9 @@ export function thoughtSignatureReplaySalt(): Buffer | undefined { try { const raw = readFileSync(saltPath()); if (raw.length >= 16) { + // Re-assert owner-only permissions on every load: a pre-existing file may have + // been created before this guard or loosened by external tooling. + try { chmodSync(saltPath(), 0o600); } catch { /* best effort on exotic filesystems */ } cachedSalt = raw; return cachedSalt; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index f4f452c89a..8d2906e536 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -361,11 +361,13 @@ function bindRouteReasoningReplayScope(args: { writerGeneration: poolContext?.writerGeneration, headers: provider.headers, }); - // Durable identity requires a STABLE account handle. A bearer alone is rotating - // material; without an account id the durable store fails closed (audit blocker 2). + // Durable identity requires a STABLE, TRUSTED account handle. Pool context comes from + // our own account store; a client-supplied chatgpt-account-id header is attacker + // -influenceable bucket selection and a bearer alone is rotating material — both are + // refused, so direct-forward turns get no durable scope (fail closed; the in-process + // cache still covers same-process replay). const codexDurableHandle = poolContext?.accountId ?? poolContext?.chatgptAccountId - ?? args.forwardHeaders?.get("chatgpt-account-id") ?? undefined; credentialDurableIdentity = durableReplayCredentialIdentity( "codex", diff --git a/tests/google-signature-history-roundtrip.test.ts b/tests/google-signature-history-roundtrip.test.ts index 43c19014ea..499c7c8c3f 100644 --- a/tests/google-signature-history-roundtrip.test.ts +++ b/tests/google-signature-history-roundtrip.test.ts @@ -337,4 +337,31 @@ describe("#1735 thought signature survives history replay", () => { resetThoughtSignatureReplayForTests(); expect(lookupReplayThoughtSignature("call_dest_restart", scopeFor())).toBe(SIGNATURE); }); + + test("adapter serialization reads the durable store with the post-parse bound scope (#1926 wiring)", async () => { + // The server parses BEFORE the route/credential scope exists, so the parser's own + // lookup cannot hit; the google adapter's serialization-time fallback must read the + // durable store once the scope identity has been bound. + rememberThoughtSignatureForReplay("call_wire_1", SIGNATURE, scopeFor()); + await flushThoughtSignatureReplayForTests(); + const scope: { clientThreadId: string; current?: unknown } = { clientThreadId: "thread-a" }; + const parsed = parseRequestScoped({ + model: MODEL, + stream: false, + input: [ + { role: "user", content: [{ type: "input_text", text: "run pwd" }] }, + { type: "function_call", call_id: "call_wire_1", name: "shell_command", arguments: "{}" }, + { type: "function_call_output", call_id: "call_wire_1", output: "ok" }, + ], + tools: [{ type: "function", name: "shell_command", description: "run", parameters: { type: "object" } }], + }, scope as never); + // Identity binds after parse, as bindRouteReasoningReplayScope does server-side. + scope.current = scopeFor().current; + parsed._reasoningReplayScope = scope as never; + const adapter = createGoogleAdapter(provider); + const req = await adapter.buildRequest(parsed, { headers: new Headers() }); + const parts = modelParts(String(req.body)); + const fnPart = parts.find(p => (p as { functionCall?: unknown }).functionCall) as { thoughtSignature?: string } | undefined; + expect(fnPart?.thoughtSignature).toBe(SIGNATURE); + }); });