-
Notifications
You must be signed in to change notification settings - Fork 212
fix(vertex): warn when 'Google Cloud Credentials' field receives a file path #294
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
a77360d
fix(vertex): warn when 'Google Cloud Credentials' field receives a fi…
0xMink 6b0e582
fix(vertex): bind parsed credentials before truthiness check + extrac…
0xMink 7912fb3
fix(a11y): use role="status" for the Vertex credentials path warning
0xMink d69f694
fix(vertex): drop path preview from warning + extract shared path-sha…
0xMink File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
43
packages/core/src/message-utils/__tests__/safeJsonParse.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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):") | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.