fix: explain launch failures caused by running as root (#2634)

Follow-up to #261. That issue was closed by #338, which added
`--chrome-arg` so the sandbox can be disabled explicitly. The flag
works, but the failure it fixes is still undiscoverable — you cannot
find out that `--chrome-arg=--no-sandbox` is the answer.

### Problem

`launch()` uses `pipe: true`. In pipe mode Puppeteer never calls
`waitForLineOutput()`, which is the only place Chrome's stderr makes it
into the thrown error. So when Chrome exits at startup with

```
Running as root without --no-sandbox is not supported. See https://crbug.com/638180.
```

that line is dropped, and what reaches the MCP client is:

```
Protocol error (Target.setDiscoverTargets): Target closed
```

Reproduced on `main` (v1.8.0) by pointing `executablePath` at a stub
that prints Chrome's message to stderr and exits 1 — exactly the symptom
reported in #261. The `catch` in `launch()` only recognised `The browser
is already running`.

### Fix

Detect the case and rethrow with an explanation. Deliberately **not**
disabling the sandbox automatically, per
https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/261#issuecomment-3389871157:

```
Chrome failed to start: Protocol error (Target.setDiscoverTargets): Target closed

chrome-devtools-mcp is running as root and Chrome does not start as root unless its
sandbox is disabled (https://crbug.com/638180). Prefer running chrome-devtools-mcp as
a non-root user. If that is not possible, for example in a container, pass
--chrome-arg=--no-sandbox. That disables the Chrome sandbox, so only do it for content
you trust.
```

Notes on the shape of the check:

- It runs **only on a failed launch**, so root with a properly installed
setuid sandbox is unaffected.
- The original error is kept in the message and as `cause`, so an
unrelated failure under root is never masked or mislabelled.
- `--no-sandbox` present in the args short-circuits it: root is then not
what stopped Chrome.
- `--disable-setuid-sandbox` and `--no-sandbox-and-elevated` do **not**
count as opting out — Chrome's zygote check requires `--no-sandbox`
specifically. Covered by a test.
- On Windows `process.getuid` is undefined, so the check is a no-op.

Also adds a "Running as root" section to `docs/troubleshooting.md`, next
to the existing "Operating system sandboxes".

### Testing

Five unit tests in `tests/browser.test.ts` covering root / non-root /
no-uid platforms, the already-opted-out args, and the near-miss args
above.

The pre-existing integration tests in that file cannot run in my
environment — Chrome reports `No usable sandbox! ... AppArmor userns
restrictions` there, on a clean checkout as well as with this change.
This commit is contained in:
Aleksandr Konovalov
2026-09-15 11:52:27 +00:00
committed by GitHub
parent 655031baec
commit 9d2922369b
3 changed files with 107 additions and 0 deletions
+13
View File
@@ -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).
+43
View File
@@ -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 =
@@ -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;
}
}
+51
View File
@@ -16,6 +16,7 @@ import {
ensureBrowserConnected,
launch,
makeTargetFilter,
rootSandboxLaunchError,
} from '../src/browser.js';
import type {Browser} from '../src/third_party/index.js';
@@ -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();