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

* 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
This commit is contained in:
Yufeng He 2026-06-12 13:58:35 +08:00 committed by GitHub
parent 1cd5b81aa3
commit adbdff8b1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 906 additions and 211 deletions

12
package-lock.json generated
View file

@ -7625,6 +7625,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
"integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"run-applescript": "^7.0.0"
@ -9390,6 +9391,7 @@
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz",
"integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
"bundle-name": "^4.1.0",
@ -9406,6 +9408,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz",
"integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@ -9436,6 +9439,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
"integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@ -13227,6 +13231,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
"integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
"dev": true,
"license": "MIT",
"bin": {
"is-docker": "cli.js"
@ -13334,6 +13339,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
"integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^3.0.0"
@ -13635,6 +13641,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz",
"integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-inside-container": "^1.0.0"
@ -16745,6 +16752,7 @@
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
"integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"default-browser": "^5.2.1",
@ -18805,6 +18813,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz",
"integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@ -22084,6 +22093,7 @@
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
"integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-wsl": "^3.1.0"
@ -22489,7 +22499,6 @@
"ink-link": "^4.1.0",
"ink-spinner": "^5.0.0",
"lowlight": "^3.3.0",
"open": "^10.1.2",
"p-limit": "^7.3.0",
"prompts": "^2.4.2",
"react": "^19.2.4",
@ -22732,7 +22741,6 @@
"marked": "^15.0.12",
"mime": "4.0.7",
"mnemonist": "^0.40.3",
"open": "^10.1.2",
"openai": "5.11.0",
"picomatch": "^4.0.1",
"prompts": "^2.4.2",

View file

@ -67,7 +67,6 @@
"ink-link": "^4.1.0",
"ink-spinner": "^5.0.0",
"lowlight": "^3.3.0",
"open": "^10.1.2",
"p-limit": "^7.3.0",
"prompts": "^2.4.2",
"react": "^19.2.4",

View file

@ -5,15 +5,23 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import open from 'open';
import { bugCommand } from './bugCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import * as systemInfoUtils from '../../utils/systemInfo.js';
const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn());
// Mock dependencies
vi.mock('open');
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
openBrowserSecurely: mockOpenBrowserSecurely,
};
});
vi.mock('../../utils/systemInfo.js');
describe('bugCommand', () => {
@ -36,6 +44,8 @@ describe('bugCommand', () => {
? GIT_COMMIT_INFO
: undefined,
});
mockOpenBrowserSecurely.mockClear();
mockOpenBrowserSecurely.mockResolvedValue(undefined);
vi.stubEnv('SANDBOX', 'qwen-test');
});
@ -84,7 +94,7 @@ Memory Usage: 100 MB`;
},
expect.any(Number),
);
expect(open).toHaveBeenCalledWith(expectedUrl);
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(expectedUrl);
});
it('should use a custom URL template from config if provided', async () => {
@ -128,7 +138,7 @@ Memory Usage: 100 MB`;
},
expect.any(Number),
);
expect(open).toHaveBeenCalledWith(expectedUrl);
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(expectedUrl);
});
it('should include Base URL when auth type is OpenAI', async () => {
@ -193,6 +203,28 @@ Memory Usage: 100 MB`;
},
expect.any(Number),
);
expect(open).toHaveBeenCalledWith(expectedUrl);
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(expectedUrl);
});
it('should report browser launch failures without failing the command', async () => {
mockOpenBrowserSecurely.mockRejectedValueOnce(new Error('browser failed'));
const mockContext = createMockCommandContext({
services: {
config: {
getBugCommand: () => undefined,
},
},
});
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(mockContext, 'Browser failure');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
text: 'Could not open URL in browser: browser failed',
}),
expect.any(Number),
);
});
});

View file

@ -4,12 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import open from 'open';
import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
import { openBrowserSecurely } from '@qwen-code/qwen-code-core';
import { MessageType, type HistoryItem } from '../types.js';
import { getExtendedSystemInfo } from '../../utils/systemInfo.js';
import { getSystemInfoFields } from '../../utils/systemInfoFields.js';
@ -55,7 +55,7 @@ export const bugCommand: SlashCommand = {
context.ui.addItem(bugReportItem, Date.now());
try {
await open(bugReportUrl);
await openBrowserSecurely(bugReportUrl);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);

View file

@ -5,24 +5,29 @@
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import open from 'open';
import { docsCommand } from './docsCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
// Mock the 'open' library
vi.mock('open', () => ({
default: vi.fn(),
}));
const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn());
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
openBrowserSecurely: mockOpenBrowserSecurely,
};
});
describe('docsCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
// Create a fresh mock context before each test
mockContext = createMockCommandContext();
// Reset the `open` mock
vi.mocked(open).mockClear();
mockOpenBrowserSecurely.mockClear();
mockOpenBrowserSecurely.mockResolvedValue(undefined);
});
afterEach(() => {
@ -47,7 +52,7 @@ describe('docsCommand', () => {
expect.any(Number),
);
expect(open).toHaveBeenCalledWith(docsUrl);
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(docsUrl);
});
it('should only add an info message in a sandbox environment', async () => {
@ -70,7 +75,7 @@ describe('docsCommand', () => {
);
// Ensure 'open' was not called in the sandbox
expect(open).not.toHaveBeenCalled();
expect(mockOpenBrowserSecurely).not.toHaveBeenCalled();
});
it("should not open browser for 'sandbox-exec'", async () => {
@ -93,8 +98,27 @@ describe('docsCommand', () => {
expect.any(Number),
);
// 'open' should be called in this specific sandbox case
expect(open).toHaveBeenCalledWith(docsUrl);
// Browser launch should be called in this specific sandbox case.
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(docsUrl);
});
it('should show the docs URL when browser opening throws unexpectedly', async () => {
if (!docsCommand.action) {
throw new Error('docsCommand must have an action.');
}
const docsUrl = 'https://qwenlm.github.io/qwen-code-docs/en';
mockOpenBrowserSecurely.mockRejectedValueOnce(new Error('bad url'));
await docsCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: `Failed to open browser. View documentation at ${docsUrl}`,
},
expect.any(Number),
);
});
describe('non-interactive mode', () => {
@ -112,7 +136,7 @@ describe('docsCommand', () => {
messageType: 'info',
content: expect.stringContaining('qwenlm.github.io'),
});
expect(open).not.toHaveBeenCalled();
expect(mockOpenBrowserSecurely).not.toHaveBeenCalled();
expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled();
});
});

View file

@ -4,13 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import open from 'open';
import process from 'node:process';
import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
import { openBrowserSecurely } from '@qwen-code/qwen-code-core';
import { MessageType } from '../types.js';
import { t, getCurrentLanguage } from '../../i18n/index.js';
@ -57,7 +57,19 @@ export const docsCommand: SlashCommand = {
},
Date.now(),
);
await open(docsUrl);
try {
await openBrowserSecurely(docsUrl);
} catch (_error) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Failed to open browser. View documentation at {{url}}', {
url: docsUrl,
}),
},
Date.now(),
);
}
}
return;
},

View file

@ -22,11 +22,14 @@ import {
parseInstallSource,
} from '@qwen-code/qwen-code-core';
const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn());
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
openBrowserSecurely: mockOpenBrowserSecurely,
parseInstallSource: vi.fn(),
};
});
@ -46,6 +49,7 @@ describe('extensionsCommand', () => {
beforeEach(() => {
vi.resetAllMocks();
mockOpenBrowserSecurely.mockResolvedValue(undefined);
mockExtensionManager = createMockExtensionManager();
mockGetExtensions.mockReturnValue([]);
mockGetLoadedExtensions.mockReturnValue([]);
@ -64,6 +68,47 @@ describe('extensionsCommand', () => {
});
});
describe('explore', () => {
const exploreAction = extensionsCommand.subCommands?.find(
(cmd) => cmd.name === 'explore',
)?.action;
if (!exploreAction) {
throw new Error('Explore action not found');
}
it('should open the selected extensions gallery through the shared browser helper', async () => {
vi.stubEnv('NODE_ENV', 'development');
await exploreAction(mockContext, 'Gemini');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Opening extensions page in your browser: https://geminicli.com/extensions/',
},
expect.any(Number),
);
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(
'https://geminicli.com/extensions/',
);
});
it('should show the URL when browser launch fails', async () => {
vi.stubEnv('NODE_ENV', 'development');
mockOpenBrowserSecurely.mockRejectedValue(new Error('no browser'));
await exploreAction(mockContext, 'ClaudeCode');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Failed to open browser. Check out the extensions gallery at https://claudemarketplaces.com/',
},
expect.any(Number),
);
});
});
describe('default action (manage)', () => {
it('should open extensions manager dialog when extensions exist', async () => {
if (!extensionsCommand.action) throw new Error('Action not defined');

View file

@ -14,11 +14,11 @@ import {
import { t } from '../../i18n/index.js';
import {
ExtensionManager,
openBrowserSecurely,
parseInstallSource,
createDebugLogger,
redactUrlCredentials,
} from '@qwen-code/qwen-code-core';
import open from 'open';
const debugLogger = createDebugLogger('EXTENSIONS_COMMAND');
const EXTENSION_EXPLORE_URL = {
@ -85,7 +85,7 @@ async function exploreAction(context: CommandContext, args: string) {
Date.now(),
);
try {
await open(extensionsUrl);
await openBrowserSecurely(extensionsUrl);
} catch (_error) {
context.ui.addItem(
{
@ -144,10 +144,11 @@ async function listTextAction(context: CommandContext, _args: string) {
}
const active = extensions.filter((e) => e.isActive);
let output = t('**Installed Extensions ({{total}} total, {{active}} active)**', {
total: String(extensions.length),
active: String(active.length),
}) + '\n\n';
let output =
t('**Installed Extensions ({{total}} total, {{active}} active)**', {
total: String(extensions.length),
active: String(active.length),
}) + '\n\n';
for (const ext of extensions) {
const status = ext.isActive ? '✓' : '✗';
@ -155,9 +156,7 @@ async function listTextAction(context: CommandContext, _args: string) {
? ` (${redactUrlCredentials(ext.installMetadata.source)})`
: '';
const caps: string[] = [];
const mcpCount = ext.mcpServers
? Object.keys(ext.mcpServers).length
: 0;
const mcpCount = ext.mcpServers ? Object.keys(ext.mcpServers).length : 0;
if (mcpCount > 0) {
caps.push(t('{{count}} MCP servers', { count: String(mcpCount) }));
}
@ -165,7 +164,9 @@ async function listTextAction(context: CommandContext, _args: string) {
caps.push(t('{{count}} skills', { count: String(ext.skills.length) }));
}
if (ext.commands && ext.commands.length > 0) {
caps.push(t('{{count}} commands', { count: String(ext.commands.length) }));
caps.push(
t('{{count}} commands', { count: String(ext.commands.length) }),
);
}
const capsStr = caps.length > 0 ? ` [${caps.join(', ')}]` : '';
output += `- [${status}] **${ext.name}** v${ext.version}${source}${capsStr}\n`;

View file

@ -6,13 +6,23 @@
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import path from 'path';
import open from 'open';
import { pathToFileURL } from 'node:url';
import { parseInsightMessage, Storage } from '@qwen-code/qwen-code-core';
import { insightCommand } from './insightCommand.js';
import type { CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
const mockGenerateStaticInsight = vi.fn();
const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn());
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
openBrowserSecurely: mockOpenBrowserSecurely,
};
});
vi.mock('../../services/insight/generators/StaticInsightGenerator.js', () => ({
StaticInsightGenerator: vi.fn(() => ({
@ -20,10 +30,6 @@ vi.mock('../../services/insight/generators/StaticInsightGenerator.js', () => ({
})),
}));
vi.mock('open', () => ({
default: vi.fn(),
}));
describe('insightCommand', () => {
let mockContext: CommandContext;
@ -32,7 +38,8 @@ describe('insightCommand', () => {
mockGenerateStaticInsight.mockResolvedValue(
path.resolve('runtime-output', 'insights', 'insight-2026-03-05.html'),
);
vi.mocked(open).mockResolvedValue(undefined as never);
mockOpenBrowserSecurely.mockClear();
mockOpenBrowserSecurely.mockResolvedValue(undefined);
mockContext = createMockCommandContext({
services: {
@ -62,6 +69,41 @@ describe('insightCommand', () => {
path.join(Storage.getRuntimeBaseDir(), 'projects'),
expect.any(Function),
);
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(
pathToFileURL(
path.resolve('runtime-output', 'insights', 'insight-2026-03-05.html'),
).href,
{
allowFile: true,
allowedFilePaths: [
path.resolve('runtime-output', 'insights', 'insight-2026-03-05.html'),
],
},
);
});
it('shows the generated file path before attempting to open the browser', async () => {
if (!insightCommand.action) {
throw new Error('insight command must have action');
}
await insightCommand.action(mockContext, '');
const messages = vi
.mocked(mockContext.ui.addItem)
.mock.calls.map(([item]) =>
'text' in item && typeof item.text === 'string' ? item.text : '',
);
expect(messages).toContain(
`Insights generated at: ${path.resolve(
'runtime-output',
'insights',
'insight-2026-03-05.html',
)}. If the browser does not open automatically, open this file manually.`,
);
expect(
messages.some((message) => message.includes('Opening insights')),
).toBe(false);
});
it('streams ACP progress messages without waiting for generation to finish', async () => {
@ -198,7 +240,7 @@ describe('insightCommand', () => {
expect((result as { content: string }).content).toContain(
path.resolve('runtime-output', 'insights', 'insight-2026-03-05.html'),
);
expect(open).not.toHaveBeenCalled();
expect(mockOpenBrowserSecurely).not.toHaveBeenCalled();
});
it('non_interactive: returns error message when generation fails', async () => {
@ -227,6 +269,6 @@ describe('insightCommand', () => {
messageType: 'error',
});
expect((result as { content: string }).content).toContain('disk full');
expect(open).not.toHaveBeenCalled();
expect(mockOpenBrowserSecurely).not.toHaveBeenCalled();
});
});

View file

@ -10,15 +10,16 @@ import { MessageType } from '../types.js';
import type { HistoryItemInsightProgress } from '../types.js';
import { t } from '../../i18n/index.js';
import { join } from 'path';
import { pathToFileURL } from 'node:url';
import { StaticInsightGenerator } from '../../services/insight/generators/StaticInsightGenerator.js';
import {
createDebugLogger,
encodeInsightErrorMessage,
encodeInsightProgressMessage,
encodeInsightReadyMessage,
openBrowserSecurely,
Storage,
} from '@qwen-code/qwen-code-core';
import open from 'open';
const logger = createDebugLogger('DataProcessor');
@ -221,33 +222,26 @@ export const insightCommand: SlashCommand = {
Date.now(),
);
try {
await open(outputPath);
context.ui.addItem(
{
type: MessageType.INFO,
text: t('Opening insights in your browser: {{path}}', {
context.ui.addItem(
{
type: MessageType.INFO,
text: t(
'Insights generated at: {{path}}. If the browser does not open automatically, open this file manually.',
{
path: outputPath,
}),
},
Date.now(),
);
},
),
},
Date.now(),
);
try {
await openBrowserSecurely(pathToFileURL(outputPath).href, {
allowFile: true,
allowedFilePaths: [outputPath],
});
} catch (browserError) {
logger.error('Failed to open browser automatically:', browserError);
context.ui.addItem(
{
type: MessageType.INFO,
text: t(
'Insights generated at: {{path}}. Please open this file in your browser.',
{
path: outputPath,
},
),
},
Date.now(),
);
}
context.ui.setDebugMessage(t('Insights ready.'));

View file

@ -61,7 +61,6 @@
"marked": "^15.0.12",
"mime": "4.0.7",
"mnemonist": "^0.40.3",
"open": "^10.1.2",
"openai": "5.11.0",
"picomatch": "^4.0.1",
"prompts": "^2.4.2",

View file

@ -374,6 +374,7 @@ export {
export * from './utils/runtimeStatus.js';
export * from './utils/schemaValidator.js';
export * from './utils/sessionIdContext.js';
export * from './utils/secure-browser-launcher.js';
export * from './utils/shell-utils.js';
export * from './utils/subagentGenerator.js';
export * from './utils/symlink.js';

View file

@ -89,7 +89,11 @@ class JsonRpcConnection {
}
this.disposed = true;
this.disposePending();
this.disposer?.();
try {
this.disposer?.();
} catch (error) {
debugLogger.warn('LSP disposer failed:', error);
}
}
private sendRequest(method: string, params: unknown): Promise<unknown> {
@ -231,7 +235,12 @@ class JsonRpcConnection {
}
const json = JSON.stringify(message);
const header = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n`;
this.writer(header + json);
try {
this.writer(header + json);
} catch (error) {
debugLogger.warn('LSP write failed:', error);
this.end();
}
}
private disposePending(error?: Error): void {
@ -318,6 +327,11 @@ export class LspConnectionFactory {
() => processInstance.stdin?.end(),
);
processInstance.stdin.on('error', (err) => {
debugLogger.warn(`LSP stdin stream error for ${command}:`, err);
connection.end();
});
connection.listen(processInstance.stdout);
const recordProcessExit = (
code: number | null,

View file

@ -6,7 +6,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'events';
import { type ChildProcess } from 'child_process';
import type { Config } from '../config/config.js';
import {
generateCodeChallenge,
@ -36,6 +35,8 @@ interface MockSharedTokenManager {
clearCache(): void;
}
const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn());
// Mock SharedTokenManager
vi.mock('./sharedTokenManager.js', () => ({
SharedTokenManager: class {
@ -91,9 +92,8 @@ vi.mock('./sharedTokenManager.js', () => ({
},
}));
// Mock open
vi.mock('open', () => ({
default: vi.fn(),
vi.mock('../utils/secure-browser-launcher.js', () => ({
openBrowserSecurely: mockOpenBrowserSecurely,
}));
// Mock process.stdout.write
@ -118,6 +118,11 @@ vi.mock('node:fs', () => ({
},
}));
beforeEach(() => {
mockOpenBrowserSecurely.mockReset();
mockOpenBrowserSecurely.mockResolvedValue(undefined);
});
describe('PKCE Code Generation', () => {
describe('generateCodeVerifier', () => {
it('should generate a code verifier with correct length', () => {
@ -1562,6 +1567,7 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
expect(client).toBeInstanceOf(Object);
expect(mockConfig.isBrowserLaunchSuppressed).toHaveBeenCalled();
expect(mockOpenBrowserSecurely).not.toHaveBeenCalled();
SharedTokenManager.getInstance = originalGetInstance;
});
@ -1592,9 +1598,15 @@ describe('Browser Launch and Error Handling', () => {
new Error('No cached credentials'),
);
// Mock open to throw error
const open = await import('open');
vi.mocked(open.default).mockRejectedValue(
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No credentials')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager);
mockOpenBrowserSecurely.mockRejectedValue(
new Error('Browser launch failed'),
);
@ -1629,27 +1641,26 @@ describe('Browser Launch and Error Handling', () => {
);
expect(client).toBeInstanceOf(Object);
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(
'https://chat.qwen.ai/device?code=TEST123',
);
SharedTokenManager.getInstance = originalGetInstance;
});
it('should handle browser child process error gracefully', async () => {
it('should launch the device flow URL through the shared browser helper', async () => {
const { promises: fs } = await import('node:fs');
vi.mocked(fs.readFile).mockRejectedValue(
new Error('No cached credentials'),
);
// Mock open to return a child process that will emit error
const open = await import('open');
const mockChildProcess = {
on: vi.fn((event: string, callback: (error: Error) => void) => {
if (event === 'error') {
// Call the error handler immediately for testing
setTimeout(() => callback(new Error('Process spawn failed')), 0);
}
}),
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No credentials')),
};
vi.mocked(open.default).mockResolvedValue(
mockChildProcess as unknown as ChildProcess,
);
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager);
const mockAuthResponse = {
ok: true,
@ -1682,6 +1693,11 @@ describe('Browser Launch and Error Handling', () => {
);
expect(client).toBeInstanceOf(Object);
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(
'https://chat.qwen.ai/device?code=TEST123',
);
SharedTokenManager.getInstance = originalGetInstance;
});
});

View file

@ -7,14 +7,13 @@
import crypto from 'crypto';
import path from 'node:path';
import { promises as fs } from 'node:fs';
import type { ChildProcess } from 'node:child_process';
import open from 'open';
import { EventEmitter } from 'events';
import type { Config } from '../config/config.js';
import { randomUUID } from 'node:crypto';
import { formatFetchErrorForUser } from '../utils/fetch.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { combineAbortSignals } from '../utils/abortController.js';
import { openBrowserSecurely } from '../utils/secure-browser-launcher.js';
import {
SharedTokenManager,
TokenManagerError,
@ -838,30 +837,9 @@ async function authWithQwenDeviceFlow(
// Helper to handle browser launch with error handling
const launchBrowser = async (url: string): Promise<void> => {
let childProcess: ChildProcess | undefined;
try {
// Call open and get the process
childProcess = await open(url);
// CRITICAL FIX: Attach error listener IMMEDIATELY if a process object exists.
// We do this outside the 'if' check scope to ensure it's bound as soon as possible.
if (childProcess && typeof childProcess.on === 'function') {
childProcess.on('error', (err: Error) => {
debugLogger.warn(`Browser launch process error: ${err.message}`);
debugLogger.info(`Please open this URL manually: ${url}`);
});
// Optional: Also listen for 'close' or 'exit' if needed for cleanup,
// but 'error' is the main crasher.
} else {
// Fallback: If open() didn't return a valid process object, log a warning
debugLogger.debug(
'open() did not return a valid child process object.',
);
}
await openBrowserSecurely(url);
} catch (err) {
// Handle synchronous errors or promise rejections from open()
const errorMessage = err instanceof Error ? err.message : String(err);
debugLogger.warn(`Failed to open browser automatically: ${errorMessage}`);
debugLogger.info(`Please open this URL manually: ${url}`);

View file

@ -4,6 +4,17 @@
* SPDX-License-Identifier: Apache-2.0
*/
const browserBlocklist = ['www-browser'];
export function isBrowserCommandBlocked(command: string): boolean {
const commandName = command.replace(/\\/g, '/').split('/').pop();
return !!commandName && browserBlocklist.includes(commandName);
}
type BrowserLaunchEnvironmentOptions = {
ignoreBrowserBlocklist?: boolean;
};
/**
* Determines if we should attempt to launch a browser for authentication
* based on the user's environment.
@ -11,12 +22,17 @@
* This is an adaptation of the logic from the Google Cloud SDK.
* @returns True if the tool should attempt to launch a browser.
*/
export function shouldAttemptBrowserLaunch(): boolean {
// A list of browser names that indicate we should not attempt to open a
// web browser for the user.
const browserBlocklist = ['www-browser'];
const browserEnv = process.env['BROWSER'];
if (browserEnv && browserBlocklist.includes(browserEnv)) {
export function shouldAttemptBrowserLaunch(
options: BrowserLaunchEnvironmentOptions = {},
): boolean {
const browserEnv = process.env['BROWSER']?.trim();
const browserCommand = browserEnv?.match(/^\S+/)?.[0];
if (
!options.ignoreBrowserBlocklist &&
process.platform !== 'win32' &&
browserCommand &&
isBrowserCommandBlocked(browserCommand)
) {
return false;
}
// Common environment variables used in CI/CD or other non-interactive shells.

View file

@ -5,13 +5,35 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { openBrowserSecurely } from './secure-browser-launcher.js';
// Create mock function using vi.hoisted
const mockExecFile = vi.hoisted(() => vi.fn());
const mockSpawn = vi.hoisted(() =>
vi.fn(() => {
const child: {
once: ReturnType<typeof vi.fn>;
unref: ReturnType<typeof vi.fn>;
} = {
once: vi.fn((event: string, callback: () => void) => {
if (event === 'spawn') {
callback();
}
return child;
}),
unref: vi.fn(),
};
return child;
}),
);
// Mock modules
vi.mock('node:child_process');
vi.mock('node:child_process', () => ({
execFile: vi.fn(),
spawn: mockSpawn,
}));
vi.mock('node:util', () => ({
promisify: () => mockExecFile,
}));
@ -22,13 +44,33 @@ describe('secure-browser-launcher', () => {
beforeEach(() => {
vi.clearAllMocks();
mockExecFile.mockResolvedValue({ stdout: '', stderr: '' });
mockSpawn.mockImplementation(() => {
const child: {
once: ReturnType<typeof vi.fn>;
unref: ReturnType<typeof vi.fn>;
} = {
once: vi.fn((event: string, callback: () => void) => {
if (event === 'spawn') {
callback();
}
return child;
}),
unref: vi.fn(),
};
return child;
});
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
vi.stubEnv('BROWSER', '');
vi.stubEnv('CI', '');
vi.stubEnv('DEBIAN_FRONTEND', '');
vi.stubEnv('SSH_CONNECTION', '');
});
afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
vi.unstubAllEnvs();
});
function setPlatform(platform: string) {
@ -71,6 +113,46 @@ describe('secure-browser-launcher', () => {
);
});
it('should allow file URLs only when explicitly requested with an allow-list', async () => {
setPlatform('linux');
vi.stubEnv('DISPLAY', ':1');
const filePath = resolve('report.html');
const fileUrl = pathToFileURL(filePath).href;
await expect(
openBrowserSecurely('file:///tmp/report.html'),
).rejects.toThrow('Unsafe protocol');
await expect(
openBrowserSecurely(fileUrl, {
allowFile: true,
}),
).rejects.toThrow('allowedFilePaths is required');
await openBrowserSecurely(fileUrl, {
allowFile: true,
allowedFilePaths: [filePath],
});
expect(mockExecFile).toHaveBeenCalledWith(
'xdg-open',
[fileUrl],
expect.any(Object),
);
});
it('should restrict file URLs to the caller allow-list when provided', async () => {
const allowedPath = resolve('report.html');
const otherPath = resolve('other.html');
await expect(
openBrowserSecurely(pathToFileURL(otherPath).href, {
allowFile: true,
allowedFilePaths: [allowedPath],
}),
).rejects.toThrow('allowed file set');
});
it('should reject invalid URLs', async () => {
await expect(openBrowserSecurely('not-a-url')).rejects.toThrow(
'Invalid URL',
@ -184,6 +266,7 @@ describe('secure-browser-launcher', () => {
it('should use xdg-open on Linux', async () => {
setPlatform('linux');
vi.stubEnv('DISPLAY', ':1');
await openBrowserSecurely('https://example.com');
expect(mockExecFile).toHaveBeenCalledWith(
'xdg-open',
@ -198,6 +281,265 @@ describe('secure-browser-launcher', () => {
'Unsupported platform',
);
});
it('should prefer BROWSER when it is configured', async () => {
setPlatform('linux');
vi.stubEnv('DISPLAY', ':1');
vi.stubEnv('BROWSER', 'firefox --new-tab');
await openBrowserSecurely('https://example.com');
expect(mockSpawn).toHaveBeenCalledWith(
'firefox',
['--new-tab', 'https://example.com'],
expect.objectContaining({
detached: true,
stdio: 'ignore',
}),
);
expect(mockExecFile).not.toHaveBeenCalled();
});
it('should substitute the BROWSER placeholder instead of appending the URL', async () => {
setPlatform('linux');
vi.stubEnv('DISPLAY', ':1');
vi.stubEnv('BROWSER', 'firefox --new-tab %s');
await openBrowserSecurely('https://example.com');
expect(mockSpawn).toHaveBeenCalledWith(
'firefox',
['--new-tab', 'https://example.com'],
expect.any(Object),
);
});
it('should substitute BROWSER placeholders literally', async () => {
setPlatform('linux');
vi.stubEnv('DISPLAY', ':1');
vi.stubEnv('BROWSER', 'firefox --new-tab %s');
const url = 'https://example.com/callback?code=$&state=abc';
await openBrowserSecurely(url);
expect(mockSpawn).toHaveBeenCalledWith(
'firefox',
['--new-tab', url],
expect.any(Object),
);
});
it('should parse quoted arguments inside BROWSER tokens', async () => {
setPlatform('linux');
vi.stubEnv('DISPLAY', ':1');
vi.stubEnv(
'BROWSER',
'chromium --user-data-dir="/tmp/my profile" --new-tab %s',
);
await openBrowserSecurely('https://example.com');
expect(mockSpawn).toHaveBeenCalledWith(
'chromium',
['--user-data-dir=/tmp/my profile', '--new-tab', 'https://example.com'],
expect.any(Object),
);
});
it('should parse quoted command paths in BROWSER', async () => {
setPlatform('linux');
vi.stubEnv('DISPLAY', ':1');
vi.stubEnv('BROWSER', '"/opt/my browser/chromium" --new-tab %s');
await openBrowserSecurely('https://example.com');
expect(mockSpawn).toHaveBeenCalledWith(
'/opt/my browser/chromium',
['--new-tab', 'https://example.com'],
expect.any(Object),
);
expect(mockExecFile).not.toHaveBeenCalled();
});
it('should let an explicit BROWSER override headless Linux detection', async () => {
setPlatform('linux');
vi.stubEnv('DISPLAY', '');
vi.stubEnv('WAYLAND_DISPLAY', '');
vi.stubEnv('MIR_SOCKET', '');
vi.stubEnv('BROWSER', 'firefox');
await openBrowserSecurely('https://example.com');
expect(mockSpawn).toHaveBeenCalledWith(
'firefox',
['https://example.com'],
expect.any(Object),
);
expect(mockExecFile).not.toHaveBeenCalled();
});
it('should fall back to the platform opener for blocklisted BROWSER values', async () => {
setPlatform('linux');
vi.stubEnv('DISPLAY', ':1');
vi.stubEnv('BROWSER', 'www-browser --headless');
await openBrowserSecurely('https://example.com');
expect(mockSpawn).not.toHaveBeenCalled();
expect(mockExecFile).toHaveBeenCalledWith(
'xdg-open',
['https://example.com'],
expect.any(Object),
);
});
it('should block BROWSER values by command basename before using the platform opener', async () => {
setPlatform('linux');
vi.stubEnv('DISPLAY', ':1');
vi.stubEnv('BROWSER', '/usr/bin/www-browser --headless');
await openBrowserSecurely('https://example.com');
expect(mockSpawn).not.toHaveBeenCalled();
expect(mockExecFile).toHaveBeenCalledWith(
'xdg-open',
['https://example.com'],
expect.any(Object),
);
});
it('should still skip blocklisted BROWSER values in headless Linux', async () => {
setPlatform('linux');
vi.stubEnv('DISPLAY', '');
vi.stubEnv('WAYLAND_DISPLAY', '');
vi.stubEnv('MIR_SOCKET', '');
vi.stubEnv('BROWSER', 'www-browser');
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await openBrowserSecurely('https://example.com');
expect(mockSpawn).not.toHaveBeenCalled();
expect(mockExecFile).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Please open this URL manually'),
);
consoleSpy.mockRestore();
});
it('should ignore BROWSER on Windows and keep the PowerShell opener', async () => {
setPlatform('win32');
vi.stubEnv('BROWSER', 'firefox --new-tab');
await openBrowserSecurely('https://example.com');
expect(mockSpawn).not.toHaveBeenCalled();
expect(mockExecFile).toHaveBeenCalledWith(
'powershell.exe',
expect.arrayContaining([
'-Command',
`Start-Process 'https://example.com'`,
]),
expect.any(Object),
);
});
it('should ignore blocklisted BROWSER values on Windows', async () => {
setPlatform('win32');
vi.stubEnv('BROWSER', 'www-browser');
await openBrowserSecurely('https://example.com');
expect(mockSpawn).not.toHaveBeenCalled();
expect(mockExecFile).toHaveBeenCalledWith(
'powershell.exe',
expect.arrayContaining([
'-Command',
`Start-Process 'https://example.com'`,
]),
expect.any(Object),
);
});
it('should fall back to the platform opener for invalid BROWSER values', async () => {
setPlatform('darwin');
vi.stubEnv('BROWSER', '"');
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await openBrowserSecurely('https://example.com');
expect(mockSpawn).not.toHaveBeenCalled();
expect(mockExecFile).toHaveBeenCalledWith(
'open',
['https://example.com'],
expect.any(Object),
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid BROWSER environment variable'),
);
consoleSpy.mockRestore();
});
it('should fall back to the platform opener when explicit BROWSER launch fails', async () => {
setPlatform('linux');
vi.stubEnv('DISPLAY', ':1');
vi.stubEnv('BROWSER', 'firefox --new-tab');
mockExecFile.mockResolvedValueOnce({ stdout: '', stderr: '' });
mockSpawn.mockImplementationOnce(() => {
const child: {
once: ReturnType<typeof vi.fn>;
unref: ReturnType<typeof vi.fn>;
} = {
once: vi.fn((event: string, callback: (error?: Error) => void) => {
if (event === 'error') {
callback(new Error('spawn failed'));
}
return child;
}),
unref: vi.fn(),
};
return child;
});
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await expect(
openBrowserSecurely('https://example.com'),
).resolves.toBeUndefined();
expect(mockSpawn).toHaveBeenCalledWith(
'firefox',
['--new-tab', 'https://example.com'],
expect.any(Object),
);
expect(mockExecFile).toHaveBeenCalledWith(
'xdg-open',
['https://example.com'],
expect.any(Object),
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to open BROWSER command firefox'),
);
consoleSpy.mockRestore();
});
it('should skip browser launch in headless Linux', async () => {
setPlatform('linux');
vi.stubEnv('DISPLAY', '');
vi.stubEnv('WAYLAND_DISPLAY', '');
vi.stubEnv('MIR_SOCKET', '');
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await openBrowserSecurely('https://example.com');
expect(mockExecFile).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Please open this URL manually'),
);
consoleSpy.mockRestore();
});
});
describe('Error handling', () => {
@ -222,6 +564,7 @@ describe('secure-browser-launcher', () => {
describe('Linux Fallback', () => {
it('should try fallback browsers on Linux', async () => {
setPlatform('linux');
vi.stubEnv('DISPLAY', ':1');
mockExecFile.mockRejectedValueOnce(new Error('Command not found'));
mockExecFile.mockResolvedValueOnce({ stdout: '', stderr: '' });
@ -242,5 +585,23 @@ describe('secure-browser-launcher', () => {
expect.any(Object),
);
});
it('should detach real browser fallback commands', async () => {
setPlatform('linux');
vi.stubEnv('DISPLAY', ':1');
mockExecFile
.mockRejectedValueOnce(new Error('xdg-open missing'))
.mockRejectedValueOnce(new Error('gnome-open missing'))
.mockRejectedValueOnce(new Error('kde-open missing'));
await openBrowserSecurely('https://example.com');
expect(mockSpawn).toHaveBeenCalledWith(
'firefox',
['https://example.com'],
expect.any(Object),
);
});
});
});

View file

@ -4,21 +4,35 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { execFile } from 'node:child_process';
import { execFile, spawn, type SpawnOptions } from 'node:child_process';
import { promisify } from 'node:util';
import { platform } from 'node:os';
import { URL } from 'node:url';
import { resolve } from 'node:path';
import { URL, fileURLToPath } from 'node:url';
import {
isBrowserCommandBlocked,
shouldAttemptBrowserLaunch,
} from './browser.js';
const execFileAsync = promisify(execFile);
type BrowserLaunchOptions = {
allowFile?: boolean;
allowedFilePaths?: string[];
};
/**
* Validates that a URL is safe to open in a browser.
* Only allows HTTP and HTTPS URLs to prevent command injection.
* Only allows HTTP and HTTPS URLs by default. file:// URLs are allowed only
* when a caller opts in for locally generated reports.
*
* @param url The URL to validate
* @throws Error if the URL is invalid or uses an unsafe protocol
*/
function validateUrl(url: string): void {
function validateUrl(
url: string,
{ allowFile = false, allowedFilePaths }: BrowserLaunchOptions = {},
): void {
let parsedUrl: URL;
try {
@ -27,13 +41,30 @@ function validateUrl(url: string): void {
throw new Error(`Invalid URL: ${url}`);
}
// Only allow HTTP and HTTPS protocols
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
const allowedProtocols = allowFile
? ['http:', 'https:', 'file:']
: ['http:', 'https:'];
// Only allow browser-safe protocols by default.
if (!allowedProtocols.includes(parsedUrl.protocol)) {
throw new Error(
`Unsafe protocol: ${parsedUrl.protocol}. Only HTTP and HTTPS are allowed.`,
`Unsafe protocol: ${parsedUrl.protocol}. Only ${allowedProtocols.join(
', ',
)} are allowed.`,
);
}
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');
}
}
// Additional validation: ensure no newlines or control characters
// eslint-disable-next-line no-control-regex
if (/[\r\n\x00-\x1f]/.test(url)) {
@ -49,51 +80,85 @@ function validateUrl(url: string): void {
* and resolves successfully to prevent application crashes.
*
* @param url - The URL to open.
* @param options.allowFile - Allow file:// URLs for locally generated reports.
* @returns A promise that resolves when the attempt is made (whether successful or logged).
*/
export async function openBrowserSecurely(url: string): Promise<void> {
export async function openBrowserSecurely(
url: string,
browserOptions: BrowserLaunchOptions = {},
): Promise<void> {
// Validate the URL first
validateUrl(url);
validateUrl(url, browserOptions);
const platformName = platform();
let command: string;
let args: string[];
switch (platformName) {
case 'darwin':
// macOS
command = 'open';
args = [url];
break;
const browserEnv = process.env['BROWSER']?.trim();
const browserCommand =
platformName === 'win32' ? undefined : buildBrowserCommand(browserEnv, url);
case 'win32':
// Windows - use PowerShell with Start-Process
// This avoids the cmd.exe shell which is vulnerable to injection
command = 'powershell.exe';
args = [
'-NoProfile',
'-NonInteractive',
'-WindowStyle',
'Hidden',
'-Command',
`Start-Process '${url.replace(/'/g, "''")}'`,
];
break;
case 'linux':
case 'freebsd':
case 'openbsd':
// Linux and BSD variants
// Try xdg-open first, fall back to other options
command = 'xdg-open';
args = [url];
break;
default:
throw new Error(`Unsupported platform: ${platformName}`);
if (browserCommand) {
try {
await launchDetached(browserCommand.command, browserCommand.args);
return;
} catch (_error) {
/* eslint-disable no-console */
console.warn(
`Failed to open BROWSER command ${browserCommand.command}: ${formatLaunchError(
_error,
)}. Falling back to the platform browser opener.`,
);
/* eslint-enable no-console */
}
}
const options: Record<string, unknown> = {
if (!shouldAttemptBrowserLaunch({ ignoreBrowserBlocklist: true })) {
/* eslint-disable no-console */
console.warn(
`Browser launch is not available in this environment. Please open this URL manually: ${url}`,
);
/* eslint-enable no-console */
return;
}
{
switch (platformName) {
case 'darwin':
// macOS
command = 'open';
args = [url];
break;
case 'win32':
// Windows - use PowerShell with Start-Process
// This avoids the cmd.exe shell which is vulnerable to injection
command = 'powershell.exe';
args = [
'-NoProfile',
'-NonInteractive',
'-WindowStyle',
'Hidden',
'-Command',
`Start-Process '${url.replace(/'/g, "''")}'`,
];
break;
case 'linux':
case 'freebsd':
case 'openbsd':
// Linux and BSD variants
// Try xdg-open first, fall back to other options
command = 'xdg-open';
args = [url];
break;
default:
throw new Error(`Unsupported platform: ${platformName}`);
}
}
const execOptions: Record<string, unknown> = {
// Don't inherit parent's environment to avoid potential issues
env: {
...process.env,
@ -106,7 +171,7 @@ export async function openBrowserSecurely(url: string): Promise<void> {
};
try {
await execFileAsync(command, args, options);
await execFileAsync(command, args, execOptions);
} catch (_error) {
// For Linux, try fallback commands if xdg-open fails
if (
@ -116,17 +181,21 @@ export async function openBrowserSecurely(url: string): Promise<void> {
command === 'xdg-open'
) {
const fallbackCommands = [
'gnome-open',
'kde-open',
'firefox',
'chromium',
'google-chrome',
'microsoft-edge',
{ command: 'gnome-open', detached: false },
{ command: 'kde-open', detached: false },
{ command: 'firefox', detached: true },
{ command: 'chromium', detached: true },
{ command: 'google-chrome', detached: true },
{ command: 'microsoft-edge', detached: true },
];
for (const fallbackCommand of fallbackCommands) {
for (const { command: fallbackCommand, detached } of fallbackCommands) {
try {
await execFileAsync(fallbackCommand, [url], options);
if (detached) {
await launchDetached(fallbackCommand, [url]);
} else {
await execFileAsync(fallbackCommand, [url], execOptions);
}
return; // Success!
} catch {
// Try next command
@ -145,6 +214,129 @@ export async function openBrowserSecurely(url: string): Promise<void> {
}
}
async function launchDetached(command: string, args: string[]): Promise<void> {
const spawnOptions: SpawnOptions = {
env: {
...process.env,
SHELL: undefined,
},
detached: true,
stdio: 'ignore',
};
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args, spawnOptions);
let settled = false;
child.once('error', (error) => {
if (settled) {
return;
}
settled = true;
reject(error);
});
child.once('spawn', () => {
if (settled) {
return;
}
settled = true;
child.unref();
resolve();
});
});
}
function formatLaunchError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function buildBrowserCommand(
browserEnv: string | undefined,
url: string,
): { command: string; args: string[] } | undefined {
if (!browserEnv) {
return undefined;
}
const browserCommand = parseBrowserCommand(browserEnv);
if (!browserCommand) {
/* eslint-disable no-console */
console.warn(
'Invalid BROWSER environment variable, falling back to platform default.',
);
/* eslint-enable no-console */
return undefined;
}
if (isBrowserCommandBlocked(browserCommand.command)) {
return undefined;
}
let usedPlaceholder = false;
const args = browserCommand.args.map((arg) => {
if (!arg.includes('%s')) {
return arg;
}
usedPlaceholder = true;
return arg.replace(/%s/g, () => url);
});
if (!usedPlaceholder) {
args.push(url);
}
return { command: browserCommand.command, args };
}
function parseBrowserCommand(
browserEnv: string,
): { command: string; args: string[] } | undefined {
const parts: string[] = [];
let current = '';
let quote: '"' | "'" | undefined;
for (const char of browserEnv) {
if (quote) {
if (char === quote) {
quote = undefined;
} else {
current += char;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (/\s/.test(char)) {
if (current) {
parts.push(current);
current = '';
}
continue;
}
current += char;
}
if (quote) {
return undefined;
}
if (current) {
parts.push(current);
}
const [command, ...args] = parts;
if (!command) {
return undefined;
}
return { command, args };
}
/**
* Checks if the current environment should attempt to launch a browser.
* This is the same logic as in browser.ts for consistency.
@ -152,44 +344,5 @@ export async function openBrowserSecurely(url: string): Promise<void> {
* @returns True if the tool should attempt to launch a browser
*/
export function shouldLaunchBrowser(): boolean {
// A list of browser names that indicate we should not attempt to open a
// web browser for the user.
const browserBlocklist = ['www-browser'];
const browserEnv = process.env['BROWSER'];
if (browserEnv && browserBlocklist.includes(browserEnv)) {
return false;
}
// Common environment variables used in CI/CD or other non-interactive shells.
if (
process.env['CI'] ||
process.env['DEBIAN_FRONTEND'] === 'noninteractive'
) {
return false;
}
// The presence of SSH_CONNECTION indicates a remote session.
// We should not attempt to launch a browser unless a display is explicitly available
// (checked below for Linux).
const isSSH = !!process.env['SSH_CONNECTION'];
// On Linux, the presence of a display server is a strong indicator of a GUI.
if (platform() === 'linux') {
// These are environment variables that can indicate a running compositor on Linux.
const displayVariables = ['DISPLAY', 'WAYLAND_DISPLAY', 'MIR_SOCKET'];
const hasDisplay = displayVariables.some((v) => !!process.env[v]);
if (!hasDisplay) {
return false;
}
}
// If in an SSH session on a non-Linux OS (e.g., macOS), don't launch browser.
// The Linux case is handled above (it's allowed if DISPLAY is set).
if (isSSH && platform() !== 'linux') {
return false;
}
// For non-Linux OSes, we generally assume a GUI is available
// unless other signals (like SSH) suggest otherwise.
return true;
return shouldAttemptBrowserLaunch();
}