fix(google): honor Cloud SDK credential location and Vertex billing project (#118745)

This commit is contained in:
Peter Steinberger
2026-08-03 10:47:13 -07:00
committed by GitHub
parent 10b0b7b7c4
commit 8eaf917efb
7 changed files with 446 additions and 28 deletions
+81 -1
View File
@@ -1,5 +1,5 @@
// Google tests cover index plugin behavior.
import { mkdtemp, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Context, Model } from "openclaw/plugin-sdk/llm";
@@ -327,6 +327,86 @@ describe("google provider plugin hooks", () => {
).toBe("gcp-vertex-credentials");
});
it("prefers relocated Google Cloud SDK ADC over the home fallback", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-cloud-sdk-"));
const cloudSdkDir = path.join(tempDir, "cloud-sdk");
const homeCredentialsDir = path.join(tempDir, "home", ".config", "gcloud");
await Promise.all([
mkdir(cloudSdkDir, { recursive: true }),
mkdir(homeCredentialsDir, { recursive: true }),
]);
const relocatedCredentialsPath = path.join(cloudSdkDir, "application_default_credentials.json");
const homeCredentialsPath = path.join(
homeCredentialsDir,
"application_default_credentials.json",
);
await Promise.all([
writeFile(
relocatedCredentialsPath,
JSON.stringify({
type: "authorized_user",
client_id: "fixture-client",
client_secret: "fixture-secret",
refresh_token: "fixture-refresh",
}),
"utf8",
),
writeFile(homeCredentialsPath, JSON.stringify({ type: "unsupported" }), "utf8"),
]);
const { providers } = await registerProviderPlugin({
plugin: googleProviderPlugin,
id: "google",
name: "Google Provider",
});
const provider = requireRegisteredProvider(providers, "google-vertex");
const env = {
CLOUDSDK_CONFIG: cloudSdkDir,
HOME: path.join(tempDir, "home"),
GOOGLE_CLOUD_PROJECT: "fixture-project",
GOOGLE_CLOUD_LOCATION: "global",
};
expect(provider.resolveConfigApiKey?.({ provider: "google-vertex", env })).toBe(
"gcp-vertex-credentials",
);
expect(googleProviderDiscovery.resolveConfigApiKey?.({ provider: "google-vertex", env })).toBe(
"gcp-vertex-credentials",
);
expect(
provider.resolveConfigApiKey?.({
provider: "google-vertex",
env: { ...env, GOOGLE_APPLICATION_CREDENTIALS: homeCredentialsPath },
}),
).toBeUndefined();
await writeFile(
homeCredentialsPath,
JSON.stringify({
type: "authorized_user",
client_id: "stale-client",
client_secret: "stale-secret",
refresh_token: "stale-refresh",
}),
"utf8",
);
const missingRelocatedCredentialsEnv = {
...env,
CLOUDSDK_CONFIG: path.join(tempDir, "missing-cloud-sdk"),
};
expect(
provider.resolveConfigApiKey?.({
provider: "google-vertex",
env: missingRelocatedCredentialsEnv,
}),
).toBeUndefined();
expect(
googleProviderDiscovery.resolveConfigApiKey?.({
provider: "google-vertex",
env: missingRelocatedCredentialsEnv,
}),
).toBeUndefined();
});
it("owns Gemini tool schema normalization for direct and CLI providers", async () => {
const { providers } = await registerProviderPlugin({
plugin: googleProviderPlugin,
+26
View File
@@ -5,6 +5,15 @@ import type { JsonSchemaObject } from "openclaw/plugin-sdk/json-schema-runtime";
import { describe, expect, it } from "vitest";
type GoogleManifest = {
setup?: {
providers?: Array<{
id?: string;
authEvidence?: Array<{
fileEnvVar?: string;
fallbackPaths?: string[];
}>;
}>;
};
providerAuthChoices?: Array<{
provider?: string;
method?: string;
@@ -86,6 +95,23 @@ function loadManifest(): GoogleManifest {
}
describe("google manifest model catalog", () => {
it("checks relocated Cloud SDK ADC before platform-specific fallback paths", () => {
const vertex = loadManifest().setup?.providers?.find(
(provider) => provider.id === "google-vertex",
);
expect(vertex?.authEvidence).toEqual([
expect.objectContaining({
fileEnvVar: "GOOGLE_APPLICATION_CREDENTIALS",
fallbackPaths: [
"${CLOUDSDK_CONFIG}/application_default_credentials.json",
"${HOME}/.config/gcloud/application_default_credentials.json",
"${APPDATA}/gcloud/application_default_credentials.json",
],
}),
]);
});
it("offers Google AI Studio API keys without consumer CLI OAuth", () => {
const choices = loadManifest().providerAuthChoices ?? [];
+1
View File
@@ -669,6 +669,7 @@
"type": "local-file-with-env",
"fileEnvVar": "GOOGLE_APPLICATION_CREDENTIALS",
"fallbackPaths": [
"${CLOUDSDK_CONFIG}/application_default_credentials.json",
"${HOME}/.config/gcloud/application_default_credentials.json",
"${APPDATA}/gcloud/application_default_credentials.json"
],
+128 -1
View File
@@ -137,7 +137,11 @@ async function runGoogleVertexStreamResult(params: {
return stream.result();
}
async function useGoogleAuthorizedUserCredentials(label: string, refreshToken: string) {
async function useGoogleAuthorizedUserCredentials(
label: string,
refreshToken: string,
quotaProjectId?: string,
) {
const tempDir = await mkdtemp(path.join(os.tmpdir(), `openclaw-google-vertex-${label}-`));
const credentialsPath = path.join(tempDir, "application_default_credentials.json");
await writeFile(
@@ -147,6 +151,7 @@ async function useGoogleAuthorizedUserCredentials(label: string, refreshToken: s
client_id: "client-id",
client_secret: "client-secret",
refresh_token: refreshToken,
...(quotaProjectId ? { quota_project_id: quotaProjectId } : {}),
}),
"utf8",
);
@@ -1469,6 +1474,37 @@ describe("google transport stream", () => {
expect(tokenFetchMock).not.toHaveBeenCalled();
});
it("never refreshes stale home ADC when the selected Cloud SDK directory has no credentials", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-stale-home-"));
const homeCredentialsDir = path.join(tempDir, "home", ".config", "gcloud");
await mkdir(homeCredentialsDir, { recursive: true });
await writeFile(
path.join(homeCredentialsDir, "application_default_credentials.json"),
JSON.stringify({
type: "authorized_user",
client_id: "stale-client",
client_secret: "stale-secret",
refresh_token: "stale-refresh",
}),
"utf8",
);
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", "");
vi.stubEnv("CLOUDSDK_CONFIG", path.join(tempDir, "missing-cloud-sdk"));
vi.stubEnv("HOME", path.join(tempDir, "home"));
vi.stubEnv("APPDATA", "");
googleAuthGetAccessTokenMock.mockResolvedValueOnce("fixture-google-auth-token");
const tokenFetchMock = vi.fn();
await expect(resolveGoogleVertexAuthorizedUserHeaders(tokenFetchMock)).resolves.toEqual({
Authorization: "Bearer fixture-google-auth-token",
});
expect(googleAuthMock).toHaveBeenCalledWith({
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
clientOptions: { transporterOptions: { timeout: 30_000 } },
});
expect(tokenFetchMock).not.toHaveBeenCalled();
});
it("bounds Google Vertex ADC files before google-auth-library reads them", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-adc-file-"));
const credentialsPath = path.join(tempDir, "application_default_credentials.json");
@@ -1593,6 +1629,97 @@ describe("google transport stream", () => {
expect(new Headers(guardedInit.headers).has("x-goog-api-key")).toBe(false);
});
it.each([
{
scenario: "authorized-user ADC quota project",
credentialType: "authorized_user",
credentialQuotaProject: "fixture-json-billing",
envQuotaProject: "",
expectedQuotaProject: "fixture-json-billing",
},
{
scenario: "environment quota project overriding authorized-user ADC",
credentialType: "authorized_user",
credentialQuotaProject: "fixture-json-billing",
envQuotaProject: "fixture-env-billing",
expectedQuotaProject: "fixture-env-billing",
},
{
scenario: "google-auth service-account ADC quota project",
credentialType: "service_account",
credentialQuotaProject: "fixture-service-billing",
envQuotaProject: "",
expectedQuotaProject: "fixture-service-billing",
},
{
scenario: "google-auth metadata ADC environment quota project",
credentialType: "metadata",
credentialQuotaProject: "",
envQuotaProject: "fixture-metadata-billing",
expectedQuotaProject: "fixture-metadata-billing",
},
])(
"forwards the $scenario on the actual Vertex request",
async ({ credentialType, credentialQuotaProject, envQuotaProject, expectedQuotaProject }) => {
const tokenFetchMock = vi.fn();
vi.stubEnv("GOOGLE_CLOUD_PROJECT", "fixture-project");
vi.stubEnv("GOOGLE_CLOUD_LOCATION", "global");
vi.stubEnv("GOOGLE_CLOUD_QUOTA_PROJECT", envQuotaProject);
if (credentialType === "authorized_user") {
await useGoogleAuthorizedUserCredentials(
"quota-authorized-user",
"fixture-refresh-token",
credentialQuotaProject,
);
tokenFetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ access_token: "fixture-vertex-token", expires_in: 3600 }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
} else if (credentialType === "service_account") {
const tempDir = await mkdtemp(
path.join(os.tmpdir(), "openclaw-google-vertex-quota-service-"),
);
const credentialsPath = path.join(tempDir, "application_default_credentials.json");
await writeFile(
credentialsPath,
JSON.stringify({
type: "service_account",
client_email: "fixture@example.invalid",
quota_project_id: credentialQuotaProject,
}),
"utf8",
);
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", credentialsPath);
googleAuthGetAccessTokenMock.mockResolvedValueOnce("fixture-vertex-token");
} else {
const tempDir = await mkdtemp(
path.join(os.tmpdir(), "openclaw-google-vertex-quota-metadata-"),
);
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", "");
vi.stubEnv("HOME", path.join(tempDir, "home"));
vi.stubEnv("APPDATA", "");
googleAuthGetAccessTokenMock.mockResolvedValueOnce("fixture-vertex-token");
}
guardedFetchMock.mockResolvedValueOnce(
buildSseResponse([
{
candidates: [{ content: { parts: [{ text: "ok" }] }, finishReason: "STOP" }],
},
]),
);
await runGoogleVertexStreamResult({ fetch: tokenFetchMock });
const guardedCall = requireMockCall(guardedFetchMock, 0, "guarded fetch");
expectHeaders(requireRequestInit(guardedCall, "guarded fetch"), {
Authorization: "Bearer fixture-vertex-token",
"x-goog-user-project": expectedQuotaProject,
});
},
);
it("strips redundant google provider prefixes from Google Vertex model paths", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-prefix-"));
vi.stubEnv("HOME", path.join(tempDir, "home"));
+24 -18
View File
@@ -132,6 +132,11 @@ function resolveGoogleApplicationCredentialsPath(
if (explicit) {
return existsSync(explicit) ? explicit : undefined;
}
const cloudSdkDir = normalizeOptionalString(env.CLOUDSDK_CONFIG);
if (cloudSdkDir) {
const cloudSdkFallback = path.join(cloudSdkDir, "application_default_credentials.json");
return existsSync(cloudSdkFallback) ? cloudSdkFallback : undefined;
}
const homeDir = normalizeOptionalString(env.HOME) ?? os.homedir();
const homeFallback = path.join(
homeDir,
@@ -449,22 +454,23 @@ export async function resolveGoogleVertexAuthorizedUserHeaders(
fetchImpl?: typeof fetch,
): Promise<Record<string, string>> {
const adcPath = resolveGoogleApplicationCredentialsPath();
let adcConfig: GoogleAdcConfig | undefined;
if (adcPath) {
adcConfig = readGoogleAdcCredentials(adcPath);
const userAdc = resolveGoogleAuthorizedUserCredentials(adcConfig);
if (userAdc) {
const token = await refreshGoogleVertexAuthorizedUserAccessToken({
credentialsPath: adcPath,
credentials: userAdc,
fetchImpl,
});
return { Authorization: `Bearer ${token}` };
}
}
// No file-based authorized_user ADC. Fall back to google-auth-library which
// handles GKE Workload Identity (metadata server), Workload Identity
// Federation (external_account), and service-account keys.
const token = await resolveGoogleVertexAccessTokenViaGoogleAuth(adcConfig);
return { Authorization: `Bearer ${token}` };
const adcConfig = adcPath ? readGoogleAdcCredentials(adcPath) : undefined;
const userAdc = adcConfig ? resolveGoogleAuthorizedUserCredentials(adcConfig) : undefined;
// Google auth owns metadata, federation, and service-account ADC variants.
const token =
userAdc && adcPath
? await refreshGoogleVertexAuthorizedUserAccessToken({
credentialsPath: adcPath,
credentials: userAdc,
fetchImpl,
})
: await resolveGoogleVertexAccessTokenViaGoogleAuth(adcConfig);
// Google auth gives the explicit billing project precedence over ADC metadata.
const quotaProject =
normalizeOptionalString(process.env.GOOGLE_CLOUD_QUOTA_PROJECT) ??
normalizeOptionalString((adcConfig as Record<string, unknown> | undefined)?.quota_project_id);
return {
Authorization: `Bearer ${token}`,
...(quotaProject ? { "x-goog-user-project": quotaProject } : {}),
};
}
+31 -8
View File
@@ -19,17 +19,33 @@ type ResolvedLocalProviderAuthEvidence = {
source: string;
};
function expandAuthEvidencePath(rawPath: string, env: NodeJS.ProcessEnv): string | undefined {
function expandAuthEvidencePath(
rawPath: string,
env: NodeJS.ProcessEnv,
): { path: string; explicitOverride: boolean } | undefined {
const trimmed = rawPath.trim();
if (!trimmed) {
return undefined;
}
const homeDir = normalizeOptionalPathInput(env.HOME) ?? os.homedir();
const appDataDir = normalizeOptionalPathInput(env.APPDATA);
if (trimmed.includes("${APPDATA}") && !appDataDir) {
return undefined;
}
return trimmed.replaceAll("${HOME}", homeDir).replaceAll("${APPDATA}", appDataDir ?? "");
let unresolvedPlaceholder = false;
let explicitOverride = false;
const expanded = trimmed.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/gu, (_match, name: string) => {
const value =
name === "HOME"
? (normalizeOptionalPathInput(env.HOME) ?? os.homedir())
: normalizeOptionalPathInput(env[name]);
if (!value) {
unresolvedPlaceholder = true;
return "";
}
if (name !== "HOME" && name !== "APPDATA") {
explicitOverride = true;
}
return value;
});
return unresolvedPlaceholder || expanded.includes("${")
? undefined
: { path: expanded, explicitOverride };
}
function hasRequiredAuthEvidenceEnv(
@@ -58,9 +74,16 @@ function hasLocalFileAuthEvidence(
}
for (const rawPath of evidence.fallbackPaths ?? []) {
const expandedPath = expandAuthEvidencePath(rawPath, env);
if (expandedPath && fs.existsSync(expandedPath)) {
if (!expandedPath) {
continue;
}
if (fs.existsSync(expandedPath.path)) {
return true;
}
// An explicit provider directory owns identity; never select stale platform credentials.
if (expandedPath.explicitOverride) {
return false;
}
}
return false;
}
@@ -1,6 +1,8 @@
/** Tests dynamic provider env-var discovery from plugin metadata. */
import fs from "node:fs";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { sanitizeEnvVars } from "../agents/sandbox/sanitize-env-vars.js";
import { resolveLocalProviderAuthEvidence } from "./provider-auth-evidence.js";
import {
getProviderEnvVars,
listKnownProviderAuthEnvVarNames,
@@ -287,6 +289,159 @@ describe("provider env vars dynamic manifest metadata", () => {
expect(snapshotOptions.preferPersisted).toBe(false);
});
it("expands provider-owned directory variables in manifest credential evidence", () => {
useRegistrySetupPlugin("external-cloud", "global", {
id: "external-cloud",
authEvidence: [
{
type: "local-file-with-env",
fallbackPaths: ["${EXTERNAL_CLOUD_CONFIG}/application_default_credentials.json"],
requiresAllEnv: ["EXTERNAL_CLOUD_PROJECT"],
credentialMarker: "external-cloud-local-credentials",
source: "external cloud credentials",
},
],
});
const evidence = resolveProviderAuthLookupMaps().authEvidenceMap["external-cloud"];
const expectedPath = "/fixture/cloud-sdk/application_default_credentials.json";
const existsSync = vi.spyOn(fs, "existsSync").mockImplementation((candidate) => {
return candidate === expectedPath;
});
try {
expect(
resolveLocalProviderAuthEvidence(evidence, {
EXTERNAL_CLOUD_CONFIG: "/fixture/cloud-sdk",
EXTERNAL_CLOUD_PROJECT: "fixture-project",
}),
).toEqual({
credentialMarker: "external-cloud-local-credentials",
source: "external cloud credentials",
});
expect(existsSync).toHaveBeenCalledWith(expectedPath);
} finally {
existsSync.mockRestore();
}
});
it("rejects stale home evidence when an explicit provider directory has no credentials", () => {
useRegistrySetupPlugin("external-cloud", "global", {
id: "external-cloud",
authEvidence: [
{
type: "local-file-with-env",
fallbackPaths: [
"${EXTERNAL_CLOUD_CONFIG}/credentials.json",
"${HOME}/credentials.json",
"${APPDATA}/credentials.json",
],
credentialMarker: "external-cloud-local-credentials",
},
],
});
const evidence = resolveProviderAuthLookupMaps().authEvidenceMap["external-cloud"];
const existsSync = vi.spyOn(fs, "existsSync").mockImplementation((candidate) => {
return candidate === "/fixture/home/credentials.json";
});
try {
expect(
resolveLocalProviderAuthEvidence(evidence, {
EXTERNAL_CLOUD_CONFIG: "/fixture/missing-cloud-sdk",
HOME: "/fixture/home",
APPDATA: "/fixture/appdata",
}),
).toBeNull();
expect(existsSync).toHaveBeenCalledOnce();
expect(existsSync).toHaveBeenCalledWith("/fixture/missing-cloud-sdk/credentials.json");
} finally {
existsSync.mockRestore();
}
});
it("preserves home and appdata fallback when no provider directory is selected", () => {
const fallbackPaths = [
"${EXTERNAL_CLOUD_CONFIG}/credentials.json",
"${HOME}/credentials.json",
"${APPDATA}/credentials.json",
];
const evidence = [
{
type: "local-file-with-env" as const,
fallbackPaths,
credentialMarker: "external-cloud-local-credentials",
},
];
const existsSync = vi.spyOn(fs, "existsSync").mockImplementation((candidate) => {
return (
candidate === "/fixture/home/credentials.json" ||
candidate === "/fixture/appdata/credentials.json"
);
});
try {
expect(resolveLocalProviderAuthEvidence(evidence, { HOME: "/fixture/home" })).toEqual({
credentialMarker: "external-cloud-local-credentials",
source: "local auth evidence",
});
expect(
resolveLocalProviderAuthEvidence(evidence, {
EXTERNAL_CLOUD_CONFIG: " ",
HOME: "/fixture/missing-home",
APPDATA: "/fixture/appdata",
}),
).toEqual({
credentialMarker: "external-cloud-local-credentials",
source: "local auth evidence",
});
} finally {
existsSync.mockRestore();
}
});
it.each([
{
scenario: "missing variable",
fallbackPath: "${EXTERNAL_CLOUD_CONFIG}/credentials.json",
env: {},
},
{
scenario: "blank variable",
fallbackPath: "${EXTERNAL_CLOUD_CONFIG}/credentials.json",
env: { EXTERNAL_CLOUD_CONFIG: " " },
},
{
scenario: "invalid variable name",
fallbackPath: "${EXTERNAL-CLOUD-CONFIG}/credentials.json",
env: { "EXTERNAL-CLOUD-CONFIG": "/fixture/cloud-sdk" },
},
{
scenario: "unterminated placeholder",
fallbackPath: "${EXTERNAL_CLOUD_CONFIG/credentials.json",
env: { EXTERNAL_CLOUD_CONFIG: "/fixture/cloud-sdk" },
},
])("rejects manifest credential evidence with a $scenario", ({ fallbackPath, env }) => {
const existsSync = vi.spyOn(fs, "existsSync").mockReturnValue(true);
try {
expect(
resolveLocalProviderAuthEvidence(
[
{
type: "local-file-with-env",
fallbackPaths: [fallbackPath],
credentialMarker: "external-cloud-local-credentials",
},
],
env,
),
).toBeNull();
expect(existsSync).not.toHaveBeenCalled();
} finally {
existsSync.mockRestore();
}
});
it("reuses the current compatible metadata snapshot for workspace auth evidence", () => {
pluginRegistryMocks.getCurrentPluginMetadataSnapshot.mockReturnValue(
metadataSnapshot(