Skip to content

Commit 2f8ea9c

Browse files
ArtyETH06claude
andauthored
fix(mcp): Windows .dxt sign-in browser now opens reliably (product#3839) (#139)
* fix(mcp): Windows .dxt sign-in browser now opens reliably (product#3839) On Windows, openInBrowser resolved the moment cmd.exe was CREATED — before its `start` builtin actually handed the URL to the browser. A silent no-op (no default-browser association / a locked-down shell / AppLocker) went undetected: nothing opened yet the flow reported success and told the user "a browser may have opened." Windows now waits (bounded) for each launcher's exit code, treats a non-zero exit as failure, and falls through to shell-free launchers — rundll32 url.dll,FileProtocolHandler (Explorer's ShellExecute path, honest exit code) then PowerShell Start-Process. When all fail, browserOpenFailedAtBootstrap is set and the AUTH_REQUIRED envelope honestly says the browser couldn't open, with the clickable sign-in link. macOS/Linux keep resolve-on-spawn (the #3805 headless-hang fix). The #3801 &-quoting cmd candidate is unchanged, tried first. Shutdown browser-open wait 1.5s -> 3s for the multi-launcher walk. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): quote PowerShell fallback URL so `&` isn't parsed as PS source The PowerShell Start-Process fallback passed the raw URL after `-Command`, which parses everything as PowerShell SOURCE — so an OAuth authorize URL's `&` (query separators) would be read as PS's call/separator operator and the URL mangled or split, breaking the last recovery path on locked-down Windows (both cmd candidates failed + rundll32 unavailable). Wrap the URL in a single-quoted PS string literal (verbatim form; escape embedded `'` by doubling). rundll32 keeps the raw URL (no shell). New test covers the multi-`&` URL and the quote-escape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 92f0f1a commit 2f8ea9c

7 files changed

Lines changed: 421 additions & 11 deletions

File tree

packages/mcp/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog — @leadbay/mcp
22

3+
## 0.23.11 — 2026-07-02
4+
5+
Windows `.dxt` sign-in now opens the browser reliably (product#3839).
6+
7+
- **`openInBrowser` (Windows)** — the auto-open resolved the moment `cmd.exe` was *created*, before its internal `start` builtin actually handed the URL to the default browser. So a silent no-op (no default-browser protocol association, a locked-down shell / AppLocker, a corrupt `HKCR\http\shell\open`) went undetected: `browserOpenFailedAtBootstrap` stayed false and the user was told "a browser may have opened" when nothing did. On Windows we now wait (bounded — 800ms for `cmd start`, 1200ms otherwise) for the launcher's **exit code**, treat a non-zero exit as failure, and fall through to `rundll32 url.dll,FileProtocolHandler` (no command interpreter — the same ShellExecute path Explorer uses, with an honest exit code) and finally PowerShell `Start-Process`. When every launcher fails, the `AUTH_REQUIRED` envelope honestly says the browser couldn't be opened and shows the clickable sign-in link. The #3801 `&`-quoting `cmd start` candidate is unchanged and still tried first. macOS/Linux keep resolve-on-`spawn` (the #3805 headless-hang fix — those launchers are the hand-off).
8+
- **`bin.ts` shutdown** — the `browserOpenInFlight` teardown wait rose 1.5s → 3s so the multi-launcher Windows walk can finish dispatching before exit (the sibling bootstrap wait already allows 4s; the surfaced sign-in link is the fallback either way).
9+
310
## 0.23.10 — 2026-07-01
411

512
A freshly-created lens no longer reads as "empty" (product#3833).

packages/mcp/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@leadbay/mcp",
3-
"version": "0.23.10",
3+
"version": "0.23.11",
44
"mcpName": "io.github.leadbay/leadbay-mcp",
55
"description": "Model Context Protocol (MCP) server for Leadbay — AI lead discovery, qualification, and enrichment for Claude Desktop, Cursor, and Claude Code.",
66
"type": "module",

packages/mcp/server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"name": "io.github.leadbay/leadbay-mcp",
44
"title": "Leadbay",
55
"description": "AI lead discovery, qualification, and outreach prep on your Leadbay account.",
6-
"version": "0.23.10",
6+
"version": "0.23.11",
77
"repository": {
88
"url": "https://github.com/leadbay/mcp",
99
"source": "github",
@@ -15,7 +15,7 @@
1515
"registryType": "npm",
1616
"registryBaseUrl": "https://registry.npmjs.org",
1717
"identifier": "@leadbay/mcp",
18-
"version": "0.23.10",
18+
"version": "0.23.11",
1919
"transport": {
2020
"type": "stdio"
2121
},

packages/mcp/src/bin.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1765,11 +1765,16 @@ async function main(): Promise<void> {
17651765
}
17661766
}
17671767
if (browserOpenInFlight) {
1768-
bootstrapDebug(`shutdown(code=${code}) browser-open still in flight — waiting up to 1.5s`);
1768+
// 3s (was 1.5s): on Windows openInBrowser now waits for each launcher's
1769+
// exit code and may walk cmd → rundll32 before one succeeds (#3839), so
1770+
// the dispatch can take longer than a single spawn. Best-effort during
1771+
// teardown; the sibling bootstrapInFlight race already waits 4s, and the
1772+
// surfaced sign-in link remains the fallback if we're still cut off.
1773+
bootstrapDebug(`shutdown(code=${code}) browser-open still in flight — waiting up to 3s`);
17691774
try {
17701775
await Promise.race([
17711776
browserOpenInFlight,
1772-
new Promise((r) => setTimeout(r, 1500)),
1777+
new Promise((r) => setTimeout(r, 3000)),
17731778
]);
17741779
} catch {
17751780
// ignore — best-effort; the surfaced sign-in link is the fallback

packages/mcp/src/oauth.ts

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,47 @@ export function browserOpenCandidates(url: string): Array<{ cmd: string; args: s
558558
];
559559
}
560560

561+
/**
562+
* Windows-only SECOND-CHANCE launchers, tried by openInBrowser after every
563+
* `cmd /c start` candidate above has failed (issue #3839).
564+
*
565+
* Why a separate list rather than more entries in browserOpenCandidates(): the
566+
* `cmd start` candidates all end in a double-quoted `"<url>"` (the #3801
567+
* fix, pinned by a regression test). These launchers take the URL as a RAW,
568+
* unquoted single argument — mixing the two shapes in one list is confusing and
569+
* would break that test's "every candidate is quoted" invariant. Keeping them
570+
* apart also makes the intent explicit: cmd first (fast, familiar), these only
571+
* when cmd silently no-ops.
572+
*
573+
* 1. rundll32 url.dll,FileProtocolHandler <url> — the same ShellExecute path
574+
* Explorer uses. No command interpreter, so no `&`-truncation and no
575+
* quoting dance; returns a non-zero exit when the protocol handler can't
576+
* be invoked, so openInBrowser's exit-wait DETECTS the failure. This is
577+
* the launcher that recovers #3839 when `cmd start` no-ops (no
578+
* default-browser association / a locked-down shell / AppLocker).
579+
* 2. powershell Start-Process <url> — heavy (spins a PS runtime) and may be
580+
* blocked in locked-down orgs (constrained language mode / AppLocker), so
581+
* it's the last resort; where present it gives a reliable exit code.
582+
* -NoProfile/-NonInteractive keep it from sourcing a profile or prompting.
583+
*/
584+
export function windowsFallbackCandidates(url: string): Array<{ cmd: string; args: string[] }> {
585+
const sysRoot = process.env.SystemRoot || process.env.windir || "C:\\Windows";
586+
// `-Command` parses everything after it as PowerShell SOURCE, not literal
587+
// args — so a raw OAuth URL (`…?a=1&b=2`) would have its `&` read as PS's
588+
// call/separator operator and the URL would be mangled or split. Wrap it in a
589+
// single-quoted PS string literal (the verbatim form: `&`, spaces, `?`, `=`
590+
// are all inert inside `'…'`), escaping any embedded `'` by doubling it per
591+
// PS convention. windowsVerbatimArguments at spawn keeps these quotes intact.
592+
const psLiteral = `'${url.replace(/'/g, "''")}'`;
593+
return [
594+
{ cmd: `${sysRoot}\\System32\\rundll32.exe`, args: ["url.dll,FileProtocolHandler", url] },
595+
{
596+
cmd: `${sysRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`,
597+
args: ["-NoProfile", "-NonInteractive", "-Command", "Start-Process", psLiteral],
598+
},
599+
];
600+
}
601+
561602
/**
562603
* Build the env to spawn the browser launcher with. On Linux, Claude Desktop
563604
* spawns the .dxt server with an INCONSISTENT environment — DISPLAY and
@@ -672,7 +713,13 @@ export async function openInBrowser(
672713
//
673714
// Try each candidate in order; only the LAST ENOENT propagates. This makes
674715
// the launch independent of the inherited PATH (see browserOpenCandidates).
675-
const candidates = browserOpenCandidates(url);
716+
// On Windows, append the shell-free second-chance launchers: the exit-wait
717+
// below turns a silent `cmd start` no-op into a detected failure that falls
718+
// through to rundll32 / PowerShell (issue #3839).
719+
const candidates =
720+
process.platform === "win32"
721+
? [...browserOpenCandidates(url), ...windowsFallbackCandidates(url)]
722+
: browserOpenCandidates(url);
676723
const launchEnv = browserLaunchEnv(debug);
677724
debug?.(
678725
`openInBrowser: platform=${process.platform} ` +
@@ -685,17 +732,66 @@ export async function openInBrowser(
685732
for (const { cmd, args } of candidates) {
686733
try {
687734
await new Promise<void>((resolve, reject) => {
688-
const spawnOpts: SpawnOptions = { stdio: "ignore", detached: true, env: launchEnv };
735+
const isWin = process.platform === "win32";
736+
// POSIX: detach + unref so the browser opener outlives us — `open` /
737+
// `xdg-open` ARE the hand-off and return immediately, so resolving on
738+
// `spawn` is correct (and load-bearing: #3805 deliberately moved OFF
739+
// waiting for `close`, which a detached child often never emits, to
740+
// stop the installer hanging in headless hosts). Windows: do NOT
741+
// detach — we now observe the child's EXIT to detect a silent no-op
742+
// (#3839), and a detached Win child in its own group only makes that
743+
// harder and risks a console flash.
744+
const spawnOpts: SpawnOptions = { stdio: "ignore", detached: !isWin, env: launchEnv };
689745
// On Windows the candidate args carry a pre-quoted "<url>" (see
690746
// browserOpenCandidates); verbatim mode stops Node's arg-quoter from
691747
// mangling those quotes, so cmd sees the whole URL as one token.
692-
if (process.platform === "win32") spawnOpts.windowsVerbatimArguments = true;
748+
if (isWin) spawnOpts.windowsVerbatimArguments = true;
693749
const child = spawn(cmd, args, spawnOpts);
694750
child.on("error", reject);
695-
child.on("spawn", () => {
696-
debug?.(`spawn OK: ${cmd} (pid=${child.pid})`);
751+
752+
if (!isWin) {
753+
// mac/Linux: resolve the moment the opener is spawned, then detach.
754+
child.on("spawn", () => {
755+
debug?.(`spawn OK: ${cmd} (pid=${child.pid})`);
756+
child.unref();
757+
resolve();
758+
});
759+
return;
760+
}
761+
762+
// Windows: `"spawn"` only means cmd/rundll32/powershell was CREATED —
763+
// the real browser hand-off happens INSIDE it and can no-op / exit
764+
// non-zero. So wait (bounded) for the exit code:
765+
// exit 0 (or null) → launched OK → success
766+
// non-zero exit → failed → fall through to the next candidate
767+
// no exit by the budget → still running → assume it dispatched + resolve
768+
// `cmd start` gets a tighter budget (it normally returns instantly — a
769+
// stall there is anomalous) so we reach rundll32 fast enough to stay
770+
// inside shutdown()'s browserOpenInFlight wait.
771+
const isCmd = /cmd(\.exe)?$/i.test(cmd);
772+
const budgetMs = isCmd ? 800 : 1200;
773+
let settled = false;
774+
const finish = (fn: () => void) => {
775+
if (settled) return;
776+
settled = true;
777+
fn();
778+
};
779+
const timer = setTimeout(() => {
780+
debug?.(`exit-wait timeout (${budgetMs}ms): ${cmd} — assuming launched`);
697781
child.unref();
698-
resolve();
782+
finish(resolve);
783+
}, budgetMs);
784+
timer.unref?.();
785+
child.on("spawn", () => debug?.(`spawn OK: ${cmd} (pid=${child.pid}) — awaiting exit`));
786+
child.on("exit", (code) => {
787+
clearTimeout(timer);
788+
if (code === 0 || code === null) {
789+
debug?.(`exit ${code}: ${cmd} — launched OK`);
790+
finish(resolve);
791+
} else {
792+
debug?.(`exit ${code}: ${cmd} — treating as failed, next candidate`);
793+
finish(() => reject(new Error(`${cmd} exited ${code}`)));
794+
}
699795
});
700796
});
701797
return; // launched successfully
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* Regression tests for OAuth-broken-on-Windows, part 2 (issue #3839).
3+
*
4+
* The #3801 fix quoted the URL so `cmd start` wouldn't truncate it at `&`. But
5+
* `cmd` ALWAYS spawns (fixed path), and its `start` builtin does the real
6+
* browser hand-off — which can silently no-op (no default-browser protocol
7+
* association / a locked-down shell) while spawn still reports success. So the
8+
* browser never opened and the fallback never fired.
9+
*
10+
* Fix pinned here: two shell-free second-chance launchers live in a SEPARATE
11+
* list, windowsFallbackCandidates(), which openInBrowser tries only after every
12+
* `cmd start` candidate has failed the exit-wait (see the exit-wait test):
13+
* 1. rundll32 url.dll,FileProtocolHandler <url> — Explorer's ShellExecute
14+
* path, no command interpreter (no `&` hazard, raw URL), honest exit code.
15+
* 2. powershell -NoProfile -NonInteractive -Command Start-Process <url>
16+
* — heavy last resort with a reliable exit code.
17+
* They're kept OUT of browserOpenCandidates() so the #3801-pinned "every win32
18+
* candidate ends in a quoted URL" invariant stays intact.
19+
*
20+
* New file — the existing oauth-browser-open.test.ts / -win32-url.test.ts pins
21+
* are left untouched.
22+
*/
23+
import { describe, it, expect, afterEach } from "vitest";
24+
import { browserOpenCandidates, windowsFallbackCandidates } from "../../src/oauth.js";
25+
26+
const AUTH_URL =
27+
"https://leadbay.app/oauth/authorize?client_id=99" +
28+
"&code_challenge=abc123&code_challenge_method=S256&state=xyz789" +
29+
"&redirect_uri=http%3A%2F%2F127.0.0.1%3A51789%2Fcallback";
30+
31+
const realPlatform = process.platform;
32+
function setPlatform(p: NodeJS.Platform) {
33+
Object.defineProperty(process, "platform", { value: p, configurable: true });
34+
}
35+
afterEach(() => setPlatform(realPlatform));
36+
37+
describe("browserOpenCandidates — win32 head is still just the #3801 cmd pair", () => {
38+
it("returns exactly the two `cmd start` candidates (no shell-free launchers mixed in)", () => {
39+
setPlatform("win32");
40+
const savedRoot = process.env.SystemRoot;
41+
process.env.SystemRoot = "C:\\Windows";
42+
try {
43+
const cands = browserOpenCandidates(AUTH_URL);
44+
expect(cands).toHaveLength(2);
45+
// Every candidate ends in the double-quoted URL — the invariant the
46+
// #3801 regression test loops over. Appending raw-URL launchers HERE
47+
// would break it; that's why they live in windowsFallbackCandidates.
48+
for (const c of cands) {
49+
expect(c.args[c.args.length - 1]).toBe(`"${AUTH_URL}"`);
50+
}
51+
} finally {
52+
if (savedRoot === undefined) delete process.env.SystemRoot;
53+
else process.env.SystemRoot = savedRoot;
54+
}
55+
});
56+
});
57+
58+
describe("windowsFallbackCandidates — shell-free second-chance launchers (#3839)", () => {
59+
it("returns rundll32 then powershell, both with the RAW (unquoted) URL", () => {
60+
const savedRoot = process.env.SystemRoot;
61+
process.env.SystemRoot = "C:\\Windows";
62+
try {
63+
const cands = windowsFallbackCandidates(AUTH_URL);
64+
expect(cands).toHaveLength(2);
65+
66+
// 1: rundll32 with the raw URL as one literal arg — no shell, so no
67+
// quoting dance and no `&`-truncation hazard.
68+
expect(cands[0]).toEqual({
69+
cmd: "C:\\Windows\\System32\\rundll32.exe",
70+
args: ["url.dll,FileProtocolHandler", AUTH_URL],
71+
});
72+
expect(cands[0].args[cands[0].args.length - 1]).toBe(AUTH_URL);
73+
expect(cands[0].args[cands[0].args.length - 1]).not.toMatch(/^".*"$/);
74+
75+
// 2: PowerShell Start-Process, last resort, also with the raw URL.
76+
expect(cands[1].cmd).toBe(
77+
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
78+
);
79+
expect(cands[1].args).toContain("Start-Process");
80+
expect(cands[1].args).toContain("-NoProfile");
81+
expect(cands[1].args).toContain("-NonInteractive");
82+
// The URL is passed to `-Command` as a SINGLE-QUOTED PowerShell string
83+
// literal so its `&` (and `?`, `=`, spaces) can't be parsed as PS source.
84+
// The raw, unquoted URL must NOT appear as an arg on its own.
85+
expect(cands[1].args).toContain(`'${AUTH_URL}'`);
86+
expect(cands[1].args).not.toContain(AUTH_URL);
87+
// …and the quoted literal still carries the full multi-`&` URL intact.
88+
const psArg = cands[1].args[cands[1].args.length - 1];
89+
expect(psArg).toBe(`'${AUTH_URL}'`);
90+
expect(psArg).toContain("&code_challenge=abc123");
91+
expect(psArg).toContain("redirect_uri=http%3A%2F%2F127.0.0.1%3A51789%2Fcallback");
92+
} finally {
93+
if (savedRoot === undefined) delete process.env.SystemRoot;
94+
else process.env.SystemRoot = savedRoot;
95+
}
96+
});
97+
98+
it("resolves both launchers under %windir% when SystemRoot is unset", () => {
99+
const savedRoot = process.env.SystemRoot;
100+
const savedWindir = process.env.windir;
101+
delete process.env.SystemRoot;
102+
process.env.windir = "D:\\WINNT";
103+
try {
104+
const cands = windowsFallbackCandidates(AUTH_URL);
105+
expect(cands[0].cmd).toBe("D:\\WINNT\\System32\\rundll32.exe");
106+
expect(cands[1].cmd).toBe(
107+
"D:\\WINNT\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
108+
);
109+
} finally {
110+
if (savedRoot === undefined) delete process.env.SystemRoot;
111+
else process.env.SystemRoot = savedRoot;
112+
if (savedWindir === undefined) delete process.env.windir;
113+
else process.env.windir = savedWindir;
114+
}
115+
});
116+
117+
it("escapes an embedded single quote in the PowerShell literal (PS doubling)", () => {
118+
// Defensive: a normal authorize URL is percent-encoded and won't contain a
119+
// literal `'`, but if one ever appears it must be doubled so it can't close
120+
// the PS string early and inject source. rundll32 keeps the raw URL.
121+
const trickyUrl = "https://x.test/authorize?state=a'b&c=1";
122+
const cands = windowsFallbackCandidates(trickyUrl);
123+
expect(cands[0].args).toContain(trickyUrl); // rundll32 unquoted
124+
const psArg = cands[1].args[cands[1].args.length - 1];
125+
expect(psArg).toBe("'https://x.test/authorize?state=a''b&c=1'");
126+
});
127+
});

0 commit comments

Comments
 (0)