Skip to content

Commit a77360d

Browse files
committed
fix(vertex): warn when 'Google Cloud Credentials' field receives a file path
Users reported recurring "Error parsing JSON: Unexpected token 'C', \"C:\\Users\\..\"" spam on every Task creation. Root cause: pasting a filesystem path into the 'Google Cloud Credentials' field (which expects raw JSON content), instead of the sibling 'Google Cloud Key File Path' field. Google's own ecosystem uses GOOGLE_APPLICATION_CREDENTIALS for paths, so "credentials = path to credentials file" is a natural mental model that the field naming does not disambiguate. Three pieces: - gemini.ts and anthropic-vertex.ts: shape-check the field before parsing. If it looks like a path (Windows, POSIX absolute, ~/, ./), log a specific warning pointing at the Key File Path field and the GOOGLE_APPLICATION_CREDENTIALS env var. Skip the JSON.parse to avoid the generic 'Error parsing JSON' noise. The shared helper parseVertexJsonCredentials is exported from gemini.ts and reused from anthropic-vertex.ts so the Gemini-on-Vertex and Claude-on-Vertex paths behave identically. - safeJsonParse: add an optional context arg so logs identify their source instead of being anonymous. Backward-compatible. - Vertex.tsx: inline warning under the credentials field that fires on path-shaped input, naming the correct field to use instead. New i18n string propagated to all 18 locales. Auth behavior is unchanged for users who configure the fields correctly or use the env var. The only user-visible change is the new specific warning replacing the previous generic spam.
1 parent b40461d commit a77360d

26 files changed

Lines changed: 471 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"zoo-code": patch
3+
---
4+
5+
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.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// npx vitest run packages/core/src/message-utils/__tests__/safeJsonParse.spec.ts
2+
3+
import { safeJsonParse } from "../safeJsonParse.js"
4+
5+
describe("safeJsonParse", () => {
6+
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
7+
8+
beforeEach(() => {
9+
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
10+
})
11+
12+
afterEach(() => {
13+
consoleErrorSpy.mockRestore()
14+
})
15+
16+
it("returns the parsed value for valid JSON", () => {
17+
expect(safeJsonParse<{ a: number }>('{"a":1}')).toEqual({ a: 1 })
18+
expect(consoleErrorSpy).not.toHaveBeenCalled()
19+
})
20+
21+
it("returns the default value for null, undefined, or empty input", () => {
22+
expect(safeJsonParse<string>(null, "fallback")).toBe("fallback")
23+
expect(safeJsonParse<string>(undefined, "fallback")).toBe("fallback")
24+
expect(safeJsonParse<string>("", "fallback")).toBe("fallback")
25+
expect(consoleErrorSpy).not.toHaveBeenCalled()
26+
})
27+
28+
it("returns the default value and logs the generic message when no context is given (backward compatible)", () => {
29+
const result = safeJsonParse<{ a: number }>("not json", undefined)
30+
expect(result).toBeUndefined()
31+
expect(consoleErrorSpy).toHaveBeenCalledTimes(1)
32+
const message = consoleErrorSpy.mock.calls[0]?.[0]
33+
expect(message).toBe("Error parsing JSON:")
34+
})
35+
36+
it("includes the context label in the error log when provided", () => {
37+
const result = safeJsonParse<{ a: number }>("not json", undefined, "foo")
38+
expect(result).toBeUndefined()
39+
expect(consoleErrorSpy).toHaveBeenCalledTimes(1)
40+
const message = consoleErrorSpy.mock.calls[0]?.[0]
41+
expect(message).toBe("Error parsing JSON (foo):")
42+
})
43+
})

