diff --git a/CHANGELOG.md b/CHANGELOG.md index 233d1805..910ac640 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/scripts/browser-tools-profile.test.ts b/scripts/browser-tools-profile.test.ts new file mode 100644 index 00000000..69e95d6a --- /dev/null +++ b/scripts/browser-tools-profile.test.ts @@ -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); + }); +}); diff --git a/scripts/browser-tools.ts b/scripts/browser-tools.ts index 0f76ddd9..f85443bf 100644 --- a/scripts/browser-tools.ts +++ b/scripts/browser-tools.ts @@ -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'; @@ -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(); @@ -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'], { @@ -416,14 +472,13 @@ program .option('--timeout ', '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); @@ -1164,4 +1219,22 @@ function fetchJson(url: string, timeoutMs = 2000): Promise { }); } -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); +}