Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

fix(cli): avoid headless browser open crashes#4716

Merged
wenshao merged 5 commits into
QwenLM:mainQwenLM/qwen-code:mainfrom
he-yufeng:fix/headless-slash-command-openhe-yufeng/qwen-code:fix/headless-slash-command-openCopy head branch name to clipboard
Jun 12, 2026
Merged

fix(cli): avoid headless browser open crashes#4716
wenshao merged 5 commits into
QwenLM:mainQwenLM/qwen-code:mainfrom
he-yufeng:fix/headless-slash-command-openhe-yufeng/qwen-code:fix/headless-slash-command-openCopy head branch name to clipboard

Conversation

@he-yufeng

Copy link
Copy Markdown
Contributor

Summary

  • replace direct open calls in /bug, /docs, and /insight with the existing openBrowserSecurely() path
  • teach the secure launcher to honor BROWSER without going through a shell
  • keep file:// support opt-in for the local insight HTML report, while HTTP/HTTPS remain the default

Fixes #4712.

To verify

  • npm run test --workspace=packages/core -- src/utils/secure-browser-launcher.test.ts
  • npm run test --workspace=packages/cli -- src/ui/commands/bugCommand.test.ts src/ui/commands/docsCommand.test.ts src/ui/commands/insightCommand.test.ts
  • npx 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.ts
  • npm run typecheck --workspace=packages/core
  • npm run build --workspace=packages/core
  • npm run build --workspace=@qwen-code/acp-bridge
  • npm run build --workspace=@qwen-code/channel-base
  • npm run build --workspace=@qwen-code/channel-telegram
  • npm run build --workspace=@qwen-code/channel-weixin
  • npm run build --workspace=@qwen-code/channel-dingtalk
  • npm run build --workspace=@qwen-code/channel-feishu
  • npm run build --workspace=@qwen-code/web-templates
  • npm run typecheck --workspace=packages/cli
  • git diff --check

Note: npm ci --ignore-scripts --prefer-offline reported existing dependency audit findings; I did not run npm audit fix because this PR does not change dependencies.

command = browserCommand.command;
args = [...browserCommand.args, url];
} else {
throw new Error('Invalid BROWSER environment variable');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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}}', {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

  1. Moving the "Opening" message back inside try after the await, or
  2. Having openBrowserSecurely signal failure to the caller (e.g., return a boolean) so insightCommand can decide which message to show.

— qwen3.7-max via Qwen Code /review

Date.now(),
);
await open(docsUrl);
await openBrowserSecurely(docsUrl);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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),
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Several test scenarios for the new BROWSER env var feature are missing:

  1. Blocklisted value: BROWSER=www-browser should fall through to platform default (the blocklist branch is untested).
  2. Quoted command paths: parseBrowserCommand's quote-handling regex is the main value-add over a simple .split(' '), but no test covers paths like BROWSER='"/path/to/my browser" --new-tab'.
  3. Fallback suppression: No test verifies that Linux fallbacks are skipped when BROWSER is 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];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@yiliang114

Copy link
Copy Markdown
Collaborator

Thanks for the contribution — the direction of replacing open with openBrowserSecurely is right and matches what I had in mind for this issue.

A few architectural points beyond the inline feedback from wenshao:

1. Missing shouldLaunchBrowser() pre-check

The codebase already has a browser-availability detection flow: shouldAttemptBrowserLaunch() in browser.tsisBrowserLaunchSuppressed() in config.ts. The OAuth path in qwenOAuth2.ts (L856) uses this correctly — it checks the environment before attempting to open a browser. These three commands should follow the same pattern: check first, skip the launch attempt entirely in headless/CI/SSH environments, and just display the URL.

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

extensionsCommand.ts and qwenOAuth2.ts also use import open from 'open' and have the same headless crash risk. If we're fixing this pattern, it's worth covering all the callsites in one pass.

3. Duplicated browser-check logic

shouldLaunchBrowser() in secure-browser-launcher.ts and shouldAttemptBrowserLaunch() in browser.ts are identical (the comment even says so). Exporting both without consolidating them will cause drift over time. Worth deduplicating as part of this PR.

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.

@tanzhenxin tanzhenxin added the type/bug Something isn't working as expected label Jun 3, 2026
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Updated the branch to address the browser-launch review points:

  • openBrowserSecurely now uses the existing shouldAttemptBrowserLaunch() pre-check before selecting or spawning any browser command.
  • shouldLaunchBrowser() now delegates to the same helper instead of keeping a second copy of the environment logic.
  • Switched the remaining direct browser opens in /extensions explore and Qwen OAuth device flow to openBrowserSecurely.
  • Added headless Linux coverage: no DISPLAY/WAYLAND_DISPLAY/MIR_SOCKET means no execFile call, and the manual URL fallback is shown.

Validation run locally:

  • npm test --workspace=packages/core -- src/utils/secure-browser-launcher.test.ts src/qwen/qwenOAuth2.test.ts: 100 passed
  • npm test --workspace=packages/cli -- src/ui/commands/bugCommand.test.ts src/ui/commands/docsCommand.test.ts src/ui/commands/insightCommand.test.ts src/ui/commands/extensionsCommand.test.ts: 22 passed
  • npx eslint on touched source/test files: passed
  • npx prettier --check on touched files: passed
  • npm run typecheck --workspace=packages/core: passed
  • npm run typecheck --workspace=packages/cli: passed
  • npm run build --workspace=packages/core: passed
  • npm run build --workspace=packages/cli: passed
  • git diff --check: passed

@he-yufeng

Copy link
Copy Markdown
Contributor Author

Pushed a small test-only follow-up for the CI failure on this branch.

Root cause: the full CI job sets CI, so the new shouldAttemptBrowserLaunch() guard correctly skips browser launching. The browser launcher unit tests were still asserting the GUI-launch path without isolating that environment.

Change:

  • clear CI, DEBIAN_FRONTEND, and SSH_CONNECTION in the browser launcher test setup so the launch-path tests keep testing launch behavior explicitly

