|
1 | 1 | import logger from "../../logger.js"; |
2 | | -import childProcess from "child_process"; |
3 | 2 | import { filterDesktop } from "./desktop-filter.js"; |
4 | 3 | import { filterMobile } from "./mobile-filter.js"; |
5 | 4 | import { |
@@ -73,10 +72,21 @@ export async function startBrowserSession( |
73 | 72 | isLocal, |
74 | 73 | ) |
75 | 74 | : buildMobileUrl(args as MobileSearchArgs, entry as MobileEntry, isLocal); |
| 75 | + const note = entry.notes ? `, ${entry.notes}` : ""; |
| 76 | + |
76 | 77 | if (!envConfig.REMOTE_MCP) { |
77 | | - openBrowser(url); |
| 78 | + const openCommand = getOpenBrowserCommand(url); |
| 79 | + if (openCommand) { |
| 80 | + return [ |
| 81 | + `Live session URL: ${url}${note}`, |
| 82 | + ``, |
| 83 | + `To open the session in the default browser, run:`, |
| 84 | + ` ${openCommand}`, |
| 85 | + ].join("\n"); |
| 86 | + } |
78 | 87 | } |
79 | | - return entry.notes ? `${url}, ${entry.notes}` : url; |
| 88 | + |
| 89 | + return `${url}${note}`; |
80 | 90 | } |
81 | 91 |
|
82 | 92 | function buildDesktopUrl( |
@@ -125,28 +135,35 @@ function buildMobileUrl( |
125 | 135 | return `https://live.browserstack.com/dashboard#${params.toString()}`; |
126 | 136 | } |
127 | 137 |
|
128 | | -// ——— Open a browser window ——— |
| 138 | +// ——— Build a browser-open command for the host agent ——— |
129 | 139 |
|
130 | | -function openBrowser(launchUrl: string): void { |
| 140 | +/** |
| 141 | + * Returns the platform-appropriate shell command to open `launchUrl` in the |
| 142 | + * default browser, or null if the URL is not a trusted BrowserStack URL. |
| 143 | + * |
| 144 | + * The command is returned to the MCP client so the host agent can prompt the |
| 145 | + * user before executing it. The server itself never spawns a process, which |
| 146 | + * eliminates the command-injection surface entirely. |
| 147 | + */ |
| 148 | +function getOpenBrowserCommand(launchUrl: string): string | null { |
| 149 | + let parsed: URL; |
131 | 150 | try { |
132 | | - const command = |
133 | | - process.platform === "darwin" |
134 | | - ? ["open", launchUrl] |
135 | | - : process.platform === "win32" |
136 | | - ? ["cmd", "/c", "start", `""`, `"${launchUrl}"`] |
137 | | - : ["xdg-open", launchUrl]; |
138 | | - |
139 | | - // nosemgrep:javascript.lang.security.detect-child-process.detect-child-process |
140 | | - const child = childProcess.spawn(command[0], command.slice(1), { |
141 | | - stdio: "ignore", |
142 | | - detached: true, |
143 | | - ...(process.platform === "win32" ? { shell: true } : {}), |
144 | | - }); |
145 | | - child.on("error", (err) => |
146 | | - logger.error(`Failed to open browser: ${err}. URL: ${launchUrl}`), |
147 | | - ); |
148 | | - child.unref(); |
149 | | - } catch (err) { |
150 | | - logger.error(`Failed to launch browser: ${err}. URL: ${launchUrl}`); |
| 151 | + parsed = new URL(launchUrl); |
| 152 | + } catch { |
| 153 | + logger.error(`Refusing to surface malformed URL: ${launchUrl}`); |
| 154 | + return null; |
| 155 | + } |
| 156 | + |
| 157 | + if ( |
| 158 | + parsed.protocol !== "https:" || |
| 159 | + !/(^|\.)browserstack\.com$/i.test(parsed.hostname) |
| 160 | + ) { |
| 161 | + logger.error(`Refusing to surface untrusted URL: ${launchUrl}`); |
| 162 | + return null; |
151 | 163 | } |
| 164 | + |
| 165 | + const quoted = `"${parsed.toString()}"`; |
| 166 | + if (process.platform === "darwin") return `open ${quoted}`; |
| 167 | + if (process.platform === "win32") return `cmd /c start "" ${quoted}`; |
| 168 | + return `xdg-open ${quoted}`; |
152 | 169 | } |
0 commit comments