openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
233 lines • 8.54 kB
JavaScript
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { puppeteer } from './third_party/index.js';
import { logger, puppeteerLogger } from './utils/logger.js';
import { isAllowedUrl } from './utils/url.js';
let browser;
let browserMode;
export function makeTargetFilter(enableExtensions = false) {
return function targetFilter(target) {
const url = target.url();
if (!url) {
return true;
}
return isAllowedUrl(url, { categoryExtensions: enableExtensions });
};
}
export async function ensureBrowserConnected(options) {
const { channel, enableExtensions } = options;
if (browser?.connected) {
return browser;
}
const connectOptions = {
targetFilter: makeTargetFilter(enableExtensions),
defaultViewport: null,
handleDevToolsAsPage: true,
blocklist: options.blocklist,
allowlist: options.allowlist,
logger: puppeteerLogger,
};
let autoConnect = false;
if (options.wsEndpoint) {
connectOptions.browserWSEndpoint = options.wsEndpoint;
if (options.wsHeaders) {
connectOptions.headers = options.wsHeaders;
}
}
else if (options.browserURL) {
connectOptions.browserURL = options.browserURL;
}
else if (channel || options.userDataDir) {
const userDataDir = options.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 {
// Assign mode before browser so a concurrent closeBrowser() never sees
// `browser` set with `browserMode` still undefined (would fall through
// to the disconnect() path and orphan a launched Chrome).
const connected = await puppeteer.connect(connectOptions);
browserMode = 'connected';
browser = 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,
});
}
logger?.('Connected Puppeteer');
return browser;
}
export function 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
}
}
}
export async function launch(options) {
const { channel, executablePath, headless, isolated } = options;
const profileDirName = channel && channel !== 'stable'
? `chrome-profile-${channel}`
: 'chrome-profile';
let userDataDir = options.userDataDir;
if (!isolated && !userDataDir) {
userDataDir = path.join(os.homedir(), '.cache', options.viaCli ? 'chrome-devtools-mcp-cli' : 'chrome-devtools-mcp', profileDirName);
await fs.promises.mkdir(userDataDir, {
recursive: true,
});
}
const args = [
...(options.chromeArgs ?? []),
'--hide-crash-restore-bubble',
];
const ignoreDefaultArgs = options.ignoreDefaultChromeArgs ?? false;
if (headless) {
args.push('--screen-info={3840x2160}');
}
let puppeteerChannel;
if (options.devtools) {
args.push('--auto-open-devtools-for-tabs');
}
if (!executablePath) {
puppeteerChannel =
channel && channel !== 'stable'
? `chrome-${channel}`
: 'chrome';
}
if (!headless) {
detectDisplay();
}
try {
const browser = await puppeteer.launch({
channel: puppeteerChannel,
targetFilter: makeTargetFilter(options.enableExtensions),
executablePath,
defaultViewport: null,
userDataDir,
pipe: true,
headless,
args,
ignoreDefaultArgs: ignoreDefaultArgs,
acceptInsecureCerts: options.acceptInsecureCerts,
handleDevToolsAsPage: true,
enableExtensions: options.enableExtensions,
blocklist: options.blocklist,
allowlist: options.allowlist,
logger: puppeteerLogger,
});
if (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(options.logFile);
browser.process()?.stdout?.pipe(options.logFile);
}
if (options.viewport) {
const [page] = await browser.pages();
await page?.resize({
contentWidth: options.viewport.width,
contentHeight: options.viewport.height,
});
}
return browser;
}
catch (error) {
if (userDataDir &&
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,
});
}
throw error;
}
}
export async function ensureBrowserLaunched(options) {
if (browser?.connected) {
return browser;
}
// Assign mode before browser; see the connect path above for rationale.
const launched = await launch(options);
browserMode = 'launched';
browser = launched;
return browser;
}
/**
* 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. Called from the server entrypoint
* on stdin EOF / SIGTERM / SIGINT.
*/
export async function closeBrowser() {
const b = browser;
const mode = browserMode;
browser = undefined;
browserMode = undefined;
if (!b || !b.connected) {
return;
}
if (mode === 'launched') {
await b.close().catch(err => {
logger?.('Failed to close browser', err);
});
return;
}
await b.disconnect().catch(err => {
logger?.('Failed to disconnect from browser', err);
});
}
//# sourceMappingURL=browser.js.map