Validation run locally:

  • npm test --workspace=packages/core -- src/utils/secure-browser-launcher.test.ts src/qwen/qwenOAuth2.test.ts: 100 passed
  • npx eslint packages/core/src/utils/secure-browser-launcher.test.ts packages/core/src/utils/secure-browser-launcher.ts packages/core/src/qwen/qwenOAuth2.ts: passed
  • npx prettier --check packages/core/src/utils/secure-browser-launcher.test.ts packages/core/src/utils/secure-browser-launcher.ts packages/core/src/qwen/qwenOAuth2.ts: passed
  • npm run typecheck --workspace=packages/core: passed
  • npm run build --workspace=packages/core: passed
  • git diff --check: passed

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The open package ("open": "^10.1.2") remains in both packages/cli/package.json and packages/core/package.json despite zero remaining imports across the codebase. All callers now use openBrowserSecurely. Remove the dead dependency from both files and re-run npm install to update the lock file. (Posted as a body-level comment because package.json files are not in this PR's diff.)

— qwen3.7-max via Qwen Code /review


try {
await execFileAsync(command, args, options);
await execFileAsync(command, args, execOptions);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] execFileAsync (promisified execFile) waits for the child process to exit even with detached: true. The detached option only calls setsid() on the child — it does NOT make the promise resolve early.

For the default platform commands (open on macOS, xdg-open on Linux, powershell Start-Process on Windows), this is fine because they exit immediately after launching the browser. But when BROWSER points to a browser binary (e.g., firefox, chromium), the process stays alive and await execFileAsync(...) blocks indefinitely.

This causes two critical failure modes:

  • OAuth device flow deadlock (qwenOAuth2.ts): launchBrowser() blocks, the token polling loop never starts, the device code expires.
  • TUI command freeze (bugCommand, docsCommand, insightCommand): the command handler never returns.
Suggested change
await execFileAsync(command, args, execOptions);
try {
// For the BROWSER path, use spawn + unref for true fire-and-forget
// (execFileAsync would block until the long-running browser process exits)
const { spawn } = await import('node:child_process');
const child = spawn(command, args, {
detached: true,
stdio: 'ignore',
env: execOptions.env as NodeJS.ProcessEnv,
});
child.unref();
}

): Promise<void> {
// Validate the URL first
validateUrl(url);
validateUrl(url, browserOptions);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] shouldAttemptBrowserLaunch() is checked here BEFORE the BROWSER env var is evaluated (line 82). When a user explicitly sets BROWSER in a CI or headless environment (e.g., BROWSER=/usr/bin/chromium), shouldAttemptBrowserLaunch() returns false (because CI is set or no display is available), and the function exits early without ever reaching the BROWSER command logic.

An explicit BROWSER setting is a strong signal that the user intends browser launching to work. Consider evaluating BROWSER first; if it is set and not blocklisted, skip the shouldAttemptBrowserLaunch() gate so users can override headless detection.


default:
throw new Error(`Unsupported platform: ${platformName}`);
const browserEnv = process.env['BROWSER']?.trim();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] On Windows (win32), the BROWSER env var bypasses the PowerShell Start-Process path entirely. The BROWSER branch is evaluated before the platform switch, so the URL goes to execFile(parsedCommand, [...args, url]) directly — undoing the deliberate defense-in-depth decision to use Start-Process over cmd.exe to avoid shell injection (as noted in the comment on the win32 case below).

Consider either ignoring BROWSER on Windows (always use the PowerShell path) or restricting it to an allowlist of known browser binaries.

}
}

function parseBrowserCommand(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The regex /"[^"]*"|'[^']*'|[^\s]+/g only recognizes quotes at token boundaries. It cannot handle embedded quotes within compound tokens. For example, BROWSER='chromium --user-data-dir="/tmp/my profile"' splits into ["chromium", "--user-data-dir=\"/tmp/my", "profile\""] — two garbled arguments instead of one clean --user-data-dir=/tmp/my profile.

This is a realistic BROWSER configuration (Chrome/Chromium with a spaced user-data directory). Consider using a proper shell-like tokenizer that recognizes quote boundaries mid-token.


// Only allow HTTP and HTTPS protocols
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
const allowedProtocols = allowFile

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When allowFile is true, any file:// URL passes validation without path restriction — including file:///etc/shadow, file:///proc/self/environ, or file:///~/.ssh/id_rsa. The sole current caller (insightCommand) uses internally generated paths, so this is not exploitable today. However, this function is publicly exported from @qwen-code/qwen-code-core, and any future caller passing user-influenced paths with { allowFile: true } would create a local-file disclosure vector.

Consider adding an optional allowedBasePath parameter to constrain file:// URLs to a known output directory.

// For non-Linux OSes, we generally assume a GUI is available
// unless other signals (like SSH) suggest otherwise.
return true;
return shouldAttemptBrowserLaunch();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] shouldLaunchBrowser() is now a one-line wrapper delegating to shouldAttemptBrowserLaunch() and has zero callers anywhere in the codebase. It is still re-exported from index.ts via export *. Consider removing it entirely to reduce unnecessary public API surface.

