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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ summary: Timeline of guardrail helper changes mirrored from Sweetistics and rela

# Changelog

## 2026-07-11 — Browser Tools Hardening
- Removed shell interpretation from browser profile setup, preserved relative symlinks while copying profiles, and fixed the `--no-serialize` console flag. Thanks @ShiroKSH.

## 2026-07-11 — xurl Install Discovery
- Fixed the xurl skill's npm installer metadata so OpenClaw discovers the supported Node installation path. Thanks @not-stbenjam.

Expand Down
94 changes: 94 additions & 0 deletions scripts/browser-tools-profile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { lstatSync, mkdtempSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { describe, expect, test } from 'bun:test';
import { copyChromeProfile, isMainModule } from './browser-tools';

describe('copyChromeProfile', () => {
test('preserves relative symlink targets', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'browser-tools-profile-'));
const source = path.join(root, 'source');
const sourceLink = path.join(root, 'source-link');
const destination = path.join(root, 'destination');
mkdirSync(source);
writeFileSync(path.join(source, 'target'), 'profile state');
symlinkSync('target', path.join(source, 'relative-link'));
symlinkSync('source', sourceLink);

copyChromeProfile(sourceLink, destination);

expect(readlinkSync(path.join(destination, 'relative-link'))).toBe('target');
});

test('rejects overlapping source and destination paths', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'browser-tools-profile-overlap-'));
const source = path.join(root, 'source');
mkdirSync(source);
writeFileSync(path.join(source, 'profile-state'), 'keep me');

expect(() => copyChromeProfile(source, source)).toThrow('must not overlap');
expect(() => copyChromeProfile(source, path.join(source, 'nested'))).toThrow('must not overlap');
expect(() => copyChromeProfile(source, root)).toThrow('must not overlap');
expect(readFileSync(path.join(source, 'profile-state'), 'utf8')).toBe('keep me');
});

test('validates the source before changing the destination', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'browser-tools-profile-missing-source-'));
const destination = path.join(root, 'destination');
mkdirSync(destination);
writeFileSync(path.join(destination, 'profile-state'), 'keep me');

expect(() => copyChromeProfile(path.join(root, 'missing'), destination)).toThrow();
expect(readFileSync(path.join(destination, 'profile-state'), 'utf8')).toBe('keep me');
});

test('preserves a symlinked destination directory', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'browser-tools-profile-destination-link-'));
const source = path.join(root, 'source');
const destinationTarget = path.join(root, 'destination-target');
const destinationLink = path.join(root, 'destination-link');
mkdirSync(source);
mkdirSync(destinationTarget);
writeFileSync(path.join(source, 'new-state'), 'new');
writeFileSync(path.join(destinationTarget, 'old-state'), 'old');
symlinkSync('destination-target', destinationLink);

copyChromeProfile(source, destinationLink);

expect(lstatSync(destinationLink).isSymbolicLink()).toBe(true);
expect(readlinkSync(destinationLink)).toBe('destination-target');
expect(readFileSync(path.join(destinationTarget, 'new-state'), 'utf8')).toBe('new');
expect(() => readFileSync(path.join(destinationTarget, 'old-state'), 'utf8')).toThrow();
});

test('replaces a symlink to a non-directory without changing its target', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'browser-tools-profile-destination-file-link-'));
const source = path.join(root, 'source');
const destinationTarget = path.join(root, 'destination-target');
const destinationLink = path.join(root, 'destination-link');
mkdirSync(source);
writeFileSync(path.join(source, 'new-state'), 'new');
writeFileSync(destinationTarget, 'keep target');
symlinkSync('destination-target', destinationLink);

copyChromeProfile(source, destinationLink);

expect(lstatSync(destinationLink).isDirectory()).toBe(true);
expect(readFileSync(destinationTarget, 'utf8')).toBe('keep target');
expect(readFileSync(path.join(destinationLink, 'new-state'), 'utf8')).toBe('new');
});
});

describe('isMainModule', () => {
test('falls back to canonical paths when import.meta.main is unavailable', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'browser-tools-main-module-'));
const modulePath = path.join(root, 'browser-tools.ts');
const launcherPath = path.join(root, 'browser-tools');
writeFileSync(modulePath, 'fixture');
symlinkSync('browser-tools.ts', launcherPath);

expect(isMainModule(null, launcherPath, pathToFileURL(modulePath).href)).toBe(true);
expect(isMainModule(null, path.join(root, 'other'), pathToFileURL(modulePath).href)).toBe(false);
});
});
91 changes: 82 additions & 9 deletions scripts/browser-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
* directly via the DevTools protocol without pulling in a large MCP server.
*/
import { Command } from 'commander';
import { execSync, spawn } from 'node:child_process';
import { execFileSync, execSync, spawn } from 'node:child_process';
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import { fileURLToPath } from 'node:url';
import { inspect } from 'node:util';
import puppeteer, { type HTTPRequest } from 'puppeteer-core';

