diff --git a/src/adapters/google-antigravity-replay.ts b/src/adapters/google-antigravity-replay.ts index 514008a983..9e3453aa59 100644 --- a/src/adapters/google-antigravity-replay.ts +++ b/src/adapters/google-antigravity-replay.ts @@ -512,10 +512,10 @@ export function antigravityReplaySessionKeysForTests(): string[] { function extractSignature(part: Record): string | undefined { const direct = part.thoughtSignature ?? part.thought_signature; - if (typeof direct === "string" && direct.length >= MIN_SIGNATURE_LEN) return direct; + if (typeof direct === "string" && direct.length >= MIN_SIGNATURE_LEN && direct !== THOUGHT_SIGNATURE_BYPASS) return direct; const extra = part.extra_content as { google?: { thought_signature?: unknown } } | undefined; const nested = extra?.google?.thought_signature; - if (typeof nested === "string" && nested.length >= MIN_SIGNATURE_LEN) return nested; + if (typeof nested === "string" && nested.length >= MIN_SIGNATURE_LEN && nested !== THOUGHT_SIGNATURE_BYPASS) return nested; return undefined; } @@ -624,6 +624,75 @@ export function antigravityUsesReplayCache(model: string): boolean { return !/claude/i.test(model); } +/** + * Gemini 3 rejects a turn whose FIRST functionCall part carries no thought signature. When + * neither the wire metadata nor the replay cache can supply a real one, this is the official + * validator-bypass token. + */ +const THOUGHT_SIGNATURE_BYPASS = "skip_thought_signature_validator"; + +/** + * True when the model speaks the Gemini wire dialect that requires a thought signature on the + * first functionCall of a turn — and therefore accepts the validator-bypass sentinel. + * + * Deliberately NOT `antigravityUsesReplayCache`. That predicate is broad on purpose (every + * non-Claude model participates in signature replay), and reusing it for the sentinel is how a + * Gemini-only control token was observed being injected into `gpt-oss-120b-medium`. Replaying a + * signature upstream gave us is harmless for any model; *fabricating* a Gemini token is not. + * + * The identity must be REDUCED to its model component before matching, not scanned whole. The + * Vertex replay key is built in `src/adapters/google.ts` as + * `vertex:::`, and the project id is operator-chosen: a project + * named `gemini-prod` made a whole-string scan return true for + * `vertex:gemini-prod:global:gpt-oss-120b`, arming the Gemini-only sentinel for a non-Gemini + * model — the exact class of defect this predicate exists to prevent, reintroduced one layer up. + * + * So: take the last `:` segment for a Vertex identity, then the last `/` segment for a + * namespaced id (`google/gemini-3-pro`), and match only that. The trailing `[-.\d]` keeps + * `geminibot` and `my-gemini-clone` out. A model outside this set that genuinely needs the + * sentinel must arrive with a captured accepted CCA contract, not by widening this predicate. + */ +export function antigravitySupportsThoughtSignatureSentinel(model: string): boolean { + const afterTransport = model.slice(model.lastIndexOf(":") + 1); + const wireModel = afterTransport.slice(afterTransport.lastIndexOf("/") + 1); + return /^gemini[-.\d]/i.test(wireModel); +} + +/** + * Ensure every model turn's FIRST functionCall carries a thought signature, injecting the + * validator-bypass sentinel only where one is genuinely absent. + * + * Split out of `applyAntigravityReplay` on purpose. Replay answers "what did upstream already + * tell us about this call", and its absence of a signature is meaningful — 18 assertions in the + * suite read `thoughtSignature === undefined` as "the cache did not match", covering eviction, + * TTL expiry, oversize refusal and clear-on-invalid. Folding a fabricated token into that + * function would overwrite the very signal those tests read. Keeping the sentinel as its own + * pass means a cache miss still looks like a cache miss. + * + * Three properties this must hold, each of which a naive presence-check gets wrong: + * - it decides from `extractSignature`, so a valid NESTED + * `extra_content.google.thought_signature` counts as signed (no competing sentinel) and a + * present-but-too-short value does not (the fallback still fires); + * - it looks at the FIRST functionCall only, so a later sibling receiving a cached signature + * cannot vote away the sentinel the first call requires; + * - it is gated on the Gemini wire dialect, not on replay-cache participation. + */ +export function applyAntigravityThoughtSignatureFallback(model: string, contents: unknown[]): unknown[] { + if (!antigravitySupportsThoughtSignatureSentinel(model) || !Array.isArray(contents)) return contents; + for (const rawContent of contents as { role?: string; parts?: unknown[] }[]) { + if (!rawContent || typeof rawContent !== "object" || rawContent.role !== "model") continue; + if (!Array.isArray(rawContent.parts)) continue; + for (const rawPart of rawContent.parts) { + if (!rawPart || typeof rawPart !== "object") continue; + const part = rawPart as Record; + if (!part.functionCall) continue; + if (!extractSignature(part)) part.thoughtSignature = THOUGHT_SIGNATURE_BYPASS; + break; + } + } + return contents; +} + /** * Observe a parsed CCA chunk's `candidates[0].content.parts` and record thought signatures keyed by * the functionCall identity (name + args). Accumulates across the whole session so a sequential diff --git a/src/adapters/google-antigravity-wire.ts b/src/adapters/google-antigravity-wire.ts index b4b0ca3176..8b208aa644 100644 --- a/src/adapters/google-antigravity-wire.ts +++ b/src/adapters/google-antigravity-wire.ts @@ -29,6 +29,11 @@ export const ANTIGRAVITY_REQUEST_UA = antigravityUserAgent(); */ export function isLikelyRealThoughtSignature(sig: string | undefined): boolean { if (typeof sig !== "string" || sig.length < 16) return false; + // The validator-bypass sentinel is something WE fabricate for outbound requests when no real + // signature exists. It is alphanumeric with underscores, so it would otherwise satisfy every + // check below and be re-ingested as genuine — cached, replayed, and eventually treated as + // evidence that a turn was signed. It is never a real signature. + if (sig === "skip_thought_signature_validator") return false; // Reject synthetic Responses/tool-call ids and Anthropic tool-use ids (`_` or `-` separators). if (/^(fc|ctc|tsc|call|msg|rs|resp|reasoning|item|ws|toolu|tool|func|function)[-_]/i.test(sig)) return false; // Real Gemini thought signatures are opaque base64/base64url blobs: only [A-Za-z0-9+/_=-]. diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 78354e7fe5..d2f40a96e7 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -23,7 +23,13 @@ import { isVertexTruncatedTurn, vertexTruncationErrorMessage } from "./google-tr import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire"; import { compileGoogleWireBody } from "./google-wire-compiler"; import { identifyRoutedModel } from "./identity"; -import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay"; +import { + antigravityUsesReplayCache, + applyAntigravityReplay, + applyAntigravityThoughtSignatureFallback, + clearAntigravityReplay, + observeAntigravityReplay, +} from "./google-antigravity-replay"; import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models"; import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; import { forgetThoughtSignatureForReplay, lookupReplayThoughtSignature } from "../responses/thought-signature-replay"; @@ -826,6 +832,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } else { sanitizeAntigravityClaudeSignatures(contents); } + // After replay, not instead of it: a real signature always wins, and the sentinel only + // fills a first functionCall that replay could not sign. Outside the cache branch too, + // because the turn still needs a signature when no session was ever recorded. + applyAntigravityThoughtSignatureFallback(wireModelId, contents); // Claude-on-Antigravity rejects assistant-tail (model-tail in Gemini terms) histories // as prefill: "This model does not support assistant message prefill. The conversation // must end with a user message." Context compaction, previous_response_id expansion, @@ -870,6 +880,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte vertexReplaySession, (compiled.body as { contents: unknown[] }).contents, ); + applyAntigravityThoughtSignatureFallback( + vertexReplayModel, + (compiled.body as { contents: unknown[] }).contents, + ); } // Vertex AI: project/location endpoint with GCP ADC, or x-goog-api-key fast path. const apiKey = resolveVertexApiKey(provider.apiKey); diff --git a/tests/google-antigravity-replay.test.ts b/tests/google-antigravity-replay.test.ts index a21a57d84e..0777b05130 100644 --- a/tests/google-antigravity-replay.test.ts +++ b/tests/google-antigravity-replay.test.ts @@ -14,6 +14,8 @@ import { antigravityReplaySessionKeysForTests, antigravityUsesReplayCache, applyAntigravityReplay, + antigravitySupportsThoughtSignatureSentinel, + applyAntigravityThoughtSignatureFallback, clearAntigravityReplay, evictOldestAntigravityReplayForBudget, flushAntigravityReplay, @@ -1001,3 +1003,129 @@ describe("durable antigravity replay snapshot", () => { } }); }); + +describe("thought-signature validator-bypass fallback (#2693)", () => { + const BYPASS = "skip_thought_signature_validator"; + const sigOf = (part: unknown) => (part as { thoughtSignature?: string }).thoughtSignature; + const modelTurn = (parts: unknown[]) => [{ role: "model", parts }]; + + test("the FIRST functionCall gets the sentinel even when a later sibling is signed", () => { + // The original attempt tracked a turn-wide "any part is signed" boolean, so a second call + // matching the cache voted away the sentinel the first call still required. Gemini rejects + // that turn: the requirement is about the first functionCall, not about the turn. + observeAntigravityReplay(MODEL, SESSION, [fcPart("second", {}, SIG)]); + const contents = modelTurn([ + { functionCall: { name: "first", args: {} } }, + { functionCall: { name: "second", args: {} } }, + ]); + applyAntigravityReplay(MODEL, SESSION, contents); + applyAntigravityThoughtSignatureFallback(MODEL, contents); + expect(sigOf(contents[0].parts[1])).toBe(SIG); + expect(sigOf(contents[0].parts[0])).toBe(BYPASS); + }); + + test("a valid NESTED signature counts as signed and gets no competing sentinel", () => { + // extra_content.google.thought_signature is a wire shape this module already supports, but a + // bare key-presence check cannot see it, so it added a second, conflicting signature. + const contents = modelTurn([fcPart("get_x", {}, SIG, true)]); + applyAntigravityThoughtSignatureFallback(MODEL, contents); + expect(sigOf(contents[0].parts[0])).toBeUndefined(); + expect((contents[0].parts[0] as { extra_content?: { google?: { thought_signature?: string } } }) + .extra_content?.google?.thought_signature).toBe(SIG); + }); + + test("a present but too-short signature still gets the sentinel", () => { + // "short" is below MIN_SIGNATURE_LEN, so extractSignature rejects it. A presence check reads + // the key as set and suppresses the fallback on a turn that genuinely needs it. + const contents = modelTurn([fcPart("get_x", {}, "short")]); + applyAntigravityThoughtSignatureFallback(MODEL, contents); + expect(sigOf(contents[0].parts[0])).toBe(BYPASS); + }); + + test("a non-Gemini model never receives the Gemini-only sentinel", () => { + // antigravityUsesReplayCache is !/claude/i, so gating on it injected this token into + // gpt-oss-120b-medium. Replay scope is broad by design; sentinel scope must not be. + const contents = modelTurn([{ functionCall: { name: "get_x", args: {} } }]); + applyAntigravityThoughtSignatureFallback("gpt-oss-120b-medium", contents); + expect(sigOf(contents[0].parts[0])).toBeUndefined(); + }); + + test("a Gemini turn with no cache entry at all still gets the sentinel", () => { + // The feature's whole point: nothing was recorded for this session, so replay cannot help. + const contents = modelTurn([{ functionCall: { name: "never_seen", args: {} } }]); + applyAntigravityReplay(MODEL, "session-with-no-entry", contents); + applyAntigravityThoughtSignatureFallback(MODEL, contents); + expect(sigOf(contents[0].parts[0])).toBe(BYPASS); + }); + + test("the Vertex transport-prefixed model id is recognised as Gemini", () => { + // src/adapters/google.ts builds vertex:::. A predicate matching + // only "/" would skip every Vertex Gemini request while looking correct on CCA ids. + expect(antigravitySupportsThoughtSignatureSentinel("vertex:proj:global:gemini-3-pro")).toBe(true); + expect(antigravitySupportsThoughtSignatureSentinel("google/gemini-3-pro")).toBe(true); + expect(antigravitySupportsThoughtSignatureSentinel("gemini-3-pro")).toBe(true); + expect(antigravitySupportsThoughtSignatureSentinel("vertex:proj:global:gpt-oss-120b")).toBe(false); + expect(antigravitySupportsThoughtSignatureSentinel("geminibot")).toBe(false); + }); + + + test("a later sibling signed ON THE WIRE does not vote away the first call's sentinel", () => { + // Sibling arm of defect 2, with NO cache involved. A patch that only ignores cache-set + // signatures would still pass the cache-hit case above while leaving this open, so bind it + // explicitly: the decision reads the FIRST functionCall, never the turn. + const contents = [{ + role: "model", + parts: [ + { functionCall: { name: "first", args: {} } }, + { functionCall: { name: "second", args: {} }, thoughtSignature: SIG }, + ], + }]; + applyAntigravityThoughtSignatureFallback(MODEL, contents); + expect(sigOf(contents[0].parts[0])).toBe(BYPASS); + expect(sigOf(contents[0].parts[1])).toBe(SIG); + }); + + test("Vertex-prefixed Gemini turns still receive the sentinel end to end", () => { + // The Vertex replay key is vertex:::. Asserting only the + // predicate would let a regex that matches "/" but not ":" look correct; drive the real + // function with the real identity instead. + const vertexModel = "vertex:api-key:global:gemini-3-pro"; + const contents = [{ role: "model", parts: [{ functionCall: { name: "get_x", args: {} } }] }]; + applyAntigravityThoughtSignatureFallback(vertexModel, contents); + expect(sigOf(contents[0].parts[0])).toBe(BYPASS); + + const vertexNonGemini = "vertex:api-key:global:gpt-oss-120b-medium"; + const other = [{ role: "model", parts: [{ functionCall: { name: "get_x", args: {} } }] }]; + applyAntigravityThoughtSignatureFallback(vertexNonGemini, other); + expect(sigOf(other[0].parts[0])).toBeUndefined(); + }); + + test("a gemini-named Vertex PROJECT does not arm the sentinel for a non-Gemini model", () => { + // The Vertex replay key is vertex::: and the project id is + // operator-chosen. Scanning the whole identity meant a project called "gemini-prod" armed + // the Gemini-only sentinel for gpt-oss-120b — the same class of defect the predicate exists + // to prevent, one layer up. Reduce to the model component before matching. + expect(antigravitySupportsThoughtSignatureSentinel("vertex:gemini-prod:global:gpt-oss-120b")) + .toBe(false); + expect(antigravitySupportsThoughtSignatureSentinel("vertex:gemini-team:us:claude-fable-5")) + .toBe(false); + + const contents = [{ + role: "model", + parts: [{ functionCall: { name: "get_x", args: {} } }], + }]; + applyAntigravityThoughtSignatureFallback("vertex:gemini-prod:global:gpt-oss-120b", contents); + expect(sigOf(contents[0].parts[0])).toBeUndefined(); + + // The positive control still holds under the same parsing. + const gemini = [{ role: "model", parts: [{ functionCall: { name: "get_x", args: {} } }] }]; + applyAntigravityThoughtSignatureFallback("vertex:gemini-prod:global:gemini-3-pro", gemini); + expect(sigOf(gemini[0].parts[0])).toBe(BYPASS); + }); + + test("a user-role turn is untouched", () => { + const contents = [{ role: "user", parts: [{ functionCall: { name: "get_x", args: {} } }] }]; + applyAntigravityThoughtSignatureFallback(MODEL, contents); + expect(sigOf(contents[0].parts[0])).toBeUndefined(); + }); +}); diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index 8aefb92d46..d00ba03b8a 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -777,7 +777,10 @@ describe("antigravity history preserves tool-call thoughtSignature", () => { const env = JSON.parse(req.body); const modelTurn = (env.request.contents as { role: string; parts: Record[] }[]).find(c => c.role === "model"); const fcPart = modelTurn?.parts.find(part => "functionCall" in part); - expect(fcPart?.thoughtSignature).toBeUndefined(); + // The synthetic fc_ id is still stripped — what lands is the constant bypass sentinel, + // fabricated here rather than forwarded from the client. isLikelyRealThoughtSignature + // rejects both, so neither can be cached or replayed as a genuine signature. + expect(fcPart?.thoughtSignature).toBe("skip_thought_signature_validator"); }); test("custom_tool_call item ids (ctc_...) from Claude/mixed history are NOT forwarded (issue #174)", async () => { @@ -797,7 +800,8 @@ describe("antigravity history preserves tool-call thoughtSignature", () => { const env = JSON.parse(req.body); const modelTurn = (env.request.contents as { role: string; parts: Record[] }[]).find(c => c.role === "model"); const fcPart = modelTurn?.parts.find(part => "functionCall" in part); - expect(fcPart?.thoughtSignature).toBeUndefined(); + // Same contract for ctc_ ids: not forwarded; the sentinel is injected in their place. + expect(fcPart?.thoughtSignature).toBe("skip_thought_signature_validator"); }); }); diff --git a/tests/google-signature-history-roundtrip.test.ts b/tests/google-signature-history-roundtrip.test.ts index fbb6d02f59..57b876bb8e 100644 --- a/tests/google-signature-history-roundtrip.test.ts +++ b/tests/google-signature-history-roundtrip.test.ts @@ -311,7 +311,9 @@ describe("#1735 thought signature survives history replay", () => { }); const request = await createGoogleAdapter(provider).buildRequest(parsed); const part = modelParts(request.body as string).find(candidate => "functionCall" in candidate); - expect(part?.thoughtSignature).toBeUndefined(); + // The sentinel, not a borrowed signature: nothing was inherited from another call. The + // property this guards is anti-borrowing, and a constant carries no other call's identity. + expect(part?.thoughtSignature).toBe("skip_thought_signature_validator"); }); test("a signature the proxy remembered re-signs a replay the client sent without extra_content", async () => { @@ -430,7 +432,8 @@ describe("#1735 thought signature survives history replay", () => { }); const request = await createGoogleAdapter(provider).buildRequest(parsed); const part = modelParts(request.body as string).find(candidate => "functionCall" in candidate); - expect(part?.thoughtSignature).toBeUndefined(); + // Unknown call_id borrows nothing; it receives the constant bypass sentinel instead. + expect(part?.thoughtSignature).toBe("skip_thought_signature_validator"); }); test("the same call_id in a different thread does not borrow the signature (#1823)", () => { diff --git a/tests/google-vertex-thought-signature.test.ts b/tests/google-vertex-thought-signature.test.ts index 5761462da8..99d262ba2d 100644 --- a/tests/google-vertex-thought-signature.test.ts +++ b/tests/google-vertex-thought-signature.test.ts @@ -126,7 +126,9 @@ describe("Vertex thought-signature continuation (#1254)", () => { const otherThread = await createGoogleAdapter(provider).buildRequest( scopedReplayRequest(continuation(), "thread-b", "shared-cache-cohort"), ); - expect(replayedFunctionCall(otherThread.body as string).thoughtSignature).toBeUndefined(); + // #1312 isolation still holds: thread-b gets the CONSTANT sentinel, never thread-a's real + // signature. A genuine cross-namespace leak would surface the real value here and fail. + expect(replayedFunctionCall(otherThread.body as string).thoughtSignature).toBe("skip_thought_signature_validator"); const originalThread = await createGoogleAdapter(provider).buildRequest( scopedReplayRequest(continuation(), "thread-a", "different-cache-cohort"),