Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/vertex-credentials-field-validation.md

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curious on your opinion if we should revive this? I noticed the Roo team stopped using changesets in favour of LLM drive releases, however they left the old changeset infrastructure in the repo.

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"zoo-code": patch
---

Detect when the "Google Cloud Credentials" Vertex field has been given a filesystem path instead of the raw JSON contents of a service-account key file. The runtime now skips JSON.parse for path-shaped input, logs a single specific console warning naming the sibling "Google Cloud Key File Path" field and `GOOGLE_APPLICATION_CREDENTIALS` env var, and the settings UI shows an inline warning under the field while the input still looks like a path. Auth behavior is unchanged for correctly-configured users.
43 changes: 43 additions & 0 deletions packages/core/src/message-utils/__tests__/safeJsonParse.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// npx vitest run packages/core/src/message-utils/__tests__/safeJsonParse.spec.ts

import { safeJsonParse } from "../safeJsonParse.js"

describe("safeJsonParse", () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
})

afterEach(() => {
consoleErrorSpy.mockRestore()
})

it("returns the parsed value for valid JSON", () => {
expect(safeJsonParse<{ a: number }>('{"a":1}')).toEqual({ a: 1 })
expect(consoleErrorSpy).not.toHaveBeenCalled()
})

it("returns the default value for null, undefined, or empty input", () => {
expect(safeJsonParse<string>(null, "fallback")).toBe("fallback")
expect(safeJsonParse<string>(undefined, "fallback")).toBe("fallback")
expect(safeJsonParse<string>("", "fallback")).toBe("fallback")
expect(consoleErrorSpy).not.toHaveBeenCalled()
})

it("returns the default value and logs the generic message when no context is given (backward compatible)", () => {
const result = safeJsonParse<{ a: number }>("not json", undefined)
expect(result).toBeUndefined()
expect(consoleErrorSpy).toHaveBeenCalledTimes(1)
const message = consoleErrorSpy.mock.calls[0]?.[0]
expect(message).toBe("Error parsing JSON:")
})

