Skip to content

Commit 97d68b6

Browse files
authored
fix(google): handle compressed Vertex ADC token responses
Decode Google Vertex authorized_user ADC OAuth token refresh responses from bytes so gzip-compressed token payloads still expose access_token. Adds a regression test for the compressed token response path while preserving plain JSON handling and the custom fetch seam. Proof: OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs extensions/google/transport-stream.test.ts; pnpm exec oxfmt --check extensions/google/vertex-adc.ts extensions/google/transport-stream.test.ts; pnpm tsgo:extensions; git diff --check origin/main...HEAD; autoreview --mode branch --base origin/main. PR CI check-test-types failure was reproduced on current origin/main 607bbe4 and is unrelated to this two-file Google provider change. Thanks @liaoandi for the fix and live Google Vertex ADC proof.
1 parent 2fe7b5e commit 97d68b6

2 files changed

Lines changed: 110 additions & 3 deletions

File tree

extensions/google/transport-stream.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
33
import os from "node:os";
44
import path from "node:path";
5+
import { gzipSync } from "node:zlib";
56
import type { Model } from "openclaw/plugin-sdk/llm";
67
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
78

@@ -957,6 +958,64 @@ describe("google transport stream", () => {
957958
expect(result.content).toEqual([{ type: "text", text: "ok" }]);
958959
});
959960

961+
it("refreshes authorized_user ADC from a compressed token response", async () => {
962+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-adc-gzip-"));
963+
const credentialsPath = path.join(tempDir, "application_default_credentials.json");
964+
await writeFile(
965+
credentialsPath,
966+
JSON.stringify({
967+
type: "authorized_user",
968+
client_id: "client-id",
969+
client_secret: "client-secret",
970+
refresh_token: "gzip-refresh-token",
971+
}),
972+
"utf8",
973+
);
974+
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", credentialsPath);
975+
vi.stubEnv("GOOGLE_CLOUD_PROJECT", "vertex-project");
976+
vi.stubEnv("GOOGLE_CLOUD_LOCATION", "global");
977+
const tokenFetchMock = vi.fn().mockResolvedValue(
978+
new Response(
979+
gzipSync(JSON.stringify({ access_token: "ya29.gzip-token", expires_in: 3600 })),
980+
{
981+
status: 200,
982+
headers: {
983+
"content-encoding": "gzip",
984+
"content-type": "application/json",
985+
},
986+
},
987+
),
988+
);
989+
guardedFetchMock.mockResolvedValueOnce(
990+
buildSseResponse([
991+
{
992+
candidates: [{ content: { parts: [{ text: "ok" }] }, finishReason: "STOP" }],
993+
},
994+
]),
995+
);
996+
997+
const streamFn = createGoogleVertexTransportStreamFn();
998+
const stream = await Promise.resolve(
999+
streamFn(
1000+
buildGoogleVertexModel(),
1001+
{
1002+
messages: [{ role: "user", content: "hello", timestamp: 0 }],
1003+
} as Parameters<typeof streamFn>[1],
1004+
{
1005+
apiKey: "gcp-vertex-credentials",
1006+
fetch: tokenFetchMock,
1007+
} as Parameters<typeof streamFn>[2],
1008+
),
1009+
);
1010+
await stream.result();
1011+
1012+
expect(tokenFetchMock).toHaveBeenCalledTimes(1);
1013+
const guardedCall = requireMockCall(guardedFetchMock, 0, "guarded fetch");
1014+
expectHeaders(requireRequestInit(guardedCall, "guarded fetch"), {
1015+
Authorization: "Bearer ya29.gzip-token",
1016+
});
1017+
});
1018+
9601019
it("does not reuse authorized_user ADC tokens with unsafe expiry lifetimes", async () => {
9611020
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-unsafe-adc-"));
9621021
const credentialsPath = path.join(tempDir, "application_default_credentials.json");

extensions/google/vertex-adc.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
33
import { readFile } from "node:fs/promises";
44
import os from "node:os";
55
import path from "node:path";
6+
import { gunzipSync } from "node:zlib";
67
import {
78
asDateTimestampMs,
89
resolveExpiresAtMsFromDurationMs,
@@ -29,6 +30,13 @@ type GoogleVertexAdcToken = {
2930
expiresAtMs: number;
3031
};
3132

33+
type GoogleOauthTokenResponsePayload = {
34+
access_token?: unknown;
35+
expires_in?: unknown;
36+
error?: unknown;
37+
error_description?: unknown;
38+
};
39+
3240
const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
3341
const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
3442
const GOOGLE_VERTEX_OAUTH_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
@@ -238,16 +246,17 @@ async function refreshGoogleVertexAuthorizedUserAccessToken(params: {
238246
headers: { "Content-Type": "application/x-www-form-urlencoded" },
239247
body,
240248
});
241-
const payload = (await response.json().catch(() => undefined)) as
242-
| { access_token?: unknown; expires_in?: unknown; error?: unknown; error_description?: unknown }
243-
| undefined;
249+
const payload = await readGoogleOauthTokenResponsePayload(response);
244250
if (!response.ok) {
245251
const description = normalizeOptionalString(payload?.error_description);
246252
const code = normalizeOptionalString(payload?.error);
247253
throw new Error(
248254
`Google Vertex ADC token refresh failed: ${response.status}${code ? ` ${code}` : ""}${description ? ` (${description})` : ""}`,
249255
);
250256
}
257+
if (!payload) {
258+
throw new Error("Google Vertex ADC token refresh response could not be parsed as JSON.");
259+
}
251260
const token = normalizeOptionalString(payload?.access_token);
252261
if (!token) {
253262
throw new Error("Google Vertex ADC token refresh response did not include an access_token.");
@@ -265,6 +274,45 @@ async function refreshGoogleVertexAuthorizedUserAccessToken(params: {
265274
return token;
266275
}
267276

277+
async function readGoogleOauthTokenResponsePayload(
278+
response: Response,
279+
): Promise<GoogleOauthTokenResponsePayload | undefined> {
280+
const bytes = Buffer.from(await response.arrayBuffer());
281+
const text = decodeGoogleOauthTokenResponseBody(bytes, response.headers.get("content-encoding"));
282+
if (!text.trim()) {
283+
return undefined;
284+
}
285+
try {
286+
return JSON.parse(text) as GoogleOauthTokenResponsePayload;
287+
} catch {
288+
return undefined;
289+
}
290+
}
291+
292+
function decodeGoogleOauthTokenResponseBody(bytes: Buffer, contentEncoding: string | null): string {
293+
if (shouldGunzipGoogleOauthTokenResponse(bytes, contentEncoding)) {
294+
try {
295+
return gunzipSync(bytes).toString("utf8");
296+
} catch {
297+
return bytes.toString("utf8");
298+
}
299+
}
300+
return bytes.toString("utf8");
301+
}
302+
303+
function shouldGunzipGoogleOauthTokenResponse(
304+
bytes: Buffer,
305+
contentEncoding: string | null,
306+
): boolean {
307+
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
308+
return true;
309+
}
310+
return (contentEncoding ?? "")
311+
.split(",")
312+
.map((encoding) => encoding.trim().toLowerCase())
313+
.includes("gzip");
314+
}
315+
268316
async function resolveGoogleVertexAccessTokenViaGoogleAuth(): Promise<string> {
269317
// Lazy-import + cache so we don't pay the google-auth-library load cost on
270318
// gateway startup; only when we actually need a non-authorized_user token.

0 commit comments

Comments
 (0)