fix(sse): sanitize non-ok Antigravity streaming error body (port from 9router#2461) (#7106)

Root cause: the STREAMING branch of AntigravityExecutor.executeOnce() had no
!response.ok check at all — it unconditionally wrapped the upstream response
body in a pass-through TransformStream, unlike the sibling non-streaming
branch which already built a sanitized error via buildAntigravityUpstreamError.
When Google's 403 error body was binary/non-UTF8 (observed: gzip-magic-byte
payloads), those raw bytes were forwarded verbatim, corrupting the
client-visible error message ('[ERROR] [403]: <control-byte garbage>').

Fix: add the same !response.ok guard to the streaming branch, routing through
buildAntigravityUpstreamError()/buildErrorBody() (hard rule #12) instead of
piping unknown bytes through as if they were an SSE stream.

Reported-by: Duongkhanhtool (https://github.com/decolua/9router/issues/2461)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-16 11:55:21 -03:00
committed by GitHub
parent 7123236012
commit bc6cd2a806
3 changed files with 92 additions and 0 deletions

View File

@@ -0,0 +1 @@
- **fix(sse):** Antigravity streaming requests that hit a non-ok upstream response (e.g. a 403) no longer pipe the raw upstream bytes straight through to the client — a binary/non-UTF8 error body (observed as gzip-magic-byte garbage) is now routed through the same sanitized `buildAntigravityUpstreamError()` path the non-streaming branch already used, instead of corrupting the client-visible error message. Regression guard: `tests/unit/antigravity-streaming-error-body-sanitized-2461.test.ts` — thanks @Duongkhanhtool

View File

@@ -1591,6 +1591,34 @@ export class AntigravityExecutor extends BaseExecutor {
};
}
// #2461: a non-ok upstream response (e.g. 403) must never be piped through the
// streaming pass-through below as if it were an SSE body. Google occasionally
// returns non-UTF8/binary error bodies (observed: gzip-magic-byte payloads) for
// 403s on this endpoint; reading/forwarding those raw bytes corrupts the
// client-visible error message. Mirror the non-streaming branch above and build
// a sanitized JSON error via buildAntigravityUpstreamError (hard rule #12)
// instead of streaming unknown bytes straight through.
if (!response.ok) {
const rawBody = await response
.clone()
.text()
.catch(() => "");
const errorBody = buildAntigravityUpstreamError(
response.status,
response.statusText,
rawBody
);
return {
response: new Response(JSON.stringify(errorBody), {
status: response.status,
headers: { "Content-Type": "application/json" },
}),
url,
headers: finalHeaders,
transformedBody: attachToolNameMap(transformedBody, requestToolNameMap),
};
}
// Streaming path: wrap the response body in a pass-through TransformStream
// that extracts remainingCredits from the final SSE chunk(s) without
// consuming the stream. The client receives the unmodified SSE data.

View File

@@ -0,0 +1,63 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts";
import {
clearAntigravityVersionCache,
seedAntigravityVersionCache,
} from "../../open-sse/services/antigravityVersion.ts";
// Ports decolua/9router#2461: a non-ok (e.g. 403) Antigravity upstream response in the
// STREAMING path was piped straight through to the client via a raw pass-through
// TransformStream, with no `response.ok` check at all — unlike the non-streaming path,
// which already builds a sanitized error via buildAntigravityUpstreamError. When the
// upstream 403 body is gzip-compressed (or otherwise binary/non-UTF8), those raw bytes
// end up surfaced verbatim in the client-visible error message, corrupting it (reporters
// saw literal control-byte garbage after "[ERROR] [403]:").
test.afterEach(() => {
clearAntigravityVersionCache();
});
test("AntigravityExecutor.execute (stream=true) sanitizes a non-ok upstream body instead of piping raw bytes", async () => {
const executor = new AntigravityExecutor();
const originalFetch = globalThis.fetch;
seedAntigravityVersionCache("2026.04.17-test");
// Simulate a gzip-compressed 403 body (magic bytes 0x1f 0x8b), the exact shape
// reported upstream — reading it as text without decoding produces garbage.
const binaryBody = new Uint8Array([0x1f, 0x8b, 0x08, 0x00, 0x02, 0xff, 0x52, 0x41, 0x4e]);
globalThis.fetch = async () =>
new Response(binaryBody, {
status: 403,
headers: { "Content-Type": "application/json" },
});
try {
const result = await executor.execute({
model: "antigravity/gemini-2.5-flash",
body: { request: { contents: [] } },
stream: true,
credentials: { accessToken: "token", projectId: "project-1" },
log: { debug() {}, warn() {} },
});
assert.equal(result.response.status, 403);
const bodyText = await result.response.text();
// The raw gzip magic bytes must never reach the client-visible error text.
assert.ok(
!bodyText.includes("\x1f\x8b"),
`expected sanitized error body, got raw bytes leaking through: ${JSON.stringify(bodyText)}`
);
// Must be routed through buildErrorBody()/buildAntigravityUpstreamError() — a clean,
// parseable JSON error shape (hard rule #12), not an arbitrary pass-through stream.
const parsed = JSON.parse(bodyText) as { error?: { message?: string } };
assert.ok(parsed.error?.message, "expected a structured error.message");
assert.match(parsed.error.message, /Antigravity upstream error \(403\)/);
} finally {
globalThis.fetch = originalFetch;
}
});