packages/core/src/message-utils/safeJsonParse.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,16 @@
33
*
44
* @param jsonString The string to parse
55
* @param defaultValue Value to return if parsing fails
6+
* @param context Optional label included in the error log so callers can be
7+
* identified when something other than valid JSON is supplied (e.g. a user
8+
* pasting a file path into a JSON field).
69
* @returns Parsed JSON object or defaultValue if parsing fails
710
*/
8-
export function safeJsonParse<T>(jsonString: string | null | undefined, defaultValue?: T): T | undefined {
11+
export function safeJsonParse<T>(
12+
jsonString: string | null | undefined,
13+
defaultValue?: T,
14+
context?: string,
15+
): T | undefined {
916
if (!jsonString) {
1017
return defaultValue
1118
}
@@ -14,7 +21,7 @@ export function safeJsonParse<T>(jsonString: string | null | undefined, defaultV
1421
return JSON.parse(jsonString) as T
1522
} catch (error) {
1623
// Log the error to the console for debugging.
17-
console.error("Error parsing JSON:", error)
24+
console.error(`Error parsing JSON${context ? ` (${context})` : ""}:`, error)
1825
return defaultValue
1926
}
2027
}
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
// npx vitest run src/api/providers/__tests__/vertex-credentials.spec.ts
2+
3+
// Mock vscode first to avoid import errors when the provider stack pulls
4+
// transitive vscode-dependent modules during construction.
5+
vitest.mock("vscode", () => ({}))
6+
7+
vitest.mock("@roo-code/telemetry", () => ({
8+
TelemetryService: {
9+
instance: {
10+
captureException: vitest.fn(),
11+
},
12+
},
13+
}))
14+
15+
// Capture the constructor args passed to GoogleGenAI so we can assert on the
16+
// credentials handed to GoogleAuth via googleAuthOptions.
17+
const googleGenAICtor = vitest.fn()
18+
vitest.mock("@google/genai", () => ({
19+
GoogleGenAI: vitest.fn().mockImplementation((args: unknown) => {
20+
googleGenAICtor(args)
21+
return {
22+
models: {
23+
generateContentStream: vitest.fn(),
24+
generateContent: vitest.fn(),
25+
},
26+
}
27+
}),
28+
FunctionCallingConfigMode: { AUTO: "AUTO", ANY: "ANY", NONE: "NONE" },
29+
}))
30+
31+
// Capture the constructor args passed to GoogleAuth (Anthropic-on-Vertex path).
32+
const googleAuthCtor = vitest.fn()
33+
vitest.mock("google-auth-library", () => ({
34+
GoogleAuth: vitest.fn().mockImplementation((args: unknown) => {
35+
googleAuthCtor(args)
36+
return {
37+
/* GoogleAuth instance shape is opaque to these tests */
38+
}
39+
}),
40+
}))
41+
42+
vitest.mock("@anthropic-ai/vertex-sdk", () => ({
43+
AnthropicVertex: vitest.fn().mockImplementation(() => ({
44+
messages: { create: vitest.fn() },
45+
})),
46+
}))
47+
48+
import { GeminiHandler, parseVertexJsonCredentials } from "../gemini"
49+
import { VertexHandler } from "../vertex"
50+
import { AnthropicVertexHandler } from "../anthropic-vertex"
51+
52+
const VALID_CREDS_JSON = JSON.stringify({
53+
type: "service_account",
54+
client_email: "test@example.iam.gserviceaccount.com",
55+
private_key: "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n",
56+
})
57+
58+
describe("parseVertexJsonCredentials", () => {
59+
let warnSpy: ReturnType<typeof vi.spyOn>
60+
let errorSpy: ReturnType<typeof vi.spyOn>
61+
62+
beforeEach(() => {
63+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
64+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
65+
})
66+
67+
afterEach(() => {
68+
warnSpy.mockRestore()
69+
errorSpy.mockRestore()
70+
})
71+
72+
it("returns undefined and does not warn for empty or whitespace input", () => {
73+
expect(parseVertexJsonCredentials(undefined)).toBeUndefined()
74+
expect(parseVertexJsonCredentials("")).toBeUndefined()
75+
expect(parseVertexJsonCredentials(" ")).toBeUndefined()
76+
expect(warnSpy).not.toHaveBeenCalled()
77+
expect(errorSpy).not.toHaveBeenCalled()
78+
})
79+
80+
it("parses valid JSON credentials without warning", () => {
81+
const result = parseVertexJsonCredentials(VALID_CREDS_JSON)
82+
expect(result).toMatchObject({ type: "service_account" })
83+
expect(warnSpy).not.toHaveBeenCalled()
84+
expect(errorSpy).not.toHaveBeenCalled()
85+
})
86+
87+
it.each([
88+
["Windows backslash path", "C:\\Users\\test\\creds.json"],
89+
["Windows forward-slash path", "C:/Users/test/creds.json"],
90+
["POSIX absolute path", "/home/test/creds.json"],
91+
["POSIX home path", "~/creds.json"],
92+
["POSIX relative ./", "./creds.json"],
93+
["POSIX relative ../", "../secrets/creds.json"],
94+
])("warns and returns undefined for %s", (_label, input) => {
95+
const result = parseVertexJsonCredentials(input)
96+
expect(result).toBeUndefined()
97+
expect(warnSpy).toHaveBeenCalledTimes(1)
98+
const [message] = warnSpy.mock.calls[0]
99+
expect(message).toContain("Google Cloud Credentials")
100+
expect(message).toContain("Google Cloud Key File Path")
101+
expect(message).toContain("GOOGLE_APPLICATION_CREDENTIALS")
102+
// Generic "Error parsing JSON" must not fire for the path case.
103+
expect(errorSpy).not.toHaveBeenCalled()
104+
})
105+
106+
it("trims surrounding whitespace before detecting path shape", () => {
107+
expect(parseVertexJsonCredentials(" /tmp/creds.json ")).toBeUndefined()
108+
expect(warnSpy).toHaveBeenCalledTimes(1)
109+
})
110+
111+
it("truncates long path previews in the warning", () => {
112+
const longPath = "/" + "a".repeat(200)
113+
parseVertexJsonCredentials(longPath)
114+
const [message] = warnSpy.mock.calls[0]
115+
expect(message).toContain("…")
116+
})
117+
118+
it("falls back to the generic JSON parse error path for malformed but non-path input", () => {
119+
const result = parseVertexJsonCredentials("not-json-and-not-a-path")
120+
expect(result).toBeUndefined()
121+
expect(warnSpy).not.toHaveBeenCalled()
122+
expect(errorSpy).toHaveBeenCalledTimes(1)
123+
const [message] = errorSpy.mock.calls[0]
124+
expect(message).toBe("Error parsing JSON (Vertex credentials):")
125+
})
126+
})
127+
128+
describe("GeminiHandler vertex credentials wiring", () => {
129+
let warnSpy: ReturnType<typeof vi.spyOn>
130+
let errorSpy: ReturnType<typeof vi.spyOn>
131+
132+
beforeEach(() => {
133+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
134+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
135+
googleGenAICtor.mockClear()
136+
})
137+
138+
afterEach(() => {
139+
warnSpy.mockRestore()
140+
errorSpy.mockRestore()
141+
})
142+
143+
it("passes parsed JSON credentials through to GoogleGenAI", () => {
144+
new GeminiHandler({
145+
apiModelId: "gemini-2.0-flash-001",
146+
vertexProjectId: "p",
147+
vertexRegion: "us-central1",
148+
vertexJsonCredentials: VALID_CREDS_JSON,
149+
isVertex: true,
150+
})
151+
152+
expect(warnSpy).not.toHaveBeenCalled()
153+
expect(googleGenAICtor).toHaveBeenCalledTimes(1)
154+
const args = googleGenAICtor.mock.calls[0][0]
155+
expect(args.googleAuthOptions.credentials).toMatchObject({ type: "service_account" })
156+
})
157+
158+
it("warns and passes undefined credentials when the field looks like a path", () => {
159+
new GeminiHandler({
160+
apiModelId: "gemini-2.0-flash-001",
161+
vertexProjectId: "p",
162+
vertexRegion: "us-central1",
163+
vertexJsonCredentials: "C:\\Users\\dev\\sa.json",
164+
isVertex: true,
165+
})
166+
167+
expect(warnSpy).toHaveBeenCalledTimes(1)
168+
expect(googleGenAICtor).toHaveBeenCalledTimes(1)
169+
const args = googleGenAICtor.mock.calls[0][0]
170+
expect(args.googleAuthOptions.credentials).toBeUndefined()
171+
// Generic "Error parsing JSON" must not fire for the path case.
172+
expect(errorSpy).not.toHaveBeenCalled()
173+
})
174+
175+
it("does not warn or supply credentials when neither field is set", () => {
176+
new GeminiHandler({
177+
apiModelId: "gemini-2.0-flash-001",
178+
vertexProjectId: "p",
179+
vertexRegion: "us-central1",
180+
isVertex: true,
181+
})
182+
183+
expect(warnSpy).not.toHaveBeenCalled()
184+
expect(googleGenAICtor).toHaveBeenCalledTimes(1)
185+
const args = googleGenAICtor.mock.calls[0][0]
186+
// In this branch the constructor builds GoogleGenAI without googleAuthOptions.
187+
expect(args.googleAuthOptions).toBeUndefined()
188+
})
189+
})
190+
191+
describe("VertexHandler inherits the path-shape guard from GeminiHandler", () => {
192+
let warnSpy: ReturnType<typeof vi.spyOn>
193+
194+
beforeEach(() => {
195+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
196+
googleGenAICtor.mockClear()
197+
})
198+
199+
afterEach(() => {
200+
warnSpy.mockRestore()
201+
})
202+
203+
it("warns and passes undefined credentials when the field looks like a POSIX path", () => {
204+
new VertexHandler({
205+
apiModelId: "gemini-2.0-flash-001",
206+
vertexProjectId: "p",
207+
vertexRegion: "us-central1",
208+
vertexJsonCredentials: "/home/dev/sa.json",
209+
})
210+
211+
expect(warnSpy).toHaveBeenCalledTimes(1)
212+
const args = googleGenAICtor.mock.calls[0][0]
213+
expect(args.googleAuthOptions.credentials).toBeUndefined()
214+
})
215+
})
216+
217+
describe("AnthropicVertexHandler vertex credentials wiring", () => {
218+
let warnSpy: ReturnType<typeof vi.spyOn>
219+
let errorSpy: ReturnType<typeof vi.spyOn>
220+
221+
beforeEach(() => {
222+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
223+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
224+
googleAuthCtor.mockClear()
225+
})
226+
227+
afterEach(() => {
228+
warnSpy.mockRestore()
229+
errorSpy.mockRestore()
230+
})
231+
232+
it("passes parsed JSON credentials through to GoogleAuth", () => {
233+
new AnthropicVertexHandler({
234+
apiModelId: "claude-3-5-sonnet-v2@20241022",
235+
vertexProjectId: "p",
236+
vertexRegion: "us-east5",
237+
vertexJsonCredentials: VALID_CREDS_JSON,
238+
})
239+
240+
expect(warnSpy).not.toHaveBeenCalled()
241+
expect(googleAuthCtor).toHaveBeenCalledTimes(1)
242+
const args = googleAuthCtor.mock.calls[0][0]
243+
expect(args.credentials).toMatchObject({ type: "service_account" })
244+
})
245+
246+
it("warns and passes undefined credentials when the field looks like a Windows path", () => {
247+
new AnthropicVertexHandler({
248+
apiModelId: "claude-3-5-sonnet-v2@20241022",
249+
vertexProjectId: "p",
250+
vertexRegion: "us-east5",
251+
vertexJsonCredentials: "C:\\Users\\dev\\sa.json",
252+
})
253+
254+
expect(warnSpy).toHaveBeenCalledTimes(1)
255+
expect(googleAuthCtor).toHaveBeenCalledTimes(1)
256+
const args = googleAuthCtor.mock.calls[0][0]
257+
expect(args.credentials).toBeUndefined()
258+
expect(errorSpy).not.toHaveBeenCalled()
259+
})
260+
261+
it("does not invoke GoogleAuth when neither credentials nor keyFile is set", () => {
262+
new AnthropicVertexHandler({
263+
apiModelId: "claude-3-5-sonnet-v2@20241022",
264+
vertexProjectId: "p",
265+
vertexRegion: "us-east5",
266+
})
267+
268+
expect(warnSpy).not.toHaveBeenCalled()
269+
expect(googleAuthCtor).not.toHaveBeenCalled()
270+
})
271+
})

src/api/providers/anthropic-vertex.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Anthropic } from "@anthropic-ai/sdk"
22
import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"
3-
import { GoogleAuth, JWTInput } from "google-auth-library"
3+
import { GoogleAuth } from "google-auth-library"
44

55
import {
66
type ModelInfo,
@@ -10,7 +10,6 @@ import {
1010
ANTHROPIC_DEFAULT_MAX_TOKENS,
1111
VERTEX_1M_CONTEXT_MODEL_IDS,
1212
} from "@roo-code/types"
13-
import { safeJsonParse } from "@roo-code/core"
1413

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

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

2625
import { BaseProvider } from "./base-provider"
26+
import { parseVertexJsonCredentials } from "./gemini"
2727
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
2828

2929
// https://docs.anthropic.com/en/api/claude-on-vertex-ai
@@ -46,7 +46,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple
4646
region,
4747
googleAuth: new GoogleAuth({
4848
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
49-
credentials: safeJsonParse<JWTInput>(this.options.vertexJsonCredentials, undefined),
49+
credentials: parseVertexJsonCredentials(this.options.vertexJsonCredentials),
5050
}),
5151
})
5252
} else if (this.options.vertexKeyFile) {

0 commit comments

Comments
 (0)