Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@ either disable sandboxing for `chrome-devtools-mcp` in your MCP client or use
`--browser-url` to connect to a Chrome instance that you start manually outside
of the MCP client sandbox.

### Running as root

Chrome does not start as root
([crbug.com/638180](https://crbug.com/638180)). It exits immediately and
`chrome-devtools-mcp` reports that Chrome failed to start. This is a common
issue in containers and CI images that run everything as root.

Run `chrome-devtools-mcp` as a non-root user. In a container, create an
unprivileged user in the image and switch to it with `USER`; the build itself
can still run as root. For the host-side setup that Chrome's sandbox needs, see
Puppeteer's
[Setting up Chrome Linux sandbox](https://pptr.dev/troubleshooting#setting-up-chrome-linux-sandbox).

### WSL

By default, `chrome-devtools-mcp` in WSL requires Chrome to be installed within the Linux environment. While it normally attempts to launch Chrome on the Windows side, this currently fails due to a [known WSL issue](https://github.com/microsoft/WSL/issues/14201). Ensure you are using a [Linux distribution compatible with Chrome](https://support.google.com/chrome/a/answer/7100626).
Expand Down
43 changes: 43 additions & 0 deletions src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,45 @@ export function detectDisplay(): void {
}
}

/**
* Chrome refuses to start as root unless the sandbox is explicitly disabled and
* only says so on its stderr. Because we launch with `pipe: true`, Puppeteer
* never surfaces that stderr and the failure reaches the client as an opaque
* `Protocol error (Target.setDiscoverTargets): Target closed`. Detect the
* situation and explain the way out instead. See https://crbug.com/638180.
*
* Returns `undefined` when the failure cannot be explained by running as root,
* including on platforms without uids and when the sandbox was already disabled
* through `--chrome-arg` (in which case root is not what stopped Chrome).
*
* Exported for testing.
*/
export function rootSandboxLaunchError(
error: Error,
args: readonly string[],
uid = process.getuid?.(),
): Error | undefined {
if (uid !== 0) {
return undefined;
}
if (
args.some(arg => arg === '--no-sandbox' || arg.startsWith('--no-sandbox='))
) {
return undefined;
}
return new Error(
`Chrome failed to start: ${error.message}\n\n` +
'chrome-devtools-mcp is running as root and Chrome does not start as root ' +
'(https://crbug.com/638180). Run chrome-devtools-mcp as a non-root user; in a ' +
'container, create an unprivileged user in the image and switch to it with ' +
"USER. For the setup that Chrome's sandbox needs, see " +
'https://pptr.dev/troubleshooting#setting-up-chrome-linux-sandbox.',
{
cause: error,
},
);
}

export async function launch(options: McpLaunchOptions): Promise<Browser> {
const {channel, executablePath, headless, isolated} = options;
const profileDirName =
Expand Down Expand Up @@ -259,6 +298,10 @@ export async function launch(options: McpLaunchOptions): Promise<Browser> {
},
);
}
const rootError = rootSandboxLaunchError(error as Error, args);
if (rootError) {
throw rootError;
}
throw error;
}
}
Expand Down
51 changes: 51 additions & 0 deletions tests/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
ensureBrowserConnected,
launch,
makeTargetFilter,
rootSandboxLaunchError,
} from '../src/browser.js';
import type {Browser} from '../src/third_party/index.js';

Expand Down Expand Up @@ -61,6 +62,56 @@ describe('browser', () => {
detectDisplay();
});

describe('rootSandboxLaunchError', () => {
const targetClosed = new Error(
'Protocol error (Target.setDiscoverTargets): Target closed',
);

it('explains an opaque launch failure when running as root', () => {
const error = rootSandboxLaunchError(targetClosed, [], 0);
assert.ok(error);
assert.match(error.message, /non-root user/);
assert.match(error.message, /pptr\.dev\/troubleshooting/);
// The original failure stays visible so unrelated errors are not masked.
assert.match(error.message, /Target closed/);
assert.strictEqual(error.cause, targetClosed);
});

it('does not explain failures when not running as root', () => {
assert.strictEqual(
rootSandboxLaunchError(targetClosed, [], 1000),
undefined,
);
});

it('does not explain failures on platforms without uids', () => {
assert.strictEqual(
rootSandboxLaunchError(targetClosed, [], undefined),
undefined,
);
});

it('does not explain failures when the sandbox is already disabled', () => {
assert.strictEqual(
rootSandboxLaunchError(targetClosed, ['--no-sandbox'], 0),
undefined,
);
assert.strictEqual(
rootSandboxLaunchError(targetClosed, ['--no-sandbox=true'], 0),
undefined,
);
});

it('is not fooled by unrelated arguments that start the same', () => {
assert.ok(
rootSandboxLaunchError(targetClosed, ['--no-sandbox-and-elevated'], 0),
);
assert.ok(
rootSandboxLaunchError(targetClosed, ['--disable-setuid-sandbox'], 0),
);
});
});

it('cannot launch multiple times with the same profile', async () => {
await runWithRetry(async () => {
const tmpDir = os.tmpdir();
Expand Down