Skip to content

Windows: cmd /C start truncates the OAuth URL at the first &, so Grok browser login fails with "Missing or invalid client_id" #106

Description

@uaawafox

Summary

On Windows, claude-code-proxy grok auth login opens a browser at an OAuth URL that has been truncated at the first &. The browser receives only https://auth.x.ai/oauth2/authorize?response_type=code — with no client_id, redirect_uri, scope, code_challenge, or state — and auth.x.ai renders:

Missing or invalid client_id.

Browser login is therefore unusable on Windows. The failure looks like a revoked or wrong client ID, which is a misleading place to start debugging — the client ID is fine and is never actually sent.

src/providers/cursor/auth.rs:345 has the same defect (details at the bottom).

Environment

  • claude-code-proxy 0.1.32 (claude-code-proxy-windows-amd64.zip from the v0.1.32 release)
  • Windows 11 Enterprise 10.0.26200, x86-64
  • The calling shell is irrelevant — the truncation happens inside the cmd.exe that the proxy itself spawns.

Reproduction

claude-code-proxy grok auth login

A browser tab opens showing "Missing or invalid client_id." Note that the URL println!'d to the terminal is complete and correct — only the one handed to the browser is broken.

Mechanism

src/providers/grok/auth/login.rs:283-296:

fn open_browser(url: &str) {
    #[cfg(target_os = "windows")]
    let command = ("cmd", vec!["/C", "start", "", url]);
    ...
        let _ = std::process::Command::new(command.0)
            .args(command.1)
            .spawn();
}

The URL is passed unquoted. Rust's std::process applies MSVCRT argument quoting, which only adds quotes for arguments containing spaces, tabs, or quote characters — & is not one of them. So & reaches cmd.exe bare, and cmd.exe interprets it as a command separator.

authorize_url() (login.rs:133-149) builds the query as response_type first, then client_id, so the split lands immediately after response_type=code and discards everything else.

Demonstration, substituting echo for start to make the split visible. Python's list2cmdline implements the same MSVCRT quoting rules as Rust std, so the command line below is byte-identical in shape to what the proxy produces:

$ python -c "import subprocess; subprocess.run(['cmd','/C','echo','','https://auth.x.ai/oauth2/authorize?response_type=code&client_id=b1a00492-073a-47ea-816f-4c329264a828&redirect_uri=http%3A%2F%2F127.0.0.1%3A54321&scope=openid'])"

stdout: "" https://auth.x.ai/oauth2/authorize?response_type=code
stderr: 'client_id' is not recognized as an internal or external command,
        operable program or batch file.
        'redirect_uri' is not recognized as an internal or external command,
        operable program or batch file.
        'scope' is not recognized as an internal or external command,
        operable program or batch file.

Because the spawn result is discarded via let _ =, the three failed sub-commands are invisible at runtime.

The client ID is not the problem

Worth stating explicitly, since the error text points the wrong way. b1a00492-073a-47ea-816f-4c329264a828 is live:

$ curl -s -X POST https://auth.x.ai/oauth2/device/code \
    -d "client_id=b1a00492-073a-47ea-816f-4c329264a828&scope=openid profile email offline_access grok-cli:access api:access conversations:read conversations:write"
{"device_code":"...","user_code":"...","verification_uri":"https://accounts.x.ai/oauth2/device",...}
HTTP 200

Control, with a deliberately invalid client ID — note the different wording:

$ curl -s -X POST https://auth.x.ai/oauth2/device/code \
    -d "client_id=00000000-0000-0000-0000-000000000000&scope=openid"
{"error":"invalid_client","error_description":"Unknown or disabled client"}
HTTP 400

grok-cli:access is also still present in scopes_supported at https://auth.x.ai/.well-known/openid-configuration.

Workarounds

Both work today:

  1. claude-code-proxy grok auth device — never calls open_browser, so the bug cannot fire.
  2. claude-code-proxy grok auth login, then ignore the errored tab and paste the complete URL that login.rs:49 prints to the terminal. The loopback listener is still waiting, and the flow completes normally.

Suggested fix

Any of:

  • Quote the URL: vec!["/C", "start", "", &format!("\"{url}\"")]. start accepts a quoted target, and quoting suppresses cmd's & handling.
  • Escape the metacharacters with ^ before handing them to cmd.
  • Skip cmd entirely — ShellExecuteW via windows/winapi, or rundll32 url.dll,FileProtocolHandler <url>, neither of which involves a command interpreter. This is the sturdiest option; the open/opener crates also handle it.

Separately: open_browser swallowing the spawn result with let _ = means a genuinely failed browser launch is also silent. Logging it would make this class of problem self-evident rather than looking like a server-side auth rejection.

Same defect in the Cursor provider

src/providers/cursor/auth.rs:340-353:

} else if cfg!(target_os = "windows") {
    std::process::Command::new("cmd")
        .args(["/c", "start", "", url])
        .status()?

Identical unquoted-URL pattern. Whether it breaks in practice depends on whether the Cursor login URL carries a query string with &, which I haven't tested — but it will truncate the same way if it does. Codex does not use this code path.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions