openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
401 lines • 16.1 kB
JavaScript
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
if (value !== null && value !== void 0) {
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
var dispose, inner;
if (async) {
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
dispose = value[Symbol.asyncDispose];
}
if (dispose === void 0) {
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
dispose = value[Symbol.dispose];
if (async) inner = dispose;
}
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
env.stack.push({ value: value, dispose: dispose, async: async });
}
else if (async) {
env.stack.push({ async: true });
}
return value;
};
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
return function (env) {
function fail(e) {
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
env.hasError = true;
}
var r, s = 0;
function next() {
while (r = env.stack.pop()) {
try {
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
if (r.dispose) {
var result = r.dispose.call(r.value);
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
}
else s |= 1;
}
catch (e) {
fail(e);
}
}
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
if (env.hasError) throw env.error;
}
return next();
};
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
});
var _a;
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Mutex, puppeteer } from './third_party/index.js';
import { logger, puppeteerLogger } from './utils/logger.js';
import { isAllowedUrl } from './utils/url.js';
export class BrowserManager {
#browser;
#browserMode;
#initPromise;
#mutex = new Mutex();
#closingCount = 0;
#serverArgs;
#options;
constructor(serverArgs, options = {}) {
this.#serverArgs = serverArgs;
this.#options = options;
}
static makeTargetFilter(enableExtensions = false) {
return function targetFilter(target) {
const url = target.url();
if (!url) {
return true;
}
return isAllowedUrl(url, { categoryExtensions: enableExtensions });
};
}
static detectDisplay() {
// Only detect display on Linux/UNIX.
if (os.platform() === 'win32' || os.platform() === 'darwin') {
return;
}
if (!process.env['DISPLAY']) {
try {
const result = execSync(`ps -u $(id -u) -o pid= | xargs -I{} cat /proc/{}/environ 2>/dev/null | tr '\\0' '\\n' | grep -m1 '^DISPLAY=' | cut -d= -f2`);
const display = result.toString('utf8').trim();
process.env['DISPLAY'] = display;
}
catch {
// no-op
}
}
}
/**
* 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).
*/
static rootSandboxLaunchError(error, args, uid = process.getuid?.()) {
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,
});
}
async ensureBrowser() {
if (this.#initPromise) {
return await this.#initPromise;
}
const initPromise = this.#ensureBrowserLocked();
this.#initPromise = initPromise;
try {
return await initPromise;
}
finally {
if (this.#initPromise === initPromise) {
this.#initPromise = undefined;
}
}
}
async #ensureBrowserLocked() {
const env_1 = { stack: [], error: void 0, hasError: false };
try {
if (this.#closingCount > 0) {
throw new Error('Browser was closed while initializing.');
}
const _guard = __addDisposableResource(env_1, await this.#mutex.acquire(), false);
if (this.#closingCount > 0) {
throw new Error('Browser was closed while initializing.');
}
if (!this.#browser?.connected) {
await this.#initBrowser();
}
if (this.#closingCount > 0 || !this.#browser) {
await this.#closeBrowser();
throw new Error('Browser was closed while initializing.');
}
return this.#browser;
}
catch (e_1) {
env_1.error = e_1;
env_1.hasError = true;
}
finally {
__disposeResources(env_1);
}
}
async #initBrowser() {
if (this.#serverArgs.browserUrl ||
this.#serverArgs.wsEndpoint ||
this.#serverArgs.autoConnect) {
return await this.#connect();
}
return await this.#launch();
}
async #launch() {
const { channel, executablePath, headless, isolated = false, categoryExtensions: enableExtensions, viaCli, viewport, acceptInsecureCerts, experimentalDevtools: devtools = false, blockedUrlPattern: blocklist, allowedUrlPattern: allowlist, ignoreDefaultChromeArg, proxyServer, } = this.#serverArgs;
const profileDirName = channel && channel !== 'stable'
? `chrome-profile-${channel}`
: 'chrome-profile';
let userDataDir = this.#serverArgs.userDataDir;
if (!isolated && !userDataDir) {
userDataDir = path.join(os.homedir(), '.cache', viaCli ? 'chrome-devtools-mcp-cli' : 'chrome-devtools-mcp', profileDirName);
await fs.promises.mkdir(userDataDir, {
recursive: true,
});
}
const args = [...(this.#serverArgs.chromeArg ?? [])];
if (proxyServer) {
args.push(`--proxy-server=${proxyServer}`);
}
args.push('--hide-crash-restore-bubble');
const ignoreDefaultArgs = ignoreDefaultChromeArg ?? false;
if (headless) {
args.push('--screen-info={3840x2160}');
}
let puppeteerChannel;
if (devtools) {
args.push('--auto-open-devtools-for-tabs');
}
if (!executablePath) {
puppeteerChannel =
channel && channel !== 'stable' ? `chrome-${channel}` : 'chrome';
}
if (!headless) {
_a.detectDisplay();
}
let browser;
try {
browser = await puppeteer.launch({
channel: puppeteerChannel,
targetFilter: _a.makeTargetFilter(enableExtensions),
executablePath,
defaultViewport: null,
userDataDir,
pipe: true,
headless,
args,
ignoreDefaultArgs,
acceptInsecureCerts,
handleDevToolsAsPage: true,
enableExtensions,
blocklist,
allowlist,
logger: puppeteerLogger,
});
if (this.#options.logFile) {
// FIXME: we are probably subscribing too late to catch startup logs. We
// should expose the process earlier or expose the getRecentLogs() getter.
browser.process()?.stderr?.pipe(this.#options.logFile);
browser.process()?.stdout?.pipe(this.#options.logFile);
}
if (viewport) {
const [page] = await browser.pages();
await page?.resize({
contentWidth: viewport.width,
contentHeight: viewport.height,
});
}
this.#browserMode = 'launched';
this.#browser = browser;
return browser;
}
catch (error) {
await browser?.close().catch(() => {
// Best-effort cleanup if post-launch setup failed.
});
if (userDataDir &&
error instanceof Error &&
error.message.includes('The browser is already running')) {
throw new Error(`The browser is already running for ${userDataDir}. Use --isolated to run multiple browser instances.`, {
cause: error,
});
}
if (error instanceof Error) {
const rootError = _a.rootSandboxLaunchError(error, args);
if (rootError) {
throw rootError;
}
}
throw error;
}
}
async #connect() {
const { browserUrl: browserURL, wsEndpoint, wsHeaders, autoConnect: isAutoConnect, userDataDir, blockedUrlPattern: blocklist, allowedUrlPattern: allowlist, } = this.#serverArgs;
// Important: only pass channel, if autoConnect is true.
const channel = isAutoConnect ? this.#serverArgs.channel : undefined;
const connectOptions = {
targetFilter: _a.makeTargetFilter(),
defaultViewport: null,
handleDevToolsAsPage: true,
blocklist,
allowlist,
logger: puppeteerLogger,
};
let autoConnect = false;
if (wsEndpoint) {
connectOptions.browserWSEndpoint = wsEndpoint;
if (wsHeaders) {
connectOptions.headers = wsHeaders;
}
}
else if (browserURL) {
connectOptions.browserURL = browserURL;
}
else if (channel || userDataDir) {
if (userDataDir) {
autoConnect = true;
// TODO: re-expose this logic via Puppeteer.
const portPath = path.join(userDataDir, 'DevToolsActivePort');
try {
const fileContent = await fs.promises.readFile(portPath, 'utf8');
const [rawPort, rawPath] = fileContent
.split('\n')
.map(line => {
return line.trim();
})
.filter(line => {
return !!line;
});
if (!rawPort || !rawPath) {
throw new Error(`Invalid DevToolsActivePort '${fileContent}' found`);
}
const port = parseInt(rawPort, 10);
if (isNaN(port) || port <= 0 || port > 65535) {
throw new Error(`Invalid port '${rawPort}' found`);
}
const browserWSEndpoint = `ws://127.0.0.1:${port}${rawPath}`;
connectOptions.browserWSEndpoint = browserWSEndpoint;
}
catch (error) {
throw new Error(`Could not connect to Chrome in ${userDataDir}. Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.`, {
cause: error,
});
}
}
else {
if (!channel) {
throw new Error('Channel must be provided if userDataDir is missing');
}
connectOptions.channel =
channel === 'stable' ? 'chrome' : `chrome-${channel}`;
}
}
else {
throw new Error('Either browserURL, wsEndpoint, channel or userDataDir must be provided');
}
logger?.('Connecting Puppeteer to ', JSON.stringify(connectOptions));
try {
const connected = await puppeteer.connect(connectOptions);
logger?.('Connected Puppeteer');
this.#browserMode = 'connected';
this.#browser = connected;
return connected;
}
catch (err) {
throw new Error(`Could not connect to Chrome. ${autoConnect ? `Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.` : `Check if Chrome is running.`}`, {
cause: err,
});
}
}
async #closeBrowser() {
const browser = this.#browser;
const mode = this.#browserMode;
this.#browser = undefined;
this.#browserMode = undefined;
if (!browser || !browser.connected) {
return;
}
if (mode === 'launched') {
await browser.close().catch(err => {
logger?.('Failed to close browser', err);
});
return;
}
await browser.disconnect().catch(err => {
logger?.('Failed to disconnect from browser', err);
});
}
/**
* Shutdown hook for the active browser. Closes a launched browser (so the
* Chrome subprocess is reaped) or disconnects from an attached browser (so
* the user's Chrome instance stays alive). No-op if no browser is active or
* the connection has already been dropped.
*/
async close() {
const env_2 = { stack: [], error: void 0, hasError: false };
try {
this.#initPromise = undefined;
this.#closingCount++;
const _guard = __addDisposableResource(env_2, await this.#mutex.acquire(), false);
try {
await this.#closeBrowser();
}
finally {
this.#closingCount--;
}
}
catch (e_2) {
env_2.error = e_2;
env_2.hasError = true;
}
finally {
__disposeResources(env_2);
}
}
[Symbol.dispose]() {
this.close().catch(err => {
logger?.('Failed to dispose BrowserManager', err);
});
}
async [Symbol.asyncDispose]() {
await this.close();
}
}
_a = BrowserManager;
//# sourceMappingURL=BrowserManager.js.map