it("includes the context label in the error log when provided", () => {
const result = safeJsonParse<{ a: number }>("not json", undefined, "foo")
expect(result).toBeUndefined()
expect(consoleErrorSpy).toHaveBeenCalledTimes(1)
const message = consoleErrorSpy.mock.calls[0]?.[0]
expect(message).toBe("Error parsing JSON (foo):")
})
})
11 changes: 9 additions & 2 deletions packages/core/src/message-utils/safeJsonParse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,16 @@
*
* @param jsonString The string to parse
* @param defaultValue Value to return if parsing fails
* @param context Optional label included in the error log so callers can be
* identified when something other than valid JSON is supplied (e.g. a user
* pasting a file path into a JSON field).
* @returns Parsed JSON object or defaultValue if parsing fails
*/
export function safeJsonParse<T>(jsonString: string | null | undefined, defaultValue?: T): T | undefined {
export function safeJsonParse<T>(
jsonString: string | null | undefined,
defaultValue?: T,
context?: string,
): T | undefined {
if (!jsonString) {
return defaultValue
}
Expand All @@ -14,7 +21,7 @@ export function safeJsonParse<T>(jsonString: string | null | undefined, defaultV
return JSON.parse(jsonString) as T
} catch (error) {
// Log the error to the console for debugging.
console.error("Error parsing JSON:", error)
console.error(`Error parsing JSON${context ? ` (${context})` : ""}:`, error)
return defaultValue
}
}
271 changes: 271 additions & 0 deletions src/api/providers/__tests__/vertex-credentials.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
// npx vitest run src/api/providers/__tests__/vertex-credentials.spec.ts

// Mock vscode first to avoid import errors when the provider stack pulls
// transitive vscode-dependent modules during construction.
vitest.mock("vscode", () => ({}))

vitest.mock("@roo-code/telemetry", () => ({
TelemetryService: {
instance: {
captureException: vitest.fn(),
},
},
}))

// Capture the constructor args passed to GoogleGenAI so we can assert on the
// credentials handed to GoogleAuth via googleAuthOptions.
const googleGenAICtor = vitest.fn()
vitest.mock("@google/genai", () => ({
GoogleGenAI: vitest.fn().mockImplementation((args: unknown) => {
googleGenAICtor(args)
return {
models: {
generateContentStream: vitest.fn(),
generateContent: vitest.fn(),
},
}
}),
FunctionCallingConfigMode: { AUTO: "AUTO", ANY: "ANY", NONE: "NONE" },
}))

// Capture the constructor args passed to GoogleAuth (Anthropic-on-Vertex path).
const googleAuthCtor = vitest.fn()
vitest.mock("google-auth-library", () => ({
GoogleAuth: vitest.fn().mockImplementation((args: unknown) => {
googleAuthCtor(args)
return {
/* GoogleAuth instance shape is opaque to these tests */
}
}),
}))

vitest.mock("@anthropic-ai/vertex-sdk", () => ({
AnthropicVertex: vitest.fn().mockImplementation(() => ({
messages: { create: vitest.fn() },
})),
}))

import { GeminiHandler, parseVertexJsonCredentials } from "../gemini"
import { VertexHandler } from "../vertex"
import { AnthropicVertexHandler } from "../anthropic-vertex"

const VALID_CREDS_JSON = JSON.stringify({
type: "service_account",
client_email: "test@example.iam.gserviceaccount.com",
private_key: "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n",
})

describe("parseVertexJsonCredentials", () => {
let warnSpy: ReturnType<typeof vi.spyOn>
let errorSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
})

afterEach(() => {
warnSpy.mockRestore()
errorSpy.mockRestore()
})

it("returns undefined and does not warn for empty or whitespace input", () => {
expect(parseVertexJsonCredentials(undefined)).toBeUndefined()
expect(parseVertexJsonCredentials("")).toBeUndefined()
expect(parseVertexJsonCredentials(" ")).toBeUndefined()
expect(warnSpy).not.toHaveBeenCalled()
expect(errorSpy).not.toHaveBeenCalled()
})

it("parses valid JSON credentials without warning", () => {
const result = parseVertexJsonCredentials(VALID_CREDS_JSON)
expect(result).toMatchObject({ type: "service_account" })
expect(warnSpy).not.toHaveBeenCalled()
expect(errorSpy).not.toHaveBeenCalled()
})

it.each([
["Windows backslash path", "C:\\Users\\test\\creds.json"],
["Windows forward-slash path", "C:/Users/test/creds.json"],
["POSIX absolute path", "/home/test/creds.json"],
["POSIX home path", "~/creds.json"],
["POSIX relative ./", "./creds.json"],
["POSIX relative ../", "../secrets/creds.json"],
])("warns and returns undefined for %s", (_label, input) => {
const result = parseVertexJsonCredentials(input)
expect(result).toBeUndefined()
expect(warnSpy).toHaveBeenCalledTimes(1)
const [message] = warnSpy.mock.calls[0]
expect(message).toContain("Google Cloud Credentials")
expect(message).toContain("Google Cloud Key File Path")
expect(message).toContain("GOOGLE_APPLICATION_CREDENTIALS")
// Generic "Error parsing JSON" must not fire for the path case.
expect(errorSpy).not.toHaveBeenCalled()
})

it("trims surrounding whitespace before detecting path shape", () => {
expect(parseVertexJsonCredentials(" /tmp/creds.json ")).toBeUndefined()
expect(warnSpy).toHaveBeenCalledTimes(1)
})

it("truncates long path previews in the warning", () => {
const longPath = "/" + "a".repeat(200)
parseVertexJsonCredentials(longPath)
const [message] = warnSpy.mock.calls[0]
expect(message).toContain("…")
})

it("falls back to the generic JSON parse error path for malformed but non-path input", () => {
const result = parseVertexJsonCredentials("not-json-and-not-a-path")
expect(result).toBeUndefined()
expect(warnSpy).not.toHaveBeenCalled()
expect(errorSpy).toHaveBeenCalledTimes(1)
const [message] = errorSpy.mock.calls[0]
expect(message).toBe("Error parsing JSON (Vertex credentials):")
})
})

describe("GeminiHandler vertex credentials wiring", () => {
let warnSpy: ReturnType<typeof vi.spyOn>
let errorSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
googleGenAICtor.mockClear()
})

afterEach(() => {
warnSpy.mockRestore()
errorSpy.mockRestore()
})

it("passes parsed JSON credentials through to GoogleGenAI", () => {
new GeminiHandler({
apiModelId: "gemini-2.0-flash-001",
vertexProjectId: "p",
vertexRegion: "us-central1",
vertexJsonCredentials: VALID_CREDS_JSON,
isVertex: true,
})

expect(warnSpy).not.toHaveBeenCalled()
expect(googleGenAICtor).toHaveBeenCalledTimes(1)
const args = googleGenAICtor.mock.calls[0][0]
expect(args.googleAuthOptions.credentials).toMatchObject({ type: "service_account" })
})

