diff --git a/plugins/glean/.claude-plugin/plugin.json b/plugins/glean/.claude-plugin/plugin.json index 4befbfc..1fee628 100644 --- a/plugins/glean/.claude-plugin/plugin.json +++ b/plugins/glean/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "glean-vnext", - "version": "0.2.39", + "version": "0.2.40", "description": "Glean plugin for discovering skills and running tools.", "author": { "name": "Glean" diff --git a/plugins/glean/.codex-plugin/plugin.json b/plugins/glean/.codex-plugin/plugin.json index e1ec975..16fb2f1 100644 --- a/plugins/glean/.codex-plugin/plugin.json +++ b/plugins/glean/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "glean-vnext", - "version": "0.2.39", + "version": "0.2.40", "description": "Glean Codex plugin for discovering skills and running tools.", "author": { "name": "Glean" diff --git a/plugins/glean/.cursor-plugin/plugin.json b/plugins/glean/.cursor-plugin/plugin.json index 931b215..87422ec 100644 --- a/plugins/glean/.cursor-plugin/plugin.json +++ b/plugins/glean/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "glean-vnext", "displayName": "Glean vNext", - "version": "0.2.39", + "version": "0.2.40", "description": "Search and act across your company's apps — Jira, Slack, Salesforce, Google Workspace, and more — without leaving Cursor.", "author": { "name": "Glean" diff --git a/plugins/glean/dist/index.js b/plugins/glean/dist/index.js index 11913da..c46e8fc 100644 --- a/plugins/glean/dist/index.js +++ b/plugins/glean/dist/index.js @@ -25400,9 +25400,10 @@ function clearCredentials() { // src/auth-provider.ts function openBrowser(url2) { if (platform() === "win32") { - spawn("cmd", ["/c", "start", "", url2], { + spawn("cmd", ["/c", "start", '""', "/b", url2.replace(/&/g, "^&")], { detached: true, - stdio: "ignore" + stdio: "ignore", + windowsVerbatimArguments: true }).unref(); } else { const cmd = platform() === "darwin" ? "open" : "xdg-open"; diff --git a/src/auth-provider.ts b/src/auth-provider.ts index 689b4df..4c47d0b 100644 --- a/src/auth-provider.ts +++ b/src/auth-provider.ts @@ -18,9 +18,19 @@ export type InvalidationScope = "all" | "client" | "tokens" | "verifier"; */ export function openBrowser(url: string): void { if (platform() === "win32") { - spawn("cmd", ["/c", "start", "", url], { + // Open via `cmd /c start`, which routes through ShellExecute -> the default + // browser. The catch: cmd.exe treats a bare `&` as a command separator, so + // the OAuth authorize URL would be truncated at the first `&` -- dropping + // client_id and everything after it, which the server rejects as + // invalid_client. We escape every `&` as `^&` and pass the args verbatim + // (windowsVerbatimArguments) so Node doesn't re-wrap them in quotes, inside + // which cmd stops honoring the `^` escape. cmd then un-escapes `^&` back to + // a literal `&`, so the browser receives the full URL intact. The empty + // `""` is start's window-title arg; `/b` avoids spawning a console window. + spawn("cmd", ["/c", "start", '""', "/b", url.replace(/&/g, "^&")], { detached: true, stdio: "ignore", + windowsVerbatimArguments: true, }).unref(); } else { const cmd = platform() === "darwin" ? "open" : "xdg-open"; diff --git a/tests/open-browser.test.ts b/tests/open-browser.test.ts new file mode 100644 index 0000000..d89d99d --- /dev/null +++ b/tests/open-browser.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const platformMock = vi.fn(); +vi.mock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, platform: () => platformMock() }; +}); + +const spawnMock = vi.fn(() => ({ unref: vi.fn() })); +const execFileMock = vi.fn(); +vi.mock("node:child_process", () => ({ + spawn: spawnMock, + execFile: execFileMock, +})); + +const { openBrowser } = await import("../src/auth-provider.js"); + +// A realistic authorize URL: response_type is first, then client_id after the +// first `&` — the exact shape the MCP SDK produces. +const authUrl = + "https://acme-be.glean.com/oauth/authorize?response_type=code" + + "&client_id=Glean_Claude_Cod_f345b80b" + + "&redirect_uri=http%3A%2F%2F127.0.0.1%3A29107%2Fglean-cli-callback" + + "&code_challenge=abc123&code_challenge_method=S256&state=s1"; + +describe("openBrowser", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("escapes `&` as `^&` so the full authorize URL survives cmd on Windows", () => { + platformMock.mockReturnValue("win32"); + + openBrowser(authUrl); + + expect(spawnMock).toHaveBeenCalledTimes(1); + const [command, args, options] = spawnMock.mock.calls[0] as [ + string, + string[], + { windowsVerbatimArguments?: boolean }, + ]; + expect(command).toMatch(/^cmd$/i); + + // The URL is the last arg. Every `&` must be escaped to `^&` so cmd does + // not treat it as a command separator and truncate the URL. + const urlArg = args[args.length - 1]; + expect(urlArg).toBe(authUrl.replace(/&/g, "^&")); + // Guard the actual regression: client_id lives after the first `&`, so it + // must survive, and no un-escaped `&` may remain in the argument. + expect(urlArg).toContain("client_id=Glean_Claude_Cod_f345b80b"); + expect(urlArg.replace(/\^&/g, "").includes("&")).toBe(false); + // Verbatim args are required so Node doesn't re-quote and break the `^`. + expect(options.windowsVerbatimArguments).toBe(true); + }); + + it("opens with execFile on macOS", () => { + platformMock.mockReturnValue("darwin"); + + openBrowser(authUrl); + + expect(execFileMock).toHaveBeenCalledWith("open", [authUrl]); + }); + + it("opens with xdg-open on Linux", () => { + platformMock.mockReturnValue("linux"); + + openBrowser(authUrl); + + expect(execFileMock).toHaveBeenCalledWith("xdg-open", [authUrl]); + }); +});