diff --git a/CHANGELOG.md b/CHANGELOG.md index a25fd8e3bfe..110e55f47d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,7 @@ Docs: https://docs.openclaw.ai - Agents/MCP: sort MCP tools deterministically by name so the tools block in API requests is stable across turns, preventing unnecessary prompt-cache busting from non-deterministic `listTools()` order. (#58037) Thanks @bcherny. - Infra/json-file: preserve symlink-backed JSON stores and Windows overwrite fallback when atomically saving small sync JSON state files. (#60589) Thanks @gumadeiras. - Matrix/credentials: read the current and legacy credential files directly during migration fallback so concurrent legacy rename races still resolve to the stored credentials. (#60591) Thanks @gumadeiras. +- Providers/Anthropic Vertex: read ADC files directly during auth discovery so explicit Google credentials and default ADC no longer depend on `existsSync` preflight checks. (#60592) Thanks @gumadeiras. ## 2026.4.2 diff --git a/extensions/anthropic-vertex/region.adc.test.ts b/extensions/anthropic-vertex/region.adc.test.ts new file mode 100644 index 00000000000..5313a266ced --- /dev/null +++ b/extensions/anthropic-vertex/region.adc.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { existsSyncMock, readFileSyncMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn(), + readFileSyncMock: vi.fn(), +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + existsSyncMock.mockImplementation((pathname) => actual.existsSync(pathname)); + readFileSyncMock.mockImplementation((pathname, options) => + String(pathname) === "/tmp/vertex-adc.json" + ? '{"project_id":"vertex-project"}' + : actual.readFileSync(pathname, options as never), + ); + return { + ...actual, + existsSync: existsSyncMock, + readFileSync: readFileSyncMock, + default: { + ...actual, + existsSync: existsSyncMock, + readFileSync: readFileSyncMock, + }, + }; +}); + +import { hasAnthropicVertexAvailableAuth, resolveAnthropicVertexProjectId } from "./region.js"; + +describe("anthropic-vertex ADC reads", () => { + afterEach(() => { + existsSyncMock.mockClear(); + readFileSyncMock.mockClear(); + }); + + it("reads explicit ADC credentials without an existsSync preflight", () => { + const env = { + GOOGLE_APPLICATION_CREDENTIALS: "/tmp/vertex-adc.json", + } as NodeJS.ProcessEnv; + + expect(resolveAnthropicVertexProjectId(env)).toBe("vertex-project"); + expect(hasAnthropicVertexAvailableAuth(env)).toBe(true); + expect(existsSyncMock).not.toHaveBeenCalled(); + expect(readFileSyncMock).toHaveBeenCalledWith("/tmp/vertex-adc.json", "utf8"); + }); +}); diff --git a/extensions/anthropic-vertex/region.test.ts b/extensions/anthropic-vertex/region.test.ts index 6200e5e9753..940ed0fe2c8 100644 --- a/extensions/anthropic-vertex/region.test.ts +++ b/extensions/anthropic-vertex/region.test.ts @@ -1,29 +1,38 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { resolveAnthropicVertexRegion, resolveAnthropicVertexRegionFromBaseUrl } from "./api.js"; -describe("anthropic-vertex ADC reads", () => { - afterEach(() => { - vi.resetModules(); - vi.doUnmock("node:fs"); +describe("anthropic vertex region helpers", () => { + it("accepts well-formed regional env values", () => { + expect( + resolveAnthropicVertexRegion({ + GOOGLE_CLOUD_LOCATION: "us-east1", + } as NodeJS.ProcessEnv), + ).toBe("us-east1"); }); - it("reads explicit ADC credentials without an existsSync preflight", async () => { - const existsSync = vi.fn(() => false); - const readFileSync = vi.fn((pathname: string) => - pathname.endsWith(".json") ? '{"project_id":"vertex-project"}' : "", + it("falls back to the default region for malformed env values", () => { + expect( + resolveAnthropicVertexRegion({ + GOOGLE_CLOUD_LOCATION: "us-central1.attacker.example", + } as NodeJS.ProcessEnv), + ).toBe("global"); + }); + + it("parses regional Vertex endpoints", () => { + expect( + resolveAnthropicVertexRegionFromBaseUrl("https://europe-west4-aiplatform.googleapis.com"), + ).toBe("europe-west4"); + }); + + it("treats the global Vertex endpoint as global", () => { + expect(resolveAnthropicVertexRegionFromBaseUrl("https://aiplatform.googleapis.com")).toBe( + "global", ); - vi.doMock("node:fs", () => ({ - existsSync, - readFileSync, - })); + }); - const region = await import("./region.js"); - const env = { - GOOGLE_APPLICATION_CREDENTIALS: "/tmp/vertex-adc.json", - } as NodeJS.ProcessEnv; - - expect(region.resolveAnthropicVertexProjectId(env)).toBe("vertex-project"); - expect(region.hasAnthropicVertexAvailableAuth(env)).toBe(true); - expect(existsSync).not.toHaveBeenCalled(); - expect(readFileSync).toHaveBeenCalledWith("/tmp/vertex-adc.json", "utf8"); + it("does not infer a Vertex region from custom proxy hosts", () => { + expect( + resolveAnthropicVertexRegionFromBaseUrl("https://proxy.example.com/google/aiplatform"), + ).toBeUndefined(); }); }); diff --git a/src/plugin-sdk/anthropic-vertex-auth-presence.preflight.test.ts b/src/plugin-sdk/anthropic-vertex-auth-presence.preflight.test.ts new file mode 100644 index 00000000000..1d175765695 --- /dev/null +++ b/src/plugin-sdk/anthropic-vertex-auth-presence.preflight.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { existsSyncMock, readFileSyncMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn(), + readFileSyncMock: vi.fn(), +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + existsSyncMock.mockImplementation((pathname) => actual.existsSync(pathname)); + readFileSyncMock.mockImplementation((pathname, options) => + String(pathname) === "/tmp/vertex-adc.json" + ? '{"client_id":"vertex-client"}' + : actual.readFileSync(pathname, options as never), + ); + return { + ...actual, + existsSync: existsSyncMock, + readFileSync: readFileSyncMock, + default: { + ...actual, + existsSync: existsSyncMock, + readFileSync: readFileSyncMock, + }, + }; +}); + +import { hasAnthropicVertexAvailableAuth } from "./anthropic-vertex-auth-presence.js"; + +describe("hasAnthropicVertexAvailableAuth ADC preflight", () => { + afterEach(() => { + existsSyncMock.mockClear(); + readFileSyncMock.mockClear(); + }); + + it("reads explicit ADC credentials without an existsSync preflight", () => { + expect( + hasAnthropicVertexAvailableAuth({ + GOOGLE_APPLICATION_CREDENTIALS: "/tmp/vertex-adc.json", + } as NodeJS.ProcessEnv), + ).toBe(true); + expect(existsSyncMock).not.toHaveBeenCalled(); + expect(readFileSyncMock).toHaveBeenCalledWith("/tmp/vertex-adc.json", "utf8"); + }); +}); diff --git a/src/plugin-sdk/anthropic-vertex-auth-presence.ts b/src/plugin-sdk/anthropic-vertex-auth-presence.ts index 436950da262..6e7bc878b16 100644 --- a/src/plugin-sdk/anthropic-vertex-auth-presence.ts +++ b/src/plugin-sdk/anthropic-vertex-auth-presence.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { homedir, platform } from "node:os"; import { join } from "node:path"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; @@ -25,23 +25,31 @@ function resolveAnthropicVertexDefaultAdcPath(env: NodeJS.ProcessEnv = process.e : GCLOUD_DEFAULT_ADC_PATH; } -function resolveAnthropicVertexAdcCredentialsPath( +function resolveAnthropicVertexAdcCredentialsPathCandidate( env: NodeJS.ProcessEnv = process.env, ): string | undefined { - const explicitCredentialsPath = env.GOOGLE_APPLICATION_CREDENTIALS?.trim(); + const explicitCredentialsPath = normalizeOptionalSecretInput(env.GOOGLE_APPLICATION_CREDENTIALS); if (explicitCredentialsPath) { - return existsSync(explicitCredentialsPath) ? explicitCredentialsPath : undefined; + return explicitCredentialsPath; } - const defaultAdcPath = resolveAnthropicVertexDefaultAdcPath(env); - return existsSync(defaultAdcPath) ? defaultAdcPath : undefined; + return resolveAnthropicVertexDefaultAdcPath(env); } -export function hasAnthropicVertexAvailableAuth( - env: NodeJS.ProcessEnv = process.env, -): boolean { - return ( - hasAnthropicVertexMetadataServerAdc(env) || - resolveAnthropicVertexAdcCredentialsPath(env) !== undefined - ); +function canReadAnthropicVertexAdc(env: NodeJS.ProcessEnv = process.env): boolean { + const credentialsPath = resolveAnthropicVertexAdcCredentialsPathCandidate(env); + if (!credentialsPath) { + return false; + } + + try { + readFileSync(credentialsPath, "utf8"); + return true; + } catch { + return false; + } +} + +export function hasAnthropicVertexAvailableAuth(env: NodeJS.ProcessEnv = process.env): boolean { + return hasAnthropicVertexMetadataServerAdc(env) || canReadAnthropicVertexAdc(env); }