* @throws Error if the URL is invalid or uses an unsafe protocol
*/
function validateUrl(url: string): void {
function validateUrl(url: string, { allowFile = false } = {}): void {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The JSDoc at line 16-17 states "Only allows HTTP and HTTPS URLs to prevent command injection" but the function now also permits file: when allowFile is true. The doc no longer matches the implementation — consider updating to reflect the allowFile parameter.

default:
throw new Error(`Unsupported platform: ${platformName}`);
const browserEnv = process.env['BROWSER']?.trim();
const browserBlocklist = ['www-browser'];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] browserBlocklist is duplicated between browser.ts (line 18, reads process.env['BROWSER'] raw) and this file (reads process.env['BROWSER']?.trim()). The inconsistent trimming means a whitespace-padded value like " www-browser " passes the blocklist in shouldAttemptBrowserLaunch() but matches it here, causing divergent behavior.

Consider removing the duplicate blocklist from openBrowserSecurely entirely (since shouldAttemptBrowserLaunch() already gates the launch) or extracting it into a shared constant with consistent .trim() in both files.

@he-yufeng

Copy link
Copy Markdown
Contributor Author

Updated in 91bed0424.

What changed:

  • Custom BROWSER launches now use spawn(...).unref() instead of promisified execFile, so a real browser process does not block the CLI until the browser exits.
  • Explicit BROWSER is evaluated before the headless/CI gate on non-Windows platforms, so users can intentionally override headless detection.
  • Windows keeps the existing PowerShell Start-Process path and ignores BROWSER, preserving the shell-injection defense on that platform.
  • BROWSER %s placeholders are substituted instead of appending a second URL argument.
  • The parser now handles embedded quoted argument values such as --user-data-dir="/tmp/my profile".
  • Invalid BROWSER values no longer throw through the non-throwing browser-open contract; they warn and fall back to the platform opener.
  • /docs now has the same defensive browser-open fallback shape as /extensions.

I intentionally left allowFile as an opt-in protocol switch in this patch because the current file URL caller builds its own local report path, and adding allowedBasePath would be a separate public API change for future callers.

Validation run locally:

npm test --workspace=packages/core -- src/utils/secure-browser-launcher.test.ts src/qwen/qwenOAuth2.test.ts
npm test --workspace=packages/cli -- src/ui/commands/docsCommand.test.ts
npx prettier --check packages/core/src/utils/secure-browser-launcher.ts packages/core/src/utils/browser.ts packages/core/src/utils/secure-browser-launcher.test.ts packages/cli/src/ui/commands/docsCommand.ts packages/cli/src/ui/commands/docsCommand.test.ts
npm run lint
npm run typecheck
npm run build
git diff --check

Notes:

  • The first npm run typecheck failed before npm run build because the clean checkout lacked generated packages/core/dist declarations for CLI references. After npm run build, the same npm run typecheck passed.
  • npm run build still reports the pre-existing VS Code companion curly-rule warnings; no errors.

@he-yufeng
he-yufeng force-pushed the fix/headless-slash-command-open branch from 91bed04 to 4c6734d Compare June 4, 2026 07:06
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Pushed 4c6734db6 with another browser-review follow-up.

Additional changes:

  • /insights no longer prints an optimistic "Opening insights" message before the browser launch attempt. It now always shows the generated file path and tells the user to open it manually if the browser does not open.
  • Explicit BROWSER launch failures are now handled inside openBrowserSecurely() the same way platform opener failures are handled: warn with the manual URL and resolve instead of throwing through the non-throwing browser-open contract.
  • Added coverage for quoted BROWSER command paths and for suppressing Linux fallback commands when an explicit BROWSER command fails.
  • Kept BROWSER=www-browser behavior aligned with the existing shared shouldAttemptBrowserLaunch() blocklist: it skips launch and prints the manual URL.

I still intentionally left allowedBasePath out of this patch. The current allowFile caller passes an internally generated report path, and adding path-scoped file URL validation would be a public API extension that is better handled separately.

Validation on Windows:

npm test --workspace=packages/core -- src/utils/secure-browser-launcher.test.ts src/qwen/qwenOAuth2.test.ts
# 108 passed

npm test --workspace=packages/cli -- src/ui/commands/insightCommand.test.ts src/ui/commands/docsCommand.test.ts
# 10 passed

npx prettier --check packages/core/src/utils/secure-browser-launcher.ts packages/core/src/utils/secure-browser-launcher.test.ts packages/cli/src/ui/commands/insightCommand.ts packages/cli/src/ui/commands/insightCommand.test.ts packages/cli/src/ui/commands/docsCommand.ts packages/cli/src/ui/commands/docsCommand.test.ts
# passed

npm run lint
npm run typecheck
npm run build
git diff --check
# passed

npm run build still reports the pre-existing VS Code companion curly-rule warnings and stale browserslist data, but exits successfully.

@he-yufeng
he-yufeng force-pushed the fix/headless-slash-command-open branch from 4c6734d to f7eb570 Compare June 4, 2026 07:16
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Addressed the dead dependency review note.

What changed:

  • removed the direct open dependency from packages/cli/package.json and packages/core/package.json
  • updated package-lock.json

Validation on head f7eb5707b:

  • npm run check:lockfile -> passed
  • npm test --workspace=packages/core -- src/utils/secure-browser-launcher.test.ts src/qwen/qwenOAuth2.test.ts -> 108 passed
  • npm test --workspace=packages/cli -- src/ui/commands/insightCommand.test.ts src/ui/commands/docsCommand.test.ts -> 10 passed
  • npm run lint -> passed
  • npm run typecheck -> passed
  • npm run build -> passed
  • git diff --check -> passed

The build still reports the existing VS Code companion curly warnings and stale browserslist notices, but they are warnings only and unrelated to this patch.

for (const fallbackCommand of fallbackCommands) {
try {
await execFileAsync(fallbackCommand, [url], options);
await execFileAsync(fallbackCommand, [url], execOptions);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The Linux platform-fallback path (lines 159–175) still uses execFileAsync for browser binaries (firefox, chromium, google-chrome, microsoft-edge). execFileAsync with detached: true blocks until the child process exits — the detached option only calls setsid(), it does NOT make the promise resolve early. This is the same deadlock class as the R2 Critical fix that was correctly applied to the BROWSER env var path via launchDetached.

When xdg-open is missing (common in containers, minimal servers, WSL) and a fallback binary is a real browser, openBrowserSecurely won't return until the user closes the browser. This blocks the calling slash command (/bug, /docs, /insight, /extensions) and the OAuth device-flow polling loop in qwenOAuth2.ts.

Suggested change
await execFileAsync(fallbackCommand, [url], execOptions);
await launchDetached(fallbackCommand, [url]);

— qwen3.7-max via Qwen Code /review

await launchDetached(browserCommand.command, browserCommand.args);
} catch (_error) {
/* eslint-disable no-console */
console.warn(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When launchDetached rejects (binary not found, permissions error, misparsed path), this catch block prints a warning and the return on the next line exits immediately — never falling through to the platform default (open on macOS, xdg-open on Linux).

A stale or slightly-off BROWSER env var completely disables browser launching. The most common real-world case: a macOS user with BROWSER="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" — the shell evaluates the outer quotes, leaving a bare string with spaces that parseBrowserCommand splits incorrectly.

Consider falling through to the platform-default path instead:

  if (browserCommand) {
    try {
      await launchDetached(browserCommand.command, browserCommand.args);
      return;
    } catch (_error) {
      // Fall through to platform default below
    }
  }

— qwen3.7-max via Qwen Code /review

if (browserCommand) {
try {
await launchDetached(browserCommand.command, browserCommand.args);
} catch (_error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The caught error is discarded (_error). The warning doesn't mention the BROWSER env var, the binary name that was attempted, or the underlying OS error. An operator with a misconfigured BROWSER has no way to diagnose the failure from this message alone.

Suggested change
} catch (_error) {
console.warn(
`Failed to open browser (BROWSER=${browserCommand.command}): ${_error instanceof Error ? _error.message : String(_error)}. Please open this URL manually: ${url}`,
);

— qwen3.7-max via Qwen Code /review

: undefined,
});
mockOpenBrowserSecurely.mockClear();
mockOpenBrowserSecurely.mockResolvedValue(undefined);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] docsCommand and extensionsCommand both have tests for when openBrowserSecurely throws (e.g., mockOpenBrowserSecurely.mockRejectedValue(...)), but bugCommand only tests the success path. The catch block in bugCommand.ts (lines 60–68) that adds an ERROR-type history item is dead code from a testing perspective.

Consider adding:

it('should show an error message when browser opening fails', async () => {
  mockOpenBrowserSecurely.mockRejectedValue(new Error('Browser not found'));
  // ... invoke bugCommand and assert ERROR item
});

— qwen3.7-max via Qwen Code /review

const browserEnv = process.env['BROWSER'];
if (browserEnv && browserBlocklist.includes(browserEnv)) {
const browserEnv = process.env['BROWSER']?.trim();
const browserCommand = browserEnv?.match(/^\S+/)?.[0];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] On Windows, BROWSER is intentionally excluded from the spawn path in secure-browser-launcher.ts (platformName === 'win32' ? undefined). But this function is still called as a fallback gate, and it reads BROWSER and checks the blocklist. If BROWSER=www-browser (common on Debian via update-alternatives), this returns false, preventing the PowerShell Start-Process opener from running.

The existing test only covers BROWSER='firefox --new-tab' (non-blocklisted), masking this gap. On Windows machines sharing dotfiles with a Linux environment that sets BROWSER=www-browser, the PowerShell-based browser launch silently does not run.

— qwen3.7-max via Qwen Code /review

import { shouldAttemptBrowserLaunch } from './browser.js';

const execFileAsync = promisify(execFile);
const browserBlocklist = ['www-browser'];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] browserBlocklist.includes(browserCommand.command) uses exact string matching. BROWSER=/usr/bin/www-browser (standard path on Debian via update-alternatives) bypasses this check because /usr/bin/www-browser !== 'www-browser'. Both browser.ts and this file have the same issue — the blocklist is ineffective for absolute-path BROWSER values.

Consider normalizing via path.basename():

import { basename } from 'node:path';
if (browserBlocklist.includes(basename(browserCommand.command))) {
  return undefined;
}

— qwen3.7-max via Qwen Code /review

@he-yufeng
he-yufeng force-pushed the fix/headless-slash-command-open branch from f7eb570 to 9b73b83 Compare June 4, 2026 19:45
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Pushed 9b73b833f with a follow-up for the latest browser-launch review.

What changed:

  • Linux fallback browser binaries (firefox, chromium, google-chrome, microsoft-edge) now use the same spawn/unref detached path as explicit BROWSER commands, so real browser processes do not block slash commands or OAuth polling.
  • A failed explicit BROWSER launch now warns with the attempted command and underlying error, then falls through to the platform opener instead of returning early.
  • www-browser is blocklisted by command basename, including absolute paths such as /usr/bin/www-browser.
  • Windows ignores BROWSER for launching and also ignores the Linux www-browser blocklist while using the existing PowerShell Start-Process opener.
  • file:// support for generated insight reports is now constrained to the exact generated output path via allowedFilePaths.
  • Added coverage for /bug browser-open rejection handling and the Windows blocklisted-BROWSER case.

Validation run locally:

npm run test --workspace=packages/core -- secure-browser-launcher.test.ts
# 29 passed

npm run test --workspace=packages/cli -- bugCommand.test.ts insightCommand.test.ts
# 9 passed

npx prettier --check packages/core/src/utils/browser.ts packages/core/src/utils/secure-browser-launcher.ts packages/core/src/utils/secure-browser-launcher.test.ts packages/cli/src/ui/commands/insightCommand.ts packages/cli/src/ui/commands/insightCommand.test.ts packages/cli/src/ui/commands/bugCommand.test.ts
# passed

npx eslint packages/core/src/utils/browser.ts packages/core/src/utils/secure-browser-launcher.ts packages/core/src/utils/secure-browser-launcher.test.ts packages/cli/src/ui/commands/insightCommand.ts packages/cli/src/ui/commands/insightCommand.test.ts packages/cli/src/ui/commands/bugCommand.test.ts --max-warnings=0
# passed

npm run typecheck --workspace=packages/core
# passed

git diff --check
# passed

I also tried the broader CLI workspace typecheck locally after the cache cleanup. It is currently blocked by unrelated workspace build/export issues outside this browser-launch patch (serve/*, channel package declarations, acp-bridge, and web-templates imports), so I kept this follow-up scoped to the reviewed browser-launch files.

);
}

if (parsedUrl.protocol === 'file:' && allowFile && allowedFilePaths?.length) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When allowFile is true but allowedFilePaths is undefined or empty, the condition allowedFilePaths?.length is falsy, so the entire path-validation block is skipped — any file:// URL passes. While the current sole caller (insightCommand) always provides allowedFilePaths, the API is a footgun for future callers.

Consider throwing when allowFile is true but allowedFilePaths is missing or empty:

Suggested change
if (parsedUrl.protocol === 'file:' && allowFile && allowedFilePaths?.length) {
if (parsedUrl.protocol === 'file:' && allowFile) {
if (!allowedFilePaths?.length) {
throw new Error('allowedFilePaths is required when allowFile is true');
}
const requestedPath = resolve(fileURLToPath(parsedUrl));
const allowed = allowedFilePaths.map((filePath) => resolve(filePath));
if (!allowed.includes(requestedPath)) {
throw new Error('File URL is not in the allowed file set');
}
}

— qwen3.7-max via Qwen Code /review

);
});

it('should restrict file URLs to the caller allow-list when provided', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The allowedFilePaths feature only has a rejection test here (path NOT in allow-list). The success path — where a file:// URL matches an allowedFilePaths entry and proceeds to launch — is not exercised in this test file. A regression that accidentally rejects valid allowed-file URLs would go undetected.

Consider adding a positive test:

it('should allow file URLs that match the allowedFilePaths list', async () => {
  setPlatform('linux');
  vi.stubEnv('DISPLAY', ':1');
  const filePath = resolve('report.html');

  await openBrowserSecurely(pathToFileURL(filePath).href, {
    allowFile: true,
    allowedFilePaths: [filePath],
  });

  expect(mockExecFile).toHaveBeenCalledWith(
    'xdg-open',
    [pathToFileURL(filePath).href],
    expect.any(Object),
  );
});

— qwen3.7-max via Qwen Code /review

expect(mockSpawn).toHaveBeenCalledWith(
'firefox',
['--new-tab', 'https://example.com'],
expect.any(Object),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Every mockSpawn assertion uses expect.any(Object) for the third argument (spawn options). The security-critical properties of launchDetacheddetached: true, stdio: 'ignore', and env.SHELL: undefined — are never explicitly verified. If a refactor accidentally removes detached: true or changes stdio, no test would catch it.

At least one test should assert the spawn options explicitly:

expect(mockSpawn).toHaveBeenCalledWith(
  'firefox',
  ['--new-tab', 'https://example.com'],
  expect.objectContaining({
    detached: true,
    stdio: 'ignore',
  }),
);

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Local Verification Report

Branch: fix/headless-slash-command-open
Base: main @ 641a1a739
Environment: macOS Darwin 25.4.0, Node.js


TypeScript Compilation

Package PR Branch Base Branch Verdict
core (--noEmit) 3 errors 3 errors No regression — all 3 are pre-existing @opentelemetry/instrumentation-undici module resolution errors
cli (--noEmit) 72 errors 14 errors +4 PR-related (openBrowserSecurely not found in pre-built core declarations — local build artifact, CI passes)

The 4 openBrowserSecurely errors appear because the local core dist/ doesn't include the new export. The export is correctly added in packages/core/src/index.ts. CI (macOS/Ubuntu/Windows) all pass TSC cleanly.


Test Results

Test File Package Result
secure-browser-launcher.test.ts core 29/29 passed
qwenOAuth2.test.ts core 83/83 passed
bugCommand.test.ts cli Failed (pre-existing)
docsCommand.test.ts cli Failed (pre-existing)
insightCommand.test.ts cli Failed (pre-existing)
extensionsCommand.test.ts cli Failed (pre-existing)

All 4 CLI test failures are pre-existing on main — caused by @opentelemetry/instrumentation-undici module resolution failure in vitest, confirmed by running bugCommand.test.ts on main with identical error. Not introduced by this PR.


CI Status

All CI checks passed:

  • Test (macOS): pass (15m57s)
  • Test (Ubuntu): pass (13m12s)
  • Test (Windows): pass (22m12s)
  • Lint: pass
  • CodeQL: pass

Code Review Summary

What this PR does:

  • Replaces the open npm package with a custom openBrowserSecurely() implementation using spawn with detached + unref to prevent child-process crashes from propagating
  • Adds BROWSER env var support via buildBrowserCommand() / parseBrowserCommand() with proper quoted-argument handling
  • Adds file:// URL support gated by explicit allowFile opt-in and allowedFilePaths allowlist (used by /insight)
  • Extracts isBrowserCommandBlocked() and browserBlocklist to browser.ts
  • Adds headless Linux detection via shouldAttemptBrowserLaunch() (checks DISPLAY, WAYLAND_DISPLAY, MIR_SOCKET)
  • Removes open from runtime dependencies in both core and cli packages

Key observations:

  1. Security: URL validation is solid — protocol allowlist, control character rejection, file:// path allowlist. parseBrowserCommand properly handles unclosed quotes (returns undefined). %s placeholder substitution is safe.
  2. Graceful degradation: launchDetached() failure falls back to platform opener; platform opener failure logs URL for manual access instead of crashing.
  3. Platform coverage: macOS (open), Windows (powershell.exe Start-Process), Linux/BSD (xdg-opengnome-openkde-open → browser fallbacks)
  4. Test coverage: 29 new tests covering file:// URLs, BROWSER env parsing, quoted arguments, blocklist, headless skip, Windows BROWSER ignore, spawn failure fallback

Verdict: Ready to merge — All PR-related tests pass, TSC regressions are zero (local build artifact only), CI all green. Clean security-conscious refactor removing an external dependency.

);
}

if (parsedUrl.protocol === 'file:' && allowFile && allowedFilePaths?.length) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] allowedFilePaths?.length evaluates to 0 (falsy) when allowedFilePaths is [], which silently skips the entire check — equivalent to passing no restriction. Semantically, an empty array should mean "no files allowed."

Suggested change
if (parsedUrl.protocol === 'file:' && allowFile && allowedFilePaths?.length) {
if (parsedUrl.protocol === 'file:' && allowFile && allowedFilePaths !== undefined) {

— qwen3.7-max via Qwen Code /review


for (const fallbackCommand of fallbackCommands) {
for (const { command: fallbackCommand, detached } of fallbackCommands) {
try {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When BROWSER=firefox fails (ENOENT), the code falls through to xdg-opengnome-openkde-openfirefox again (line 182). The retry of the same binary is guaranteed to fail with the same error. Consider tracking which command was already tried:

Suggested change
try {
const alreadyTried = browserCommand?.command
.replace(/\\/g, '/').split('/').pop();
for (const { command: fallbackCommand, detached } of fallbackCommands) {
if (fallbackCommand === alreadyTried) continue;

— qwen3.7-max via Qwen Code /review

@@ -116,17 +178,21 @@ export async function openBrowserSecurely(url: string): Promise<void> {
command === 'xdg-open'
) {
const fallbackCommands = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The detached: false vs detached: true split is intentional (desktop openers exit quickly; real browsers are long-running) but undocumented. A future maintainer might "fix" the inconsistency by setting all entries to detached: true, re-introducing the deadlock this PR fixed. A one-liner comment prevents this:

Suggested change
const fallbackCommands = [
// gnome-open/kde-open are short-lived dispatchers (exit after
// handing off to the desktop handler) — execFileAsync is safe.
// Real browsers are long-running and must use launchDetached.
const fallbackCommands = [

— qwen3.7-max via Qwen Code /review

const browserEnv = process.env['BROWSER'];
if (browserEnv && browserBlocklist.includes(browserEnv)) {
const browserEnv = process.env['BROWSER']?.trim();
const browserCommand = browserEnv?.match(/^\S+/)?.[0];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] shouldAttemptBrowserLaunch() parses BROWSER with /^\S+/ (preserves quotes), while buildBrowserCommand() uses the quote-aware parseBrowserCommand(). For BROWSER='"www-browser"', isBrowserCommandBlocked('"www-browser"') returns false — the blocklist check disagrees with buildBrowserCommand on command extraction. No security impact (platform opener opens a validated URL), but the inconsistency could bite if the blocklist grows. Consider reusing parseBrowserCommand or at minimum stripping surrounding quotes here.

— qwen3.7-max via Qwen Code /review

@he-yufeng

Copy link
Copy Markdown
Contributor Author

Thanks for the verification report. The earlier requested dead open dependency removal was addressed in f7eb5707b, and the latest head keeps CI green across macOS/Ubuntu/Windows.

The local CLI typecheck delta in the report is from stale pre-built core declarations; the source export is present and CI typecheck passes after the workspace build path. This is ready for maintainer re-review from my side.

return arg;
}
usedPlaceholder = true;
return arg.replace(/%s/g, url);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] String.prototype.replace interprets special $-prefixed patterns in the replacement string: $& (matched substring), $' (text after match), $$ (literal $). When url is passed directly as the second argument, any URL containing these sequences is silently mangled.

Concrete example: OAuth callback URL https://auth.example.com/callback?code=$&state=abc becomes https://auth.example.com/callback?code=%s&state=abc (the $& is replaced with the matched text %s, re-introducing the placeholder literally).

Note: the codebase already has a safeLiteralReplace utility in packages/core/src/utils/textUtils.ts that handles exactly this pitfall.

Suggested change
return arg.replace(/%s/g, url);
return arg.replace(/%s/g, () => url);

— qwen3.7-max via Qwen Code /review


default:
throw new Error(`Unsupported platform: ${platformName}`);
if (!shouldAttemptBrowserLaunch()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When BROWSER=www-browser (a blocklisted value), buildBrowserCommand() already returns undefined (line 96), so the BROWSER branch is correctly skipped. However, shouldAttemptBrowserLaunch() here ALSO checks the blocklist internally (in browser.ts:24-30) and returns false — which prevents the platform-opener fallback below (open on macOS, xdg-open on Linux with a display).

On macOS with BROWSER=www-browser, the system open command works perfectly (it ignores BROWSER entirely), but the user sees "Browser launch is not available in this environment" instead.

Consider splitting the blocklist concern: shouldAttemptBrowserLaunch() should only gate on environment availability (display server, CI, SSH), while buildBrowserCommand() handles BROWSER-specific filtering. The blocklist check in shouldAttemptBrowserLaunch() is needed for the OAuth flow (qwenOAuth2.ts) but should not short-circuit the platform-opener fallback here.

— qwen3.7-max via Qwen Code /review

@he-yufeng
he-yufeng force-pushed the fix/headless-slash-command-open branch from 9b73b83 to d148e8a Compare June 6, 2026 20:25
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Updated this branch to address the browser-launch review points.

Changes made:

  • require an explicit allowedFilePaths allow-list for file:// URLs when allowFile is enabled
  • substitute BROWSER %s placeholders via a literal replacement callback, so URL strings containing $& / $' / $$ are not mangled
  • keep BROWSER=www-browser blocked for the env override path, but let openBrowserSecurely continue to the normal platform opener when the environment itself can launch a browser
  • keep OAuth/config shouldAttemptBrowserLaunch() behavior unchanged by default; the blocklist bypass is only used inside openBrowserSecurely for the platform-opener fallback
  • added regression coverage for the file allow-list, literal placeholder substitution, blocklisted basename fallback, headless skip, and detached spawn options

Validation:

  • npm run test --workspace=packages/core -- secure-browser-launcher.test.ts (31 passed)
  • npm run typecheck --workspace=packages/core
  • npx eslint packages/core/src/utils/browser.ts packages/core/src/utils/secure-browser-launcher.ts packages/core/src/utils/secure-browser-launcher.test.ts
  • git diff --check

Note: npm ci installed dependencies but the repository prepare/build step still fails in packages/cli with existing channel workspace type-resolution errors (@qwen-code/channel-* not found). The focused core validation above passes after dependency install.

@he-yufeng

Copy link
Copy Markdown
Contributor Author

Pushed 72b35efea to address the macOS CI failure after the previous browser-launch update.

The failing job was not a browser assertion failure; all core tests passed, then Vitest failed the macOS run because LspConnectionFactory produced an unhandled write EPIPE while the stdio server-close initialization test was running. The follow-up keeps that path within the existing connection-close behavior:

  • catch synchronous LSP writer failures and close the JSON-RPC connection
  • handle stdio stdin stream error events and close the connection
  • guard the disposer so a broken stdin close cannot become a second unhandled error

Validation on this branch:

  • npm run test --workspace=packages/core -- src/lsp/LspConnectionFactory.test.ts (2 passed)
  • npm run test --workspace=packages/core -- secure-browser-launcher.test.ts (31 passed)
  • npm run typecheck --workspace=packages/core
  • npx eslint packages/core/src/lsp/LspConnectionFactory.ts packages/core/src/lsp/LspConnectionFactory.test.ts packages/core/src/utils/browser.ts packages/core/src/utils/secure-browser-launcher.ts packages/core/src/utils/secure-browser-launcher.test.ts --max-warnings=0
  • npx prettier --check packages/core/src/lsp/LspConnectionFactory.ts packages/core/src/lsp/LspConnectionFactory.test.ts packages/core/src/utils/browser.ts packages/core/src/utils/secure-browser-launcher.ts packages/core/src/utils/secure-browser-launcher.test.ts
  • git diff --check

Note: an earlier local attempt ran two core Vitest commands in parallel and hit a Windows coverage directory race (coverage/.tmp ENOENT). Re-running the same focused tests sequentially passed.

() => processInstance.stdin?.end(),
);

processInstance.stdin.on('error', (err) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The new stdin.on('error', ...) handler has no test coverage. The existing LspConnectionFactory.test.ts only covers stderr/exit during initialize and UTF-8 chunking — neither exercises a stdin stream error. This is new production error-handling code; if the connection.end() call is ever removed or altered, LSP connections could hang with pending requests that never resolve or reject.

Consider adding a test that spawns a process whose stdin emits an error, then verifies the connection ends and pending requests reject:

it('ends the connection when stdin emits an error', async () => {
  const { connection } = await factory.createStdioConnection(
    process.execPath,
    ['-e', 'process.stdin.destroy(new Error("stdin broken"));'],
  );
  await expect(connection.initialize({})).rejects.toThrow();
});

— qwen3.7-max via Qwen Code /review

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

This PR has already received extensive review (30 inline comments across multiple rounds, 11 issue-level comments, and a detailed verification report from @wenshao). I performed a full diff review covering all 18 changed files and a deterministic analysis pass.

Deterministic analysis: 0 findings (tsc and eslint clean; 4 tools skipped for non-applicable languages).

Manual review: I examined every changed file in detail, including the core secure-browser-launcher.ts refactor (BROWSER env support, launchDetached with spawn/unref, quote-aware argument parser, allowedFilePaths allow-list for file:// URLs), the browser.ts blocklist extraction by basename, the LspConnectionFactory.ts resilience improvements, and all four command-file migrations from open to openBrowserSecurely.

Duplicate check: All issues I could identify are already covered by the existing inline comments. No new findings to add.

Assessment of current state at HEAD (72b35efea):

  • The direction is correct: replacing open with openBrowserSecurely across all slash commands and the OAuth flow eliminates the headless crash (issue #4712) and centralizes browser-launch security.
  • The launchDetached helper using spawn + unref() correctly avoids blocking the CLI for long-running browser processes.
  • The parseBrowserCommand quote-aware parser and %s placeholder substitution (with function replacement to avoid $ patterns) are solid improvements.
  • The allowedFilePaths allow-list for file:// URLs in insightCommand is the right approach for restricting file access.
  • The LspConnectionFactory changes (try/catch around disposer, writer, and stdin error handler) add necessary resilience against stream failures.
  • The open dependency removal from both package.json files is clean.
  • Test coverage is improved across all command files and the secure launcher.

The open suggestions from prior reviewers identify real issues that deserve attention (particularly the execFileAsync blocking in the platform opener path, the discrepancy between shouldAttemptBrowserLaunch and buildBrowserCommand BROWSER parsing, and the missing test scenarios). These are already well-documented in the existing comments.

— qwen-code via Qwen Code /review

wenshao
wenshao previously approved these changes Jun 8, 2026

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅ — qwen3.7-max via Qwen Code /review

DragonnZhang
DragonnZhang previously approved these changes Jun 10, 2026

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed all 18 changed files (+896/-202). ESLint clean on all changed source files. CI passes (12/12 checks). The migration from open to openBrowserSecurely is thorough and well-structured: BROWSER env var parsing with quote-aware parser, file:// URL gating behind explicit allow-list, non-blocking browser launches via spawn+unref, and LSP resilience improvements (try/catch on writer, disposer, stdin error handler). Test coverage is comprehensive across bugCommand, docsCommand, insightCommand, extensionsCommand, qwenOAuth2, and secure-browser-launcher. The 30 existing inline comments from prior reviewers already cover the remaining suggestions. No new issues found. — claude-sonnet-4-20250514 via Qwen Code /review

@wenshao

wenshao commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Real-Run Verification Report (tmux-driven interactive CLI)

Complementing the earlier compile/unit-test report, this round exercises the actual built CLI end-to-end. Every interactive scenario below was executed against a real qwen TUI session (built from this branch with npm run bundle, run as node dist/cli.js) inside tmux, with assertions made on the fake-browser argv log, screen capture, and process liveness.

Head: 72b35efea · Environment: macOS darwin arm64 25.5.0, Node v22.22.2, npm 10.9.7, tmux 3.6a


1. Build & unit tests

Step Result
npm ci
npm run bundle (full dist build)
core: secure-browser-launcher.test.ts + qwenOAuth2.test.ts ✅ 114/114 passed
cli: bugCommand + docsCommand + insightCommand + extensionsCommand tests ✅ 25/25 passed
npm run check:lockfile ✅ passed
Dead-dep check: no from 'open' imports remain in packages/*/src; open removed from both package.json files

2. Control experiment — old vs. new behavior with missing platform binary

Reproduces the exact #4712 mechanism on this machine (PATH stripped so the platform opener binary is missing — macOS equivalent of headless Linux without xdg-open):

Old path (open@10.2.0, satisfying main's ^10.1.2 dependency):

[old] calling open() — platform binary missing from PATH
[old] open() resolved WITHOUT throwing (try/catch saw nothing)
node:events:497
      throw er; // Unhandled 'error' event
Error: spawn open ENOENT
    at ChildProcess._handle.onexit (node:internal/child_process:285:19)
    at onErrorNT (node:internal/child_process:483:16)
→ process crashed, exit code 1

Identical stack to the issue report: the promise resolves, ENOENT arrives asynchronously on an unguarded ChildProcess — try/catch is provably ineffective.

New path (openBrowserSecurely, this branch):

[new] calling openBrowserSecurely() — platform binary missing from PATH
Failed to open browser automatically. Please open this URL manually: https://example.com
[new] resolved gracefully
[new] process still alive after 2s
→ exit code 0

3. Interactive scenarios (real TUI sessions)

# Scenario Setup Observed Verdict
A BROWSER honored, /docs BROWSER=<fake script> Script invoked with exactly one argv: the docs URL; CLI prompt returns
B /bug full URL integrity same Full GitHub issue URL (containing &, ?, %-escapes) delivered as a single argv, unsplit and unmangled; URL also rendered in the TUI before the launch attempt
C Quoting, %s, injection attempt BROWSER='<script> --flag "two words $(touch /tmp/pr4716-pwned)" --url=%s' ARGC=3: quoted arg stays one argv with literal $(touch …) (/tmp/pr4716-pwned never created — no shell anywhere); %s substituted; URL not appended a second time
D Non-blocking launch BROWSER=<script that sleeps 45 s> Two consecutive /docs both completed within ~2 s while the first browser process was still sleeping — spawn+unref detach confirmed
E Remote-session gate SSH_CONNECTION set, no BROWSER Browser launch is not available in this environment. Please open this URL manually: … — no crash, no launch attempt
F #4712 regression scenario GUI mode, no BROWSER, PATH stripped (platform open binary missing) Failed to open browser automatically. Please open this URL manually: … — CLI alive, prompt returns
G BROWSER ENOENT fallback chain BROWSER=/nonexistent/browser-xyz, PATH stripped Failed to open BROWSER command /nonexistent/browser-xyz: spawn … ENOENT. Falling back to the platform browser opener. → platform opener also missing → manual-open message; no crash
H Explicit BROWSER overrides CI gate CI=true + BROWSER=<fake script> Script still invoked with the URL — explicit user intent wins over the environment heuristic, as intended for container/SSH-forwarding workflows
I /insight file:// end-to-end BROWSER=<fake script> Report generated (50 sessions analyzed); script invoked with single argv file:///…/.qwen/insights/insight-2026-06-10.html (allow-list admits exactly the generated path); UI shows the path first with the review-requested wording ("If the browser does not open automatically, open this file manually")

4. Non-blocking observations

  • /docs still prints the optimistic Opening documentation in your browser: <url> info line even when the launch is subsequently gated or fails. The warning with the manual-open instruction does appear, and the URL is always visible, so this is cosmetic only — but mirroring the /insight wording change ("if the browser does not open automatically, open manually") would be more consistent. Fine as a follow-up.
  • Launcher warnings go through console.warn and render above the ink frame rather than as history items. Acceptable; just noting the visual difference.

5. Not covered by this real run

  • Real headless Linux xdg-open path — the mechanism is proven by the control experiment and unit tests; the macOS-equivalent missing-binary path was exercised for real (scenario F/G).
  • Qwen OAuth device-flow browser launch — not exercised live to avoid touching real credentials; covered by the 83 passing qwenOAuth2 unit tests.
  • LSP stdio EPIPE hardening (LspConnectionFactory) — not browser-related; relies on CI/unit coverage.

Verdict

The fix does what it claims: the async-ENOENT crash class is eliminated, the URL is always surfaced in the terminal, BROWSER is honored without any shell in the path (injection attempt passed through literally), launches don't block the CLI, and the file:// report path works under the allow-list. LGTM from the real-run side — no blocking findings.

@he-yufeng
he-yufeng dismissed stale reviews from DragonnZhang and wenshao via fb9dd77 June 11, 2026 21:59
@he-yufeng
he-yufeng force-pushed the fix/headless-slash-command-open branch from 72b35ef to fb9dd77 Compare June 11, 2026 21:59
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Rebased this branch onto current upstream/main and resolved the core export conflict by keeping both the newer sessionIdContext export and the secure browser launcher export. Focused validation after the rebase: core browser/OAuth tests passed (114 tests), core LSP connection test passed (2 tests), CLI bug/docs/insight/extensions command tests passed (25 tests), prettier check passed on touched files, eslint passed on touched files, core typecheck passed, and git diff --check passed. Local CLI workspace typecheck is still blocked by unrelated generated workspace artifacts around acp-bridge/serve/channel declarations; the focused CLI tests above pass on the touched command paths.

@he-yufeng
he-yufeng force-pushed the fix/headless-slash-command-open branch from fb9dd77 to de51360 Compare June 12, 2026 01:39
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Rebased this branch onto current upstream/main and resolved the OAuth import conflict by keeping both the newer combineAbortSignals import and this PR's secure browser launcher import.

Focused validation after rebase:

npx vitest run packages/core/src/qwen/qwenOAuth2.test.ts packages/core/src/utils/secure-browser-launcher.test.ts packages/cli/src/ui/commands/extensionsCommand.test.ts
# 3 files, 128 tests passed

npx prettier --check packages/core/src/qwen/qwenOAuth2.ts packages/core/src/qwen/qwenOAuth2.test.ts packages/core/src/utils/secure-browser-launcher.ts packages/core/src/utils/secure-browser-launcher.test.ts packages/cli/src/ui/commands/extensionsCommand.ts packages/cli/src/ui/commands/extensionsCommand.test.ts
npx eslint packages/core/src/qwen/qwenOAuth2.ts packages/core/src/qwen/qwenOAuth2.test.ts packages/core/src/utils/secure-browser-launcher.ts packages/core/src/utils/secure-browser-launcher.test.ts packages/cli/src/ui/commands/extensionsCommand.ts packages/cli/src/ui/commands/extensionsCommand.test.ts
npm run typecheck --workspace=@qwen-code/qwen-code-core
git diff --check upstream/main..HEAD

Current head: de5136062.

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at de51360. No high-confidence issues found.

Summary of changes reviewed:

  • Replaces all direct open imports with openBrowserSecurely() across /bug, /docs, /insight, and /extensions commands, and removes the open npm dependency from both packages/cli and packages/core.
  • Extends openBrowserSecurely to honor the BROWSER env var (without a shell) via a well-implemented tokenizer that handles quoted arguments and %s placeholder substitution.
  • Adds opt-in file:// URL support with an explicit allow-list for the insight HTML report — secure by default.
  • Consolidates headless-Linux detection into shouldAttemptBrowserLaunch() and reuses it in shouldLaunchBrowser().
  • Improves LSP connection resilience: wraps disposer and writer in try/catch, adds a stdin error listener.
  • Adds comprehensive tests for BROWSER parsing (quoted args, placeholder substitution, blocklist, headless fallback, Windows/macOS behavior) and error-path coverage for each command.

All changes are consistent, well-tested, and security-conscious (protocol allow-list, blocklist on basename, PowerShell single-quote escaping on Windows). CI has 1 passing check with 5 still pending.

@wenshao
wenshao merged commit adbdff8 into QwenLM:main Jun 12, 2026
23 checks passed
doudouOUC pushed a commit that referenced this pull request Jun 15, 2026
* fix(cli): avoid headless browser open crashes

* fix(cli): reuse browser launch guard

* test(core): isolate browser launcher env

* fix(core): make browser env launches non-blocking

* fix(core): handle LSP stdio write failures
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

/bug, /docs, /insight crash with spawn xdg-open ENOENT on headless Linux (follow-up to #1674)

6 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.