Expand All @@ -33,6 +35,60 @@ async function connectBrowser(port: number) {
return puppeteer.connect({ browserURL: browserURL(port), defaultViewport: null });
}

function resolveComparablePath(inputPath: string): string {
let existingPath = path.resolve(inputPath);
const missingSegments: string[] = [];
while (!existsSync(existingPath)) {
const parent = path.dirname(existingPath);
if (parent === existingPath) break;
missingSegments.unshift(path.basename(existingPath));
existingPath = parent;
}
return path.join(realpathSync(existingPath), ...missingSegments);
}

function pathsOverlap(first: string, second: string): boolean {
const relative = path.relative(first, second);
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
}

export function copyChromeProfile(sourceDir: string, profileDir: string): void {
const source = realpathSync(sourceDir);
if (!statSync(source).isDirectory()) {
throw new Error('Chrome profile source must be a directory');
}

let destinationLinkExists = false;
try {
destinationLinkExists = lstatSync(profileDir).isSymbolicLink();
} catch {
// Missing destinations are created below.
}
if (destinationLinkExists && !existsSync(profileDir)) {
throw new Error('Chrome profile destination symlink target does not exist');
}

let destination = resolveComparablePath(profileDir);
if (pathsOverlap(source, destination) || pathsOverlap(destination, source)) {
throw new Error('Chrome profile source and destination must not overlap');
}

if (existsSync(profileDir) && statSync(profileDir).isDirectory()) {
for (const entry of readdirSync(destination)) {
rmSync(path.join(destination, entry), { recursive: true, force: true });
}
} else {
rmSync(profileDir, { recursive: true, force: true });
mkdirSync(profileDir, { recursive: true });
destination = resolveComparablePath(profileDir);
}
cpSync(source, destination, {
recursive: true,
force: true,
verbatimSymlinks: true,
});
}

async function getActivePage(port: number) {
const browser = await connectBrowser(port);
const pages = await browser.pages();
Expand Down Expand Up @@ -70,16 +126,16 @@ program

if (killExisting) {
try {
execSync("killall 'Google Chrome'", { stdio: 'ignore' });
execFileSync('killall', ['Google Chrome'], { stdio: 'ignore' });
} catch {
// ignore missing processes
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
execSync(`mkdir -p "${profileDir}"`);
mkdirSync(profileDir, { recursive: true });
if (profile) {
const source = `${path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome')}/`;
execSync(`rsync -a --delete "${source}" "${profileDir}/"`, { stdio: 'ignore' });
const source = path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome');
copyChromeProfile(source, profileDir);
}

spawn(chromePath, [`--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, '--no-first-run', '--disable-popup-blocking'], {
Expand Down Expand Up @@ -416,14 +472,13 @@ program
.option('--timeout <seconds>', 'Capture duration in seconds (default: 5 for one-shot, infinite for --follow)', (value) => Number.parseInt(value, 10))
.option('--color', 'Force color output')
.option('--no-color', 'Disable color output')
.option('--no-serialize', 'Disable object serialization (show raw text only)', false)
.option('--no-serialize', 'Disable object serialization (show raw text only)')
.action(async (options) => {
const port = options.port as number;
const follow = options.follow as boolean;
const timeout = options.timeout as number | undefined;
const typesFilter = options.types as string | undefined;
const noSerialize = options.noSerialize as boolean;
const serialize = !noSerialize;
const serialize = options.serialize !== false;

// Track explicit color flags by looking at argv to avoid Commander defaults overriding TTY detection.
const argv = process.argv.slice(2);
Expand Down Expand Up @@ -1164,4 +1219,22 @@ function fetchJson(url: string, timeoutMs = 2000): Promise<unknown> {
});
}

program.parseAsync(process.argv);
export function isMainModule(
metaMain: boolean | null | undefined = import.meta.main,
argvPath: string | undefined = process.argv[1],
moduleUrl: string = import.meta.url,
): boolean {
if (typeof metaMain === 'boolean') return metaMain;
if (!argvPath) return false;

const modulePath = fileURLToPath(moduleUrl);
try {
return realpathSync(argvPath) === realpathSync(modulePath);
} catch {
return path.resolve(argvPath) === modulePath;
}
}

if (isMainModule()) {
void program.parseAsync(process.argv);
}