From 609f2191a6711f7ca1a50b7dea39a20fcf0ff708 Mon Sep 17 00:00:00 2001 From: ShiroKSH Date: Thu, 9 Jul 2026 21:08:17 +0300 Subject: [PATCH 1/5] fix: harden browser tools start --- scripts/browser-tools.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/scripts/browser-tools.ts b/scripts/browser-tools.ts index 0f76ddd9..e9282bde 100644 --- a/scripts/browser-tools.ts +++ b/scripts/browser-tools.ts @@ -8,7 +8,8 @@ * 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, mkdirSync, rmSync } from 'node:fs'; import { writeFile } from 'node:fs/promises'; import http from 'node:http'; import os from 'node:os'; @@ -33,6 +34,12 @@ async function connectBrowser(port: number) { return puppeteer.connect({ browserURL: browserURL(port), defaultViewport: null }); } +function copyChromeProfile(sourceDir: string, profileDir: string): void { + rmSync(profileDir, { recursive: true, force: true }); + mkdirSync(profileDir, { recursive: true }); + cpSync(sourceDir, profileDir, { recursive: true, force: true }); +} + async function getActivePage(port: number) { const browser = await connectBrowser(port); const pages = await browser.pages(); @@ -70,16 +77,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 +423,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); From 2edc27ba104bfcd1ab11e7a470916bb47a08325e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 13:49:28 -0700 Subject: [PATCH 2/5] fix: preserve browser profile symlinks --- CHANGELOG.md | 3 +++ scripts/browser-tools-profile.test.ts | 20 ++++++++++++++++++++ scripts/browser-tools-profile.ts | 11 +++++++++++ scripts/browser-tools.ts | 9 ++------- 4 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 scripts/browser-tools-profile.test.ts create mode 100644 scripts/browser-tools-profile.ts 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..d3b29b4d --- /dev/null +++ b/scripts/browser-tools-profile.test.ts @@ -0,0 +1,20 @@ +import { mkdtempSync, mkdirSync, readlinkSync, symlinkSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, test } from 'bun:test'; +import { copyChromeProfile } from './browser-tools-profile'; + +describe('copyChromeProfile', () => { + test('preserves relative symlink targets', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'browser-tools-profile-')); + const source = path.join(root, 'source'); + const destination = path.join(root, 'destination'); + mkdirSync(source); + writeFileSync(path.join(source, 'target'), 'profile state'); + symlinkSync('target', path.join(source, 'relative-link')); + + copyChromeProfile(source, destination); + + expect(readlinkSync(path.join(destination, 'relative-link'))).toBe('target'); + }); +}); diff --git a/scripts/browser-tools-profile.ts b/scripts/browser-tools-profile.ts new file mode 100644 index 00000000..3cfea3a2 --- /dev/null +++ b/scripts/browser-tools-profile.ts @@ -0,0 +1,11 @@ +import { cpSync, mkdirSync, rmSync } from 'node:fs'; + +export function copyChromeProfile(sourceDir: string, profileDir: string): void { + rmSync(profileDir, { recursive: true, force: true }); + mkdirSync(profileDir, { recursive: true }); + cpSync(sourceDir, profileDir, { + recursive: true, + force: true, + verbatimSymlinks: true, + }); +} diff --git a/scripts/browser-tools.ts b/scripts/browser-tools.ts index e9282bde..2549f26b 100644 --- a/scripts/browser-tools.ts +++ b/scripts/browser-tools.ts @@ -9,7 +9,7 @@ */ import { Command } from 'commander'; import { execFileSync, execSync, spawn } from 'node:child_process'; -import { cpSync, mkdirSync, rmSync } from 'node:fs'; +import { mkdirSync } from 'node:fs'; import { writeFile } from 'node:fs/promises'; import http from 'node:http'; import os from 'node:os'; @@ -18,6 +18,7 @@ import readline from 'node:readline/promises'; import { stdin as input, stdout as output } from 'node:process'; import { inspect } from 'node:util'; import puppeteer, { type HTTPRequest } from 'puppeteer-core'; +import { copyChromeProfile } from './browser-tools-profile'; /** Utility type so TypeScript knows the async function constructor */ type AsyncFunctionCtor = new (...args: string[]) => (...fnArgs: unknown[]) => Promise; @@ -34,12 +35,6 @@ async function connectBrowser(port: number) { return puppeteer.connect({ browserURL: browserURL(port), defaultViewport: null }); } -function copyChromeProfile(sourceDir: string, profileDir: string): void { - rmSync(profileDir, { recursive: true, force: true }); - mkdirSync(profileDir, { recursive: true }); - cpSync(sourceDir, profileDir, { recursive: true, force: true }); -} - async function getActivePage(port: number) { const browser = await connectBrowser(port); const pages = await browser.pages(); From d4f6dbf354e3ca472675ae1327149799de6d4c2a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 13:54:54 -0700 Subject: [PATCH 3/5] fix: guard browser profile copy targets --- scripts/browser-tools-profile.test.ts | 16 +++++++++-- scripts/browser-tools-profile.ts | 11 ------- scripts/browser-tools.ts | 41 +++++++++++++++++++++++++-- 3 files changed, 52 insertions(+), 16 deletions(-) delete mode 100644 scripts/browser-tools-profile.ts diff --git a/scripts/browser-tools-profile.test.ts b/scripts/browser-tools-profile.test.ts index d3b29b4d..261e8f33 100644 --- a/scripts/browser-tools-profile.test.ts +++ b/scripts/browser-tools-profile.test.ts @@ -1,8 +1,8 @@ -import { mkdtempSync, mkdirSync, readlinkSync, symlinkSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { describe, expect, test } from 'bun:test'; -import { copyChromeProfile } from './browser-tools-profile'; +import { copyChromeProfile } from './browser-tools'; describe('copyChromeProfile', () => { test('preserves relative symlink targets', () => { @@ -17,4 +17,16 @@ describe('copyChromeProfile', () => { 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'); + }); }); diff --git a/scripts/browser-tools-profile.ts b/scripts/browser-tools-profile.ts deleted file mode 100644 index 3cfea3a2..00000000 --- a/scripts/browser-tools-profile.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { cpSync, mkdirSync, rmSync } from 'node:fs'; - -export function copyChromeProfile(sourceDir: string, profileDir: string): void { - rmSync(profileDir, { recursive: true, force: true }); - mkdirSync(profileDir, { recursive: true }); - cpSync(sourceDir, profileDir, { - recursive: true, - force: true, - verbatimSymlinks: true, - }); -} diff --git a/scripts/browser-tools.ts b/scripts/browser-tools.ts index 2549f26b..04207fc0 100644 --- a/scripts/browser-tools.ts +++ b/scripts/browser-tools.ts @@ -9,16 +9,16 @@ */ import { Command } from 'commander'; import { execFileSync, execSync, spawn } from 'node:child_process'; -import { mkdirSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, realpathSync, rmSync } 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'; -import { copyChromeProfile } from './browser-tools-profile'; /** Utility type so TypeScript knows the async function constructor */ type AsyncFunctionCtor = new (...args: string[]) => (...fnArgs: unknown[]) => Promise; @@ -35,6 +35,39 @@ 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 = resolveComparablePath(sourceDir); + const destination = resolveComparablePath(profileDir); + if (pathsOverlap(source, destination) || pathsOverlap(destination, source)) { + throw new Error('Chrome profile source and destination must not overlap'); + } + + rmSync(profileDir, { recursive: true, force: true }); + mkdirSync(profileDir, { recursive: true }); + cpSync(sourceDir, profileDir, { + recursive: true, + force: true, + verbatimSymlinks: true, + }); +} + async function getActivePage(port: number) { const browser = await connectBrowser(port); const pages = await browser.pages(); @@ -1165,4 +1198,6 @@ function fetchJson(url: string, timeoutMs = 2000): Promise { }); } -program.parseAsync(process.argv); +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + void program.parseAsync(process.argv); +} From 96bc49261d7292d0b81f19d311273900d4c665bf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 14:03:33 -0700 Subject: [PATCH 4/5] fix: preserve browser tools launcher compatibility --- scripts/browser-tools-profile.test.ts | 20 ++++++++++++++++++-- scripts/browser-tools.ts | 20 ++++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/scripts/browser-tools-profile.test.ts b/scripts/browser-tools-profile.test.ts index 261e8f33..d9e11e97 100644 --- a/scripts/browser-tools-profile.test.ts +++ b/scripts/browser-tools-profile.test.ts @@ -1,19 +1,22 @@ import { 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 } from './browser-tools'; +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(source, destination); + copyChromeProfile(sourceLink, destination); expect(readlinkSync(path.join(destination, 'relative-link'))).toBe('target'); }); @@ -30,3 +33,16 @@ describe('copyChromeProfile', () => { expect(readFileSync(path.join(source, 'profile-state'), 'utf8')).toBe('keep me'); }); }); + +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 04207fc0..4ead295c 100644 --- a/scripts/browser-tools.ts +++ b/scripts/browser-tools.ts @@ -61,7 +61,7 @@ export function copyChromeProfile(sourceDir: string, profileDir: string): void { rmSync(profileDir, { recursive: true, force: true }); mkdirSync(profileDir, { recursive: true }); - cpSync(sourceDir, profileDir, { + cpSync(source, profileDir, { recursive: true, force: true, verbatimSymlinks: true, @@ -1198,6 +1198,22 @@ function fetchJson(url: string, timeoutMs = 2000): Promise { }); } -if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +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); } From e281904c32e2641ce522582481e49bc2f83dbfef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 14:08:37 -0700 Subject: [PATCH 5/5] fix: preserve profile destination semantics --- scripts/browser-tools-profile.test.ts | 48 ++++++++++++++++++++++++++- scripts/browser-tools.ts | 33 ++++++++++++++---- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/scripts/browser-tools-profile.test.ts b/scripts/browser-tools-profile.test.ts index d9e11e97..69e95d6a 100644 --- a/scripts/browser-tools-profile.test.ts +++ b/scripts/browser-tools-profile.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from 'node:fs'; +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'; @@ -32,6 +32,52 @@ describe('copyChromeProfile', () => { 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', () => { diff --git a/scripts/browser-tools.ts b/scripts/browser-tools.ts index 4ead295c..f85443bf 100644 --- a/scripts/browser-tools.ts +++ b/scripts/browser-tools.ts @@ -9,7 +9,7 @@ */ import { Command } from 'commander'; import { execFileSync, execSync, spawn } from 'node:child_process'; -import { cpSync, existsSync, mkdirSync, realpathSync, rmSync } from 'node:fs'; +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'; @@ -53,15 +53,36 @@ function pathsOverlap(first: string, second: string): boolean { } export function copyChromeProfile(sourceDir: string, profileDir: string): void { - const source = resolveComparablePath(sourceDir); - const destination = resolveComparablePath(profileDir); + 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'); } - rmSync(profileDir, { recursive: true, force: true }); - mkdirSync(profileDir, { recursive: true }); - cpSync(source, profileDir, { + 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,