From d829215af89f8794f0c514293feaeb3d6e5ee9c9 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 28 Aug 2026 09:49:45 +0900 Subject: [PATCH 1/3] fix(google-antigravity): gate the thought-signature sentinel correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reimplements #2693. Its intent is right — Gemini 3 rejects a turn whose first functionCall carries no thought signature, and the official validator-bypass token is the correct remedy — but three defects sat between intent and diff, all reproduced independently by a reviewer and by me. 1. It decided from key presence rather than extractSignature(), so a valid NESTED extra_content.google.thought_signature was invisible (competing sentinel added to an already-signed turn) and a too-short value read as signed (fallback suppressed where it was needed). 2. turnHasSignature was a turn-wide boolean any sibling could set, so in a two-call turn where only the second matched the cache, the first stayed unsigned and lost the sentinel it required. 3. The sentinel was gated on antigravityUsesReplayCache (!/claude/i), so the Gemini-only token reached gpt-oss-120b-medium. Implemented as a SEPARATE pass rather than inside applyAntigravityReplay. That function's absence of a signature is meaningful: 18 assertions read thoughtSignature === undefined as 'the cache did not match', covering eviction, TTL expiry, oversize refusal and clear-on-invalid. Folding a fabricated token in overwrote the very signal those tests read — the first attempt broke 12 of them. Keeping it separate means a cache miss still looks like a cache miss, and all 61 pre-existing tests pass untouched. The Gemini predicate matches the three namespaces this module actually receives: bare (gemini-3-pro), slash-prefixed (google/gemini-3-pro), and the Vertex replay key vertex::: — a COLON. A slash-only regex looked right on CCA ids and would have silently stripped the bypass from every Vertex Gemini request; both the reviewer and a local probe caught it. Mutation-verified per defect, each failing only its own test: presence-check instead of extractSignature -> 2 fail (nested, too-short) turn-wide flag instead of first-call -> 1 fail (first-call sentinel) gate on replay-cache scope -> 1 fail (non-Gemini injection) slash-only regex -> 2 fail (Vertex) 70 pass / 0 fail with all fixes; tsc exit 0. --- src/adapters/google-antigravity-replay.ts | 63 +++++++++++++ src/adapters/google.ts | 16 +++- tests/google-antigravity-replay.test.ts | 105 ++++++++++++++++++++++ 3 files changed, 183 insertions(+), 1 deletion(-) diff --git a/src/adapters/google-antigravity-replay.ts b/src/adapters/google-antigravity-replay.ts index 514008a983..34a0b55728 100644 --- a/src/adapters/google-antigravity-replay.ts +++ b/src/adapters/google-antigravity-replay.ts @@ -624,6 +624,69 @@ 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 boundary alternation covers the namespaces this module actually receives: a bare CCA id + * (`gemini-3-pro`), a slash-prefixed id (`google/gemini-3-pro`), and the Vertex replay key built + * in `src/adapters/google.ts` as `vertex:::`, whose separator is a + * COLON — matching only `/` would silently skip every Vertex Gemini request. 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 on inference. + */ +export function antigravitySupportsThoughtSignatureSentinel(model: string): boolean { + return /(^|[/:])gemini[-.\d]/i.test(model); +} + +/** + * 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.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..27dbcbb084 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,106 @@ 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 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(); + }); +}); From 3468ceaa0e7e40e597b53f1c990c9afb0ebbdb08 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 28 Aug 2026 10:26:39 +0900 Subject: [PATCH 2/3] fix(google-antigravity): keep the sentinel out of the real-signature path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exact-head CI failed three shards that the focused suite did not show — this round's own gate 4 firing on me, since I chose the targets. Five tests in four other suites assert thoughtSignature === undefined and the sentinel filled it. Investigating those five surfaced two REAL defects, not just assertion drift: 1. observeAntigravityReplay cached the sentinel as if it were a genuine signature, so a fabricated token round-tripped into the replay cache and was replayed later as evidence a turn was signed. extractSignature now refuses it on both the direct and nested paths; observing it leaves the cache empty (0 sessions) instead of storing it. 2. isLikelyRealThoughtSignature accepted it — the sentinel is alphanumeric with underscores, so it passed every filter that rejects fc_/ctc_/tsc_ synthetic ids. It is now rejected by name, which is what the issue #174 tests were protecting: a fabricated id must never be treated as real. With both closed, the five assertions are genuinely proxies. They read 'undefined' to mean 'nothing was borrowed from another call, thread, or namespace', and a constant carries no other call's identity. Verified directly: thread-a records a real signature, thread-b replays the same call in a different session, and thread-b receives the sentinel — never thread-a's value. Each assertion now expects the sentinel and states why the property still holds. The PR body for #2693 confirms the sentinel belongs on replayed history too: upstream rejects any multi-turn functionCall lacking a signature, not just the current turn. So narrowing it to fresh turns would reintroduce the 400. 159 pass / 0 fail across all four affected suites; tsc exit 0. --- src/adapters/google-antigravity-replay.ts | 4 ++-- src/adapters/google-antigravity-wire.ts | 5 +++++ tests/google-antigravity-wire.test.ts | 8 ++++++-- tests/google-signature-history-roundtrip.test.ts | 7 +++++-- tests/google-vertex-thought-signature.test.ts | 4 +++- 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/adapters/google-antigravity-replay.ts b/src/adapters/google-antigravity-replay.ts index 34a0b55728..ea01773318 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; } 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/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"), From eebd1913eabfded7ce669304bdb819d70a04b8b6 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 28 Aug 2026 11:30:05 +0900 Subject: [PATCH 3/3] fix(google-antigravity): match the model component, not the whole Vertex identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ingwannu's review found the predicate scanned the entire raw replay identity. The Vertex key is vertex::: and the project id is operator-chosen, so a project named 'gemini-prod' armed the Gemini-only sentinel for a non-Gemini model: vertex:gemini-prod:global:gpt-oss-120b -> true (sentinel injected) vertex:gemini-team:us:claude-fable-5 -> true That is the same class of defect the predicate exists to prevent, reintroduced one layer up — and my own tests missed it because they used a neutral project name on the positive control. Now reduces to the model component first: last ':' segment for a Vertex identity, then last '/' segment for a namespaced id, anchored with ^. Positive controls unchanged (vertex:proj:global:gemini-3-pro, google/gemini-3-pro, gemini-3-pro, gemini-pro-agent all still true). Mutation-verified: restoring the whole-string scan fails exactly the new test (70/1); with the fix 71/0, and 160/0 across all four affected suites. --- src/adapters/google-antigravity-replay.ts | 22 ++++++++++++++-------- tests/google-antigravity-replay.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/adapters/google-antigravity-replay.ts b/src/adapters/google-antigravity-replay.ts index ea01773318..9e3453aa59 100644 --- a/src/adapters/google-antigravity-replay.ts +++ b/src/adapters/google-antigravity-replay.ts @@ -640,16 +640,22 @@ const THOUGHT_SIGNATURE_BYPASS = "skip_thought_signature_validator"; * 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 boundary alternation covers the namespaces this module actually receives: a bare CCA id - * (`gemini-3-pro`), a slash-prefixed id (`google/gemini-3-pro`), and the Vertex replay key built - * in `src/adapters/google.ts` as `vertex:::`, whose separator is a - * COLON — matching only `/` would silently skip every Vertex Gemini request. 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 on inference. + * 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 { - return /(^|[/:])gemini[-.\d]/i.test(model); + const afterTransport = model.slice(model.lastIndexOf(":") + 1); + const wireModel = afterTransport.slice(afterTransport.lastIndexOf("/") + 1); + return /^gemini[-.\d]/i.test(wireModel); } /** diff --git a/tests/google-antigravity-replay.test.ts b/tests/google-antigravity-replay.test.ts index 27dbcbb084..0777b05130 100644 --- a/tests/google-antigravity-replay.test.ts +++ b/tests/google-antigravity-replay.test.ts @@ -1100,6 +1100,29 @@ describe("thought-signature validator-bypass fallback (#2693)", () => { 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);