fix(cli): avoid headless browser open crashes#4716
Conversation
| command = browserCommand.command; | ||
| args = [...browserCommand.args, url]; | ||
| } else { | ||
| throw new Error('Invalid BROWSER environment variable'); |
There was a problem hiding this comment.
[Suggestion] throw new Error('Invalid BROWSER environment variable') violates the function's documented contract. The JSDoc at line 53 states: "this function does NOT throw an error… and resolves successfully to prevent application crashes." However, when parseBrowserCommand returns undefined (e.g., BROWSER='""'), this throw propagates to callers like bugCommand and insightCommand which rely on the non-throwing guarantee.
| throw new Error('Invalid BROWSER environment variable'); | |
| console.warn('Invalid BROWSER environment variable, falling back to platform default.'); | |
| // fall through to the platform switch below |
Alternatively, restructure the if/else so an invalid BROWSER simply falls through to the platform-specific switch rather than throwing.
— qwen3.7-max via Qwen Code /review
| const browserEnv = process.env['BROWSER']?.trim(); | ||
| const browserBlocklist = ['www-browser']; | ||
| if (browserEnv && !browserBlocklist.includes(browserEnv)) { | ||
| const browserCommand = parseBrowserCommand(browserEnv); |
There was a problem hiding this comment.
[Suggestion] browserBlocklist.includes(browserEnv) compares the full trimmed BROWSER value against 'www-browser'. A value like BROWSER="www-browser --headless" bypasses the blocklist because the full string doesn't match. The check should compare against the parsed command name instead:
const browserCommand = parseBrowserCommand(browserEnv);
if (browserCommand && !browserBlocklist.includes(browserCommand.command)) {
command = browserCommand.command;
args = [...browserCommand.args, url];
} else if (browserCommand && browserBlocklist.includes(browserCommand.command)) {
// blocked — fall through to platform switch
} else {
throw new Error('Invalid BROWSER environment variable');
}Also, the same ['www-browser'] array is duplicated in shouldLaunchBrowser() (line ~199). Consider extracting it to a module-level constant:
const BROWSER_BLOCKLIST = ['www-browser'];Additionally, when BROWSER is set on Linux and the specified command fails, the fallback chain (gnome-open, kde-open, firefox, etc.) is never attempted because the guard at line ~140 checks command === 'xdg-open'. Consider also triggering fallbacks when BROWSER was the primary source.
— qwen3.7-max via Qwen Code /review
| context.ui.addItem( | ||
| { | ||
| type: MessageType.INFO, | ||
| text: t('Opening insights in your browser: {{path}}', { |
There was a problem hiding this comment.
[Suggestion] Moving the "Opening insights" message before the try block creates a UX issue: when the browser fails to launch, the user sees the optimistic "Opening insights in your browser" message followed by a raw file:// URL on stderr from openBrowserSecurely's internal catch. The catch block below (which shows a friendlier filesystem-path fallback) is effectively dead code for browser-launch failures, because openBrowserSecurely swallows errors internally and resolves normally.
Consider either:
- Moving the "Opening" message back inside
tryafter theawait, or - Having
openBrowserSecurelysignal failure to the caller (e.g., return a boolean) soinsightCommandcan decide which message to show.
— qwen3.7-max via Qwen Code /review
| Date.now(), | ||
| ); | ||
| await open(docsUrl); | ||
| await openBrowserSecurely(docsUrl); |
There was a problem hiding this comment.
[Suggestion] Unlike bugCommand (line 57-66) and insightCommand (line 230-244), this call to openBrowserSecurely is not wrapped in a try/catch. While openBrowserSecurely catches browser-launch failures internally, validateUrl() inside it does throw for malformed URLs. Adding a defensive try/catch here would be consistent with the other two call sites and protect against unexpected URL construction issues.
| await openBrowserSecurely(docsUrl); | |
| try { | |
| await openBrowserSecurely(docsUrl); | |
| } catch (error) { | |
| const errorMessage = error instanceof Error ? error.message : String(error); | |
| context.ui.addItem( | |
| { | |
| type: MessageType.ERROR, | |
| text: t('Failed to open browser: {{error}}', { error: errorMessage }), | |
| }, | |
| Date.now(), | |
| ); | |
| } |
— qwen3.7-max via Qwen Code /review
| 'xdg-open', | ||
| ['file:///tmp/report.html'], | ||
| expect.any(Object), | ||
| ); |
There was a problem hiding this comment.
[Suggestion] Several test scenarios for the new BROWSER env var feature are missing:
- Blocklisted value:
BROWSER=www-browsershould fall through to platform default (the blocklist branch is untested). - Quoted command paths:
parseBrowserCommand's quote-handling regex is the main value-add over a simple.split(' '), but no test covers paths likeBROWSER='"/path/to/my browser" --new-tab'. - Fallback suppression: No test verifies that Linux fallbacks are skipped when
BROWSERis set and fails.
Adding these would strengthen coverage of the new code paths.
— qwen3.7-max via Qwen Code /review
| const browserCommand = parseBrowserCommand(browserEnv); | ||
| if (browserCommand) { | ||
| command = browserCommand.command; | ||
| args = [...browserCommand.args, url]; |
There was a problem hiding this comment.
BROWSER values commonly use %s as the URL placeholder (for example BROWSER="firefox --new-tab %s"). With the current construction we keep %s as a literal argument and append the real URL after it, so the configured browser may try to open %s or receive two targets. Please substitute the URL into any %s argument, and only append url when no placeholder is present; add a test for that form.
|
Thanks for the contribution — the direction of replacing A few architectural points beyond the inline feedback from wenshao: 1. Missing The codebase already has a browser-availability detection flow: Without this, a headless container still runs through the full xdg-open → gnome-open → kde-open → firefox → chromium → google-chrome → microsoft-edge fallback chain before giving up. 2. Other callsites not addressed
3. Duplicated browser-check logic
4. Manual testing evidence Once the PR is updated, please add screenshots or a short recording showing the behavior in a headless Linux container (the core scenario from the issue) — both the success path (URL displayed, no crash) and the fallback path. This helps confirm the fix end-to-end before merge. For context — I had already analyzed this issue in detail and another contributor was looking into it as well. That said, I appreciate you picking this up. The points above are the main gaps I'd like to see addressed before we move forward. |
Summary
opencalls in/bug,/docs, and/insightwith the existingopenBrowserSecurely()pathBROWSERwithout going through a shellfile://support opt-in for the local insight HTML report, while HTTP/HTTPS remain the defaultFixes #4712.
To verify
npm run test --workspace=packages/core -- src/utils/secure-browser-launcher.test.tsnpm run test --workspace=packages/cli -- src/ui/commands/bugCommand.test.ts src/ui/commands/docsCommand.test.ts src/ui/commands/insightCommand.test.tsnpx eslint packages/core/src/utils/secure-browser-launcher.ts packages/core/src/utils/secure-browser-launcher.test.ts packages/core/src/index.ts packages/cli/src/ui/commands/bugCommand.ts packages/cli/src/ui/commands/docsCommand.ts packages/cli/src/ui/commands/insightCommand.ts packages/cli/src/ui/commands/bugCommand.test.ts packages/cli/src/ui/commands/docsCommand.test.ts packages/cli/src/ui/commands/insightCommand.test.tsnpm run typecheck --workspace=packages/corenpm run build --workspace=packages/corenpm run build --workspace=@qwen-code/acp-bridgenpm run build --workspace=@qwen-code/channel-basenpm run build --workspace=@qwen-code/channel-telegramnpm run build --workspace=@qwen-code/channel-weixinnpm run build --workspace=@qwen-code/channel-dingtalknpm run build --workspace=@qwen-code/channel-feishunpm run build --workspace=@qwen-code/web-templatesnpm run typecheck --workspace=packages/cligit diff --checkNote:
npm ci --ignore-scripts --prefer-offlinereported existing dependency audit findings; I did not runnpm audit fixbecause this PR does not change dependencies.