Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion plugins/glean/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/glean/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/glean/.cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
5 changes: 3 additions & 2 deletions plugins/glean/dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion src/auth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
71 changes: 71 additions & 0 deletions tests/open-browser.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("node:os")>("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]);
});
});
Loading