it("warns and passes undefined credentials when the field looks like a path", () => {
new GeminiHandler({
apiModelId: "gemini-2.0-flash-001",
vertexProjectId: "p",
vertexRegion: "us-central1",
vertexJsonCredentials: "C:\\Users\\dev\\sa.json",
isVertex: true,
})

expect(warnSpy).toHaveBeenCalledTimes(1)
expect(googleGenAICtor).toHaveBeenCalledTimes(1)
const args = googleGenAICtor.mock.calls[0][0]
expect(args.googleAuthOptions.credentials).toBeUndefined()
// Generic "Error parsing JSON" must not fire for the path case.
expect(errorSpy).not.toHaveBeenCalled()
})

it("does not warn or supply credentials when neither field is set", () => {
new GeminiHandler({
apiModelId: "gemini-2.0-flash-001",
vertexProjectId: "p",
vertexRegion: "us-central1",
isVertex: true,
})

expect(warnSpy).not.toHaveBeenCalled()
expect(googleGenAICtor).toHaveBeenCalledTimes(1)
const args = googleGenAICtor.mock.calls[0][0]
// In this branch the constructor builds GoogleGenAI without googleAuthOptions.
expect(args.googleAuthOptions).toBeUndefined()
})
})

describe("VertexHandler inherits the path-shape guard from GeminiHandler", () => {
let warnSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
googleGenAICtor.mockClear()
})

afterEach(() => {
warnSpy.mockRestore()
})

it("warns and passes undefined credentials when the field looks like a POSIX path", () => {
new VertexHandler({
apiModelId: "gemini-2.0-flash-001",
vertexProjectId: "p",
vertexRegion: "us-central1",
vertexJsonCredentials: "/home/dev/sa.json",
})

expect(warnSpy).toHaveBeenCalledTimes(1)
const args = googleGenAICtor.mock.calls[0][0]
expect(args.googleAuthOptions.credentials).toBeUndefined()
})
})

describe("AnthropicVertexHandler vertex credentials wiring", () => {
let warnSpy: ReturnType<typeof vi.spyOn>
let errorSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
googleAuthCtor.mockClear()
})

afterEach(() => {
warnSpy.mockRestore()
errorSpy.mockRestore()
})

it("passes parsed JSON credentials through to GoogleAuth", () => {
new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexProjectId: "p",
vertexRegion: "us-east5",
vertexJsonCredentials: VALID_CREDS_JSON,
})

expect(warnSpy).not.toHaveBeenCalled()
expect(googleAuthCtor).toHaveBeenCalledTimes(1)
const args = googleAuthCtor.mock.calls[0][0]
expect(args.credentials).toMatchObject({ type: "service_account" })
})

it("warns and passes undefined credentials when the field looks like a Windows path", () => {
new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexProjectId: "p",
vertexRegion: "us-east5",
vertexJsonCredentials: "C:\\Users\\dev\\sa.json",
})

expect(warnSpy).toHaveBeenCalledTimes(1)
expect(googleAuthCtor).toHaveBeenCalledTimes(1)
const args = googleAuthCtor.mock.calls[0][0]
expect(args.credentials).toBeUndefined()
expect(errorSpy).not.toHaveBeenCalled()
})

it("does not invoke GoogleAuth when neither credentials nor keyFile is set", () => {
new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexProjectId: "p",
vertexRegion: "us-east5",
})

expect(warnSpy).not.toHaveBeenCalled()
expect(googleAuthCtor).not.toHaveBeenCalled()
})
})
6 changes: 3 additions & 3 deletions src/api/providers/anthropic-vertex.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"
import { GoogleAuth, JWTInput } from "google-auth-library"
import { GoogleAuth } from "google-auth-library"

import {
type ModelInfo,
Expand All @@ -10,7 +10,6 @@ import {
ANTHROPIC_DEFAULT_MAX_TOKENS,
VERTEX_1M_CONTEXT_MODEL_IDS,
} from "@roo-code/types"
import { safeJsonParse } from "@roo-code/core"

import { ApiHandlerOptions } from "../../shared/api"

Expand All @@ -24,6 +23,7 @@ import {
} from "../../core/prompts/tools/native-tools/converters"

import { BaseProvider } from "./base-provider"
import { parseVertexJsonCredentials } from "./gemini"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"

Comment thread
edelauna marked this conversation as resolved.
Outdated
// https://docs.anthropic.com/en/api/claude-on-vertex-ai
Expand All @@ -46,7 +46,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple
region,
googleAuth: new GoogleAuth({
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
credentials: safeJsonParse<JWTInput>(this.options.vertexJsonCredentials, undefined),
credentials: parseVertexJsonCredentials(this.options.vertexJsonCredentials),
}),
})
} else if (this.options.vertexKeyFile) {
Expand Down
Loading
Loading