From ea9486ae2dccce046ad03ff37d46aaec7fdbcc87 Mon Sep 17 00:00:00 2001 From: Roshan Singh Date: Mon, 12 Jan 2026 17:40:26 +0000 Subject: [PATCH 1/2] Fix: quote URLs when opening browser on Windows --- src/commands/onboard-helpers.test.ts | 43 ++++++++++++++++++++++++++++ src/commands/onboard-helpers.ts | 15 ++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 src/commands/onboard-helpers.test.ts diff --git a/src/commands/onboard-helpers.test.ts b/src/commands/onboard-helpers.test.ts new file mode 100644 index 000000000..5d058f51d --- /dev/null +++ b/src/commands/onboard-helpers.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; + +import { openUrl, resolveBrowserOpenCommand } from "./onboard-helpers.js"; + +const mocks = vi.hoisted(() => ({ + runCommandWithTimeout: vi.fn(async () => ({ + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + })), +})); + +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: mocks.runCommandWithTimeout, +})); + +describe("openUrl", () => { + it("quotes URLs on win32 so '&' is not treated as cmd separator", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + + const url = + "https://accounts.google.com/o/oauth2/v2/auth?client_id=abc&response_type=code&redirect_uri=http%3A%2F%2Flocalhost"; + + const ok = await openUrl(url); + expect(ok).toBe(true); + + expect(mocks.runCommandWithTimeout).toHaveBeenCalledTimes(1); + const argv = mocks.runCommandWithTimeout.mock.calls[0]?.[0]; + expect(argv?.slice(0, 4)).toEqual(["cmd", "/c", "start", ""]); + expect(argv?.at(-1)).toBe(`"${url}"`); + }); +}); + +describe("resolveBrowserOpenCommand", () => { + it("marks win32 commands as quoteUrl=true", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const resolved = await resolveBrowserOpenCommand(); + expect(resolved.argv).toEqual(["cmd", "/c", "start", ""]); + expect(resolved.quoteUrl).toBe(true); + }); +}); diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 3676f4dea..37f85e1f5 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -134,9 +134,14 @@ type BrowserOpenCommand = { argv: string[] | null; reason?: string; command?: string; + /** + * Whether the URL must be wrapped in quotes when appended to argv. + * Needed for Windows `cmd /c start` where `&` splits commands. + */ + quoteUrl?: boolean; }; -async function resolveBrowserOpenCommand(): Promise { +export async function resolveBrowserOpenCommand(): Promise { const platform = process.platform; const hasDisplay = Boolean( process.env.DISPLAY || process.env.WAYLAND_DISPLAY, @@ -151,7 +156,11 @@ async function resolveBrowserOpenCommand(): Promise { } if (platform === "win32") { - return { argv: ["cmd", "/c", "start", ""], command: "cmd" }; + return { + argv: ["cmd", "/c", "start", ""], + command: "cmd", + quoteUrl: true, + }; } if (platform === "darwin") { @@ -223,7 +232,7 @@ function resolveSshTargetHint(): string { export async function openUrl(url: string): Promise { const resolved = await resolveBrowserOpenCommand(); if (!resolved.argv) return false; - const command = [...resolved.argv, url]; + const command = [...resolved.argv, resolved.quoteUrl ? `"${url}"` : url]; try { await runCommandWithTimeout(command, { timeoutMs: 5_000 }); return true; From 3061d8e0571a47a8e6285fc07dcb424bfdac12e3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 13 Jan 2026 04:26:43 +0000 Subject: [PATCH 2/2] fix: preserve Windows cmd start URL quoting (#794) (thanks @roshanasingh4) --- CHANGELOG.md | 1 + src/agents/pi-embedded-helpers.test.ts | 2 +- src/commands/onboard-helpers.test.ts | 8 ++++++-- src/commands/onboard-helpers.ts | 17 +++++++++++++++-- src/process/exec.ts | 3 +++ 5 files changed, 26 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcde2b446..6b3ebba1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - TUI: keep the last streamed response instead of replacing it with "(no output)". (#747 — thanks @thewilloftheshadow) - Slack: accept slash commands with or without leading `/` for custom command configs. (#798 — thanks @thewilloftheshadow) - Onboarding/Configure: refuse to proceed with invalid configs; run `clawdbot doctor` first to avoid wiping custom fields. (#764 — thanks @mukhtharcm) +- Onboarding: quote Windows browser URLs when launching via `cmd start` to preserve OAuth query params. (#794 — thanks @roshanasingh4) - Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid “Incorrect role information” errors. (#804 — thanks @ThomsenDrake) - Discord/Slack: centralize reply-thread planning so auto-thread replies stay in the created thread without parent reply refs. - Telegram: respect account-scoped bindings when webhook mode is enabled. (#821 — thanks @gumadeiras) diff --git a/src/agents/pi-embedded-helpers.test.ts b/src/agents/pi-embedded-helpers.test.ts index dcb19ed83..b352bc319 100644 --- a/src/agents/pi-embedded-helpers.test.ts +++ b/src/agents/pi-embedded-helpers.test.ts @@ -10,8 +10,8 @@ import { isCloudCodeAssistFormatError, isCompactionFailureError, isContextOverflowError, - isMessagingToolDuplicate, isFailoverErrorMessage, + isMessagingToolDuplicate, normalizeTextForComparison, sanitizeGoogleTurnOrdering, sanitizeSessionMessagesImages, diff --git a/src/commands/onboard-helpers.test.ts b/src/commands/onboard-helpers.test.ts index 5d058f51d..334c6b51e 100644 --- a/src/commands/onboard-helpers.test.ts +++ b/src/commands/onboard-helpers.test.ts @@ -27,9 +27,13 @@ describe("openUrl", () => { expect(ok).toBe(true); expect(mocks.runCommandWithTimeout).toHaveBeenCalledTimes(1); - const argv = mocks.runCommandWithTimeout.mock.calls[0]?.[0]; - expect(argv?.slice(0, 4)).toEqual(["cmd", "/c", "start", ""]); + const [argv, options] = mocks.runCommandWithTimeout.mock.calls[0] ?? []; + expect(argv?.slice(0, 4)).toEqual(["cmd", "/c", "start", '""']); expect(argv?.at(-1)).toBe(`"${url}"`); + expect(options).toMatchObject({ + timeoutMs: 5_000, + windowsVerbatimArguments: true, + }); }); }); diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 37f85e1f5..bd04da0aa 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -232,9 +232,22 @@ function resolveSshTargetHint(): string { export async function openUrl(url: string): Promise { const resolved = await resolveBrowserOpenCommand(); if (!resolved.argv) return false; - const command = [...resolved.argv, resolved.quoteUrl ? `"${url}"` : url]; + const quoteUrl = resolved.quoteUrl === true; + const command = [...resolved.argv]; + if (quoteUrl) { + if (command.at(-1) === "") { + // Preserve the empty title token for `start` when using verbatim args. + command[command.length - 1] = '""'; + } + command.push(`"${url}"`); + } else { + command.push(url); + } try { - await runCommandWithTimeout(command, { timeoutMs: 5_000 }); + await runCommandWithTimeout(command, { + timeoutMs: 5_000, + windowsVerbatimArguments: quoteUrl, + }); return true; } catch { // ignore; we still print the URL for manual open diff --git a/src/process/exec.ts b/src/process/exec.ts index 4a0d6f831..71b828bcd 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -48,6 +48,7 @@ export type CommandOptions = { cwd?: string; input?: string; env?: NodeJS.ProcessEnv; + windowsVerbatimArguments?: boolean; }; export async function runCommandWithTimeout( @@ -59,6 +60,7 @@ export async function runCommandWithTimeout( ? { timeoutMs: optionsOrTimeout } : optionsOrTimeout; const { timeoutMs, cwd, input, env } = options; + const { windowsVerbatimArguments } = options; const hasInput = input !== undefined; // Spawn with inherited stdin (TTY) so tools like `pi` stay interactive when needed. @@ -67,6 +69,7 @@ export async function runCommandWithTimeout( stdio: [hasInput ? "pipe" : "inherit", "pipe", "pipe"], cwd, env: env ? { ...process.env, ...env } : process.env, + windowsVerbatimArguments, }); let stdout = ""; let stderr = "";