⏳ This skill is pending AI review.
Scores will appear once the review pipeline completes.
browsing
Use when you need direct browser control - teaches Chrome DevTools Protocol for controlling existing browser sessions, multi-tab management, form automation, and content extraction via use_browser MCP tool
Choose how to use this skill
You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.
1. Native installer
This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.
Do not guess an installer command or replace an existing version without reviewing the diff.
2. Complete package recommended
Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.
No complete ProSkills package is published for this listing yet.3. Prompt-only
Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.
Need only the instruction file?
Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.
No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.
// RATINGS
Not yet listed on ClawHub or SkillsMP
// README
chrome-ws
Chrome DevTools Protocol CLI - zero dependencies.
Installation
chmod +x chrome-ws
Quick Test
./chrome-ws start # Launch Chrome (auto-detects platform)
./chrome-ws new "https://example.com" # Create tab
./chrome-ws navigate 0 "https://example.com"
./chrome-ws extract 0 "h1" # Extract heading
Documentation
SKILL.md- Complete usage guideEXAMPLES.md- Real-world examples
Testing
./test-raw.sh # Raw JSON-RPC
./test-tabs.sh # Tab management
./test-navigate.sh # Navigation
./test-interact.sh # Form interaction
./test-extract.sh # Content extraction
./test-wait.sh # Wait commands
./test-e2e.sh # End-to-end workflow
Requirements
- Node.js 16+
- Chrome with
--remote-debugging-port=9222
Environment Variables
| Variable | Purpose |
|---|---|
CHROME_WS_BROWSER | Path to browser executable (overrides auto-detection) |
CHROME_WS_HOST | Debug host address (default 127.0.0.1) |
CHROME_WS_PORT | Debug port (default 9222) |
CHROME_WS_PROFILE | Profile name. Default superpowers-chrome with auto-disambiguation when contended; set explicitly to share a Chrome across processes. |
CHROME_EXTRA_ARGS | Whitespace-separated extra flags appended to the Chrome command line. |
Windows Notes
- The CLI now binds to
127.0.0.1:9222by default to avoid name-resolution issues on Windows. - Customize the connection via
CHROME_WS_HOST/CHROME_WS_PORTif you forward DevTools elsewhere.
// HOW IT'S BUILT
// FILES
60 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.
skills/browsing/SKILL.md37 KB6f7f5d77f7d6truncatedupstream ↗
Showing the inlined prefix. The rest is in the upstream file.
--- name: browsing description: Use when you need direct browser control - teaches Chrome DevTools Protocol for controlling existing browser sessions, multi-tab management, form automation, and content extraction via use_browser MCP tool allowed-tools: mcp__chrome__use_browser --- # Browsing with Chrome Direct ## Overview Control Chrome via DevTools Protocol using the `use_browser` MCP tool. Single unified interface with auto-starting Chrome. **Announce:** "I'm using the browsing skill to control Chrome." ## When to Use **Use this when:** - Controlling authenticated sessions - Managing multiple tabs in running browser - Playwright MCP unavailable or excessive **Use Playwright MCP when:** - Need fresh browser instances - Generating screenshots/PDFs - Prefer higher-level abstractions ## Auto-Capture Every DOM action (navigate, click, type, select, eval, keyboard_press, hover, drag_drop, double_click, right_click, file_upload) automatically saves: - `{prefix}.png` — viewport screenshot - `{prefix}.md` — page content as structured markdown - `{prefix}.html` — full rendered DOM - `{prefix}-console.txt` — browser console messages Files are saved to the session directory with sequential prefixes (001-navigate, 002-click, etc.). You must check these before using extract or screenshot actions. **Pause switch (`pause_capture`/`resume_capture`):** the shape/marker/URL defenses below only catch a secret with a recognized shape, an opt-in `data-sen-secret` mark, or a known-sensitive URL. A page that reveals a secret a different way — a "show password"/"reveal" click that renders the plaintext into an ordinary, unremarkable element (no secret-looking id/name/class, no sensitive URL) — slips past all three, and the very next auto-capture writes it to disk. Call `pause_capture` immediately BEFORE any action you expect to reveal a secret this way; every auto-capture (DOM/markdown/screenshot/console/dialog) is skipped — no CDP call is even made — until you call `resume_capture`. The action that triggers the reveal still runs normally; only its capture is skipped. An explicit `screenshot` call also refuses outright while paused. Pause state lives on the session and persists across actions until you resume: forgetting to call `resume_capture` just means no more captures for the rest of the session, never a silent leak. **Caveat:** this is in-memory, process-local state — it does NOT survive the MCP server process restarting (e.g. `restart_chrome` re-adopting a still-running Chrome keeps the SAME process and stays paused, but a full MCP server restart starts a fresh `capturePaused:false`). If the server restarts while a secret is still on-screen from a paused reveal, the next auto-capture after that restart will NOT be paused — the only signal is that the pause notice stops appearing in responses. Use the credential broker to capture the value itself while paused; resume as soon as the secret is off-screen. **Credential-shaped pages:** when a page shows a token or secret (Slack `xoxb-`/`xapp-`, GitHub `ghp_`/`github_pat_`, 1Password `ops_`/`A3-` keys, `otpauth://` seeds, or any element marked `data-sen-secret`), no files are written for that action and the response says `⚠️ Page shows credential-shaped content; auto-capture and DOM output suppressed.` with only metadata. Token-shaped values in `extract`/`eval` output are replaced by `[REDACTED credential-shaped]`, and `screenshot` refuses. Capture secrets with a credential broker. On a token-shaped page, use `eval` only for value-blind queries (e.g. "is the token field present?"). On a page with any `data-sen-secret` element, `eval` refuses outright, and `extract`/`attr` refuse for the marked element or strip it from HTML/markdown output. The marker scan covers the top document, open shadow roots, and same-origin `iframe`/`frame`/`object`/`embed`; it does not see closed shadow roots or cross-origin frames. Markup inside an `<iframe srcdoc="…">` attribute is not stripped: `extract` html and `attr srcdoc` return it verbatim. **When to mark a secret:** mark its element with `set_attr` (`data-sen-secret`) as soon as the action that revealed it returns, before any other action on that page, then capture the value with the credential broker. Marking only affects later actions. The capture files the revealing action already wrote (e.g. `003-click.html`/`.md`) still hold the value and are not deleted, so don't read them back. This is an accident guard for a cooperating agent, not a security boundary. `SUPERPOWERS_CHROME_ALLOW_CREDENTIAL_CAPTURE=1` turns all of this off. Separately (and unconditionally): a plain password or one-time code has no shape the check above recognizes, so a page's own handler mirroring one into an attribute, a hidden input, or visible text would otherwise land in the `.html`/`.md`/`-diff.txt` files. Whatever gets written is scrubbed on an inert clone (`document.implementation.createHTMLDocument` + `importNode`, never `cloneNode` on the live document): `value` and every `data-*`/`aria-*` attribute are stripped from `input[type="password"]` (case-insensitive; remembered on that same element after a "show password" toggle flips it to `type="text"`), any field whose `autocomplete` contains `current-password`, `new-password`, `one-time-code`, `cc-number`, or `cc-csc` (case-insensitive substring), and `data-sen-secret`-marked elements. A field with none of those signals is still caught if its own live `.value` exactly matches one of its OTHER attributes (a self-mirror) -- the case for Google's 2-step verification `totpPin` field, which has no recognized type or autocomplete but copies the typed code into `data-initial-value` itself. Self-mirror detection skips inputs nobody types into (`submit`, `button`, `reset`, `checkbox`, `radio`, `hidden`, `image`) and ignores attributes that name or label a field (`type`, `name`, `id`, `aria-label`, `title`, `placeholder`, `for`, `class`). The live `.value` of every field found any of these ways, plus its HTML-entity-escaped forms (including ` `), is also replaced with `[REDACTED]` wherever it occurs in the output -- any attribute, on any element, not just the field's own `value` -- if it is at least 4 characters long (3 for `cc-csc`). Dialog captures are redacted with the values from the most recent page capture; a value typed in the same action that opened the dialog is not. Redaction is a plain string replace, so a secret that also appears as ordinary page text (a password of `password`) replaces that text too; the artifact is degraded but nothing leaks. Self-mirror detection can also flag a non-secret typeable field whose value happens to equal one of its other attributes (a search box synced into `data-query`), with the same effect. The live page is never touched. **Known gaps (the secret is written to disk in clear):** split one-digit OTP boxes feeding an aggregate hidden input (each digit is below the length floor, so the aggregate leaks); a field cleared on submit whose mirror remains; a "show password" toggle that swaps in a new element instead of changing `type`; a self-mirrored value shorter than the length floor (the field is still found, but its value is too short to substring-redact). Screenshots are pixels, not scrubbed text: a visible (`type="text"`) typed value can appear in a `.png`. **Default secret-pattern detection (no marking, no click required):** everything above needs a recognized type/autocomplete, or a value already mirrored somewhere -- neither helps a secret already in the DOM at page load, before any action (and so before any marking) has run. Real case: Slack's 2FA setup page puts the TOTP seed in a hidden `#init_key_code` element from load, so the very first navigate's auto-capture would otherwise leak it. Any element whose `id`/`name`/`class`/`autocomplete`/`aria-label` contains `secret`, `totp`, `otp`, `2fa`, `mfa`, `key_code`, `seed`, `recovery`, `backup_code`, `api_key`, or `token` as a WORD (split on `-`/`_`/camskills/browsing/README.md1.5 KB4cfa837b825bupstream ↗
# chrome-ws Chrome DevTools Protocol CLI - zero dependencies. ## Installation ```bash chmod +x chrome-ws ``` ## Quick Test ```bash ./chrome-ws start # Launch Chrome (auto-detects platform) ./chrome-ws new "https://example.com" # Create tab ./chrome-ws navigate 0 "https://example.com" ./chrome-ws extract 0 "h1" # Extract heading ``` ## Documentation - `SKILL.md` - Complete usage guide - `EXAMPLES.md` - Real-world examples ## Testing ```bash ./test-raw.sh # Raw JSON-RPC ./test-tabs.sh # Tab management ./test-navigate.sh # Navigation ./test-interact.sh # Form interaction ./test-extract.sh # Content extraction ./test-wait.sh # Wait commands ./test-e2e.sh # End-to-end workflow ``` ## Requirements - Node.js 16+ - Chrome with `--remote-debugging-port=9222` ## Environment Variables | Variable | Purpose | |---|---| | `CHROME_WS_BROWSER` | Path to browser executable (overrides auto-detection) | | `CHROME_WS_HOST` | Debug host address (default `127.0.0.1`) | | `CHROME_WS_PORT` | Debug port (default `9222`) | | `CHROME_WS_PROFILE` | Profile name. Default `superpowers-chrome` with auto-disambiguation when contended; set explicitly to share a Chrome across processes. | | `CHROME_EXTRA_ARGS` | Whitespace-separated extra flags appended to the Chrome command line. | ## Windows Notes - The CLI now binds to `127.0.0.1:9222` by default to avoid name-resolution issues on Windows. - Customize the connection via `CHROME_WS_HOST` / `CHROME_WS_PORT` if you forward DevTools elsewhere.skills/browsing/.gitignore95 Be0a9879414b0upstream ↗
node_modules/ package-lock.json IMPLEMENTATION_SUMMARY.md TEST_RESULTS.md test-*.png test-*.mdskills/browsing/chrome-ws30 KBd8465f218d5fupstream ↗
#!/usr/bin/env node const process = require('process'); // Parse --port=N flag from anywhere in argv (filter it out of positional args) const allArgs = process.argv.slice(2); const portArg = allArgs.find(a => a.startsWith('--port=')); const positionalArgs = allArgs.filter(a => !a.startsWith('--port=')); const [command, wsUrlOrIndex, ...args] = positionalArgs; // Handle --help and --version before any other processing if (command === '--help' || command === '-h' || !command) { console.log(`Usage: chrome-ws <command> [args] Commands: start [port] Start Chrome with remote debugging stop Kill Chrome pid Print Chrome PID info Print Chrome info (JSON) tabs List open tabs new <url> Open a new tab close <tab> Close a tab navigate <tab> <url> Navigate tab to URL extract <tab> <selector> Extract element text content attr <tab> <selector> <attribute> Get element attribute html <tab> [selector] Get HTML content click <tab> <selector> Click an element fill <tab> <selector> <text> Fill an input field select <tab> <selector> <value> Select a dropdown option eval <tab> <js> Evaluate JavaScript wait-for <tab> <selector> [timeout-ms] Wait for element to appear wait-text <tab> <text> [timeout-ms] Wait for text to appear screenshot <tab> <filename.png> [--fullpage] Take a screenshot markdown <tab> <filename.md> Save page as markdown har <tab> <filename.har> Export HAR (after har-start) raw <ws-url> <json-rpc-payload> Send raw CDP command --help, -h Show this help --version, -v Show version --port=N Override CHROME_WS_PORT env var Tab arg: numeric index (0, 1, 2...) or full ws:// URL. `); process.exit(0); } if (command === '--version' || command === '-v') { const pkg = require('../../package.json'); console.log(pkg.version); process.exit(0); } const hostOverride = require('./host-override').createOverride(); const { createSession } = require('./chrome-ws-lib'); const { buildChromeArgs } = require('./lib/chrome-launcher-helpers'); const CHROME_DEBUG_HOST = hostOverride.getHost(); const CHROME_DEBUG_PORT = hostOverride.getPort(); const WS_OVERRIDE_ENABLED = hostOverride.isOverrideEnabled(); const rewriteWsUrl = hostOverride.rewriteWsUrl; // Effective port: --port=N flag overrides CHROME_WS_PORT env / default 9222 const effectivePort = portArg ? parseInt(portArg.split('=')[1], 10) : CHROME_DEBUG_PORT; // Session pointed at the effective port. Built after effectivePort is known // so the lib's pooled connections target the right Chrome instance. const session = createSession({ host: CHROME_DEBUG_HOST, port: effectivePort }); // Minimal WebSocket client implementation (dependency-free) class WebSocketClient { constructor(url) { this.url = new URL(url); this.callbacks = {}; this.socket = null; this.buffer = Buffer.alloc(0); } on(event, callback) { this.callbacks[event] = callback; } connect() { return new Promise((resolve, reject) => { const http = require('http'); const crypto = require('crypto'); const key = crypto.randomBytes(16).toString('base64'); const options = { hostname: this.url.hostname, port: this.url.port || 80, path: this.url.pathname + this.url.search, headers: { 'Upgrade': 'websocket', 'Connection': 'Upgrade', 'Sec-WebSocket-Key': key, 'Sec-WebSocket-Version': '13' } }; const req = http.request(options); req.on('upgrade', (res, socket) => { this.socket = socket; socket.on('data', (data) => { this.buffer = Buffer.concat([this.buffer, data]); this.processFrames(); }); socket.on('error', (err) => { if (this.callbacks.error) this.callbacks.error(err); }); if (this.callbacks.open) this.callbacks.open(); resolve(); }); req.on('error', reject); req.end(); }); } processFrames() { while (this.buffer.length >= 2) { const firstByte = this.buffer[0]; const secondByte = this.buffer[1]; const fin = (firstByte & 0x80) !== 0; const opcode = firstByte & 0x0F; const masked = (secondByte & 0x80) !== 0; let payloadLen = secondByte & 0x7F; let offset = 2; if (payloadLen === 126) { if (this.buffer.length < 4) return; payloadLen = this.buffer.readUInt16BE(2); offset = 4; } else if (payloadLen === 127) { if (this.buffer.length < 10) return; payloadLen = Number(this.buffer.readBigUInt64BE(2)); offset = 10; } if (this.buffer.length < offset + payloadLen) return; let payload = this.buffer.slice(offset, offset + payloadLen); this.buffer = this.buffer.slice(offset + payloadLen); if (opcode === 0x1 && this.callbacks.message) { this.callbacks.message(payload.toString('utf8')); } } } send(data) { const payload = Buffer.from(data, 'utf8'); const payloadLen = payload.length; let frame; let offset = 2; if (payloadLen < 126) { frame = Buffer.alloc(payloadLen + 6); frame[1] = payloadLen | 0x80; } else if (payloadLen < 65536) { frame = Buffer.alloc(payloadLen + 8); frame[1] = 126 | 0x80; frame.writeUInt16BE(payloadLen, 2); offset = 4; } else { frame = Buffer.alloc(payloadLen + 14); frame[1] = 127 | 0x80; frame.writeBigUInt64BE(BigInt(payloadLen), 2); offset = 10; } frame[0] = 0x81; // FIN + text frame const mask = Buffer.alloc(4); require('crypto').randomFillSync(mask); mask.copy(frame, offset); offset += 4; for (let i = 0; i < payloadLen; i++) { frame[offset + i] = payload[i] ^ mask[i % 4]; } this.socket.write(frame); } close() { if (this.socket) { this.socket.end(); this.socket = null; } } } // Helper to convert string tab specifier to the type expected by session methods. // session.fill/evaluate/etc. use getPageSession which accepts a number (index) or // a ws:// string — but NOT a numeric string like "0". function resolveTabArg(wsUrlOrIndex) { if (wsUrlOrIndex && wsUrlOrIndex.startsWith('ws://')) { return wsUrlOrIndex; // Already a ws URL string } const index = parseInt(wsUrlOrIndex, 10); if (!isNaN(index)) { return index; // Numeric tab index as a number } throw new Error(`Invalid tab specifier: ${wsUrlOrIndex}`); } // Helper to resolve tab index or ws URL to actual ws URL async function resolveWsUrl(wsUrlOrIndex) { // If it's already a WebSocket URL, return it if (wsUrlOrIndex && wsUrlOrIndex.startsWith('ws://')) { return wsUrlOrIndex; } // If it's a number (tab index), resolve it const index = parseInt(wsUrlOrIndex); if (!isNaN(index)) { const tabs = await chromeHttp('/json'); const pageTabs = Array.isArray(tabs) ? tabs .filter(t => t.type === 'page') .map(tab => WS_OVERRIDE_ENABLED ? { ...tab, webSocketDebuggerUrl: rewriteWsUrl(tab.webSocketDebuggerUrl) } : tab ) : []; // Auto-create tab if none exist (similar to auto-start Chrome behavior) if (pageTabs.length === 0) { const newTabInfo = await chromeHttp('/json/new?about:blank', 'PUT'); return WS_OVERRIDE_ENABLED ? rewriteWsUrl(newTabInfo.webSocketDebuggerUrl) : newTabInfo.webSocketDebuggerUrl; } if (index < 0 || index >= pageTabs.length) { throw new Error(`Tab index ${index} out of range (0-${pageTabs.length - 1})`); } return WS_OVERRIDE_ENABLED ? rewriteWsUrl(pageTabs[index].webSocketDebuggerUrl) : pageTabs[index].webSocketDebuggerUrl; } throw new Error(`Invalid tab specifier: ${wsUrlOrIndex}`); } // Helper to make HTTP requests to Chrome on the effective port async function chromeHttp(path, method = 'GET') { const http = require('http'); return new Promise((resolve, reject) => { const options = { hostname: CHROME_DEBUG_HOST, port: effectivePort, path, method: method }; const req = http.request(options, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { if (!data) { resolve({}); return; } try { resolve(JSON.parse(data)); } catch (e) { // Some endpoints return plain text (e.g., "Target is closing") resolve({ message: data }); } }); }); req.on('error', reject); req.end(); }); } // Command: start - launch Chrome with remote debugging if (command === 'start') { const { spawn } = require('child_process'); const { existsSync } = require('fs'); const os = require('os'); // Platform-specific Chrome paths const chromePaths = { darwin: [ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium' ], linux: [ '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser' ], win32: [ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe' ] }; const platform = os.platform(); const paths = chromePaths[platform]; if (!paths) { console.error(`Unsupported platform: ${platform}`); process.exit(1); } // Find Chrome executable (CHROME_WS_BROWSER env var overrides auto-detection) const chromePath = process.env.CHROME_WS_BROWSER || paths.find(p => existsSync(p)); if (!chromePath) { console.error('Chrome not found. Searched:'); paths.forEach(p => console.error(` ${p}`)); process.exit(1); } // Launch Chrome const userDataDir = platform === 'win32' ? 'C:\\temp\\chrome-debug' : '/tmp/chrome-debug'; const chromeArgs = buildChromeArgs({ chosenPort: effectivePort, chromeUserDataDir: userDataDir, chromeHeadless: false, }); console.log(`Starting Chrome: ${chromePath}`); // Capture Chrome's stderr to a tempfile (issue #35): with stdio 'ignore', // Chrome's own complaint (sandbox, missing libs, profile lock) vanished and // every launch failure looked like an ambiguous port timeout. const { openSync, closeSync, readFileSync: readFile, unlinkSync } = require('fs'); const pathMod = require('path'); const stderrLogPath = pathMod.join(os.tmpdir(), `chrome-ws-start-${process.pid}.log`); const stderrFd = openSync(stderrLogPath, 'w'); const chrome = spawn(chromePath, chromeArgs, { detached: true, stdio: ['ignore', 'ignore', stderrFd] }); // Race Chrome's exit against the port poll so "Chrome died at launch" is // reported as that, not as "debug port not accessible". let exited = null; chrome.on('exit', (code, signal) => { exited = { code, signal }; }); chrome.on('error', (err) => { console.error(`Failed to launch Chrome (${chromePath}): ${err.message}`); process.exit(1); }); chrome.unref(); closeSync(stderrFd); // the child keeps its own descriptor const stderrTail = () => { try { const lines = readFile(stderrLogPath, 'utf8').trim().split('\n'); return lines.slice(-20).join('\n').trim(); } catch (_e) { return ''; } }; const debugBase = `http://${CHROME_DEBUG_HOST}:${effectivePort}`; // Wait and verify setTimeout(async () => { try { const version = await chromeHttp('/json/version'); console.log(`Chrome started: ${version.Browser}`); console.log(`Remote debugging: ${debugBase}`); try { unlinkSync(stderrLogPath); } catch (_e) { /* best-effort */ } } catch (e) { if (exited) { const how = exited.signal ? `signal ${exited.signal}` : `code ${exited.code}`; console.error(`Chrome exited with ${how} before opening the debug port`); } else { console.error(`Chrome is running but the debug port at ${debugBase} is not responding`); console.error(`Try: curl ${debugBase}/json/version`); } const tail = stderrTail(); if (tail) { console.error(`Chrome stderr (full log: ${stderrLogPath}):`); console.error(tail); } process.exit(1); } }, 2000); return; } // Command: stop - kill the Chrome process this session manages if (command === 'stop') { (async () => { try { await session.killChrome(); console.log('Chrome stopped'); } catch (e) { console.error('Failed to stop Chrome:', e.message); process.exit(1); } })(); return; } // Command: pid - print Chrome PID (for X11 window management, etc.) if (command === 'pid') { const pid = session.getChromePid(); if (pid === null) { console.error('Chrome is not running (started via MCP). PID is only available when Chrome was started in this process.'); process.exit(1); } console.log(pid); return; } // Command: info - print Chrome info as JSON (pid, mode, profile, port) if (command === 'info') { (async () => { try { const mode = await session.getBrowserMode(); // Also try to get PID from meta.json if available const meta = session.readProfileMeta ? session.readProfileMeta(mode.profile) : null; const info = { pid: meta ? meta.pid : mode.pid, port: meta ? meta.port : mode.port, mode: meta ? (meta.headless ? 'headless' : 'headed') : mode.mode, profile: mode.profile, profileDir: mode.profileDir, running: meta !== null }; console.log(JSON.stringify(info, null, 2)); } catch (e) { console.error('Failed to get Chrome info:', e.message); process.exit(1); } })(); return; } // Command: tabs - list all tabs if (command === 'tabs') { (async () => { try { const tabs = await chromeHttp('/json'); tabs.forEach(tab => { if (tab.type === 'page') { console.log(`${tab.id}\t${tab.url}\t${tab.title}`); } }); } catch (e) { console.error('Failed to list tabs:', e.message); process.exit(1); } })(); return; } // Command: new - create new tab if (command === 'new') { // For this command, wsUrlOrIndex variable contains the URL parameter if (!wsUrlOrIndex) { console.error('Usage: chrome-ws new <url>'); process.exit(1); } const url = wsUrlOrIndex; (async () => { try { const encoded = encodeURIComponent(url); const tab = await chromeHttp(`/json/new?${encoded}`, 'PUT'); const wsUrl = WS_OVERRIDE_ENABLED ? rewriteWsUrl(tab.webSocketDebuggerUrl) : tab.webSocketDebuggerUrl; console.log(wsUrl); } catch (e) { console.error('Failed to create tab:', e.message); process.exit(1); } })(); return; } // Command: close - close tab by ws URL or numeric index if (command === 'close') { if (!wsUrlOrIndex) { console.error('Usage: chrome-ws close <tab>'); process.exit(1); } (async () => { try { const tabWsUrl = await resolveWsUrl(wsUrlOrIndex); // Extract tab ID from ws URL const match = tabWsUrl.match(/\/devtools\/page\/([A-F0-9-]+)/i); if (!match) { console.error('Invalid WebSocket URL'); process.exit(1); } await chromeHttp(`/json/close/${match[1]}`); console.log('Tab closed'); } catch (e) { console.error('Failed to close tab:', e.message); process.exit(1); } })(); return; } // Helper to send CDP command via WebSocket async function sendCdpCommand(wsUrl, method, params = {}) { return new Promise(async (resolve, reject) => { const ws = new WebSocketClient(wsUrl); const id = Math.floor(Math.random() * 1000000); const timeout = setTimeout(() => { ws.close(); reject(new Error('Timeout after 30s')); }, 30000); ws.on('message', (data) => { const response = JSON.parse(data); if (response.id === id) { clearTimeout(timeout); if (response.error) { ws.close(); reject(new Error(response.error.message)); } else { ws.close(); resolve(response.result); } } }); ws.on('error', (err) => { clearTimeout(timeout); reject(err); }); try { await ws.connect(); ws.send(JSON.stringify({ id, method, params })); } catch (err) { clearTimeout(timeout); reject(err); } }); } // Command: navigate if (command === 'navigate') { const [url] = args; if (!wsUrlOrIndex || !url) { console.error('Usage: chrome-ws navigate <tab-index-or-ws-url> <url>'); process.exit(1); } (async () => { try { const wsUrl = await resolveWsUrl(wsUrlOrIndex); await sendCdpCommand(wsUrl, 'Page.navigate', { url }); console.log(`Navigated to ${url}`); } catch (e) { console.error('Navigation failed:', e.message); process.exit(1); } })(); return; } // Command: wait-for - wait for selector to appear if (command === 'wait-for') { const [selector, timeoutArg] = args; if (!wsUrlOrIndex || !selector) { console.error('Usage: chrome-ws wait-for <tab-index-or-ws-url> <selector> [timeout-ms]'); process.exit(1); } const timeout = timeoutArg ? parseInt(timeoutArg, 10) : 5000; if (Number.isNaN(timeout) || timeout < 0) { console.error(`Invalid timeout: ${timeoutArg}`); process.exit(1); } (async () => { try { await session.waitForElement(resolveTabArg(wsUrlOrIndex), selector, timeout); console.log(`Element found: ${selector}`); process.exit(0); } catch (e) { console.error('Wait failed:', e.message); process.exit(1); } })(); return; } // Command: click if (command === 'click') { const [selector] = args; if (!wsUrlOrIndex || !selector) { console.error('Usage: chrome-ws click <tab-index-or-ws-url> <selector>'); process.exit(1); } (async () => { try { const wsUrl = await resolveWsUrl(wsUrlOrIndex); const js = `document.querySelector(${JSON.stringify(selector)}).click()`; await sendCdpCommand(wsUrl, 'Runtime.evaluate', { expression: js }); console.log(`Clicked: ${selector}`); } catch (e) { console.error('Click failed:', e.message); process.exit(1); } })(); return; } // Command: fill if (command === 'fill') { const [selector, value] = args; if (!wsUrlOrIndex || !selector || value === undefined) { console.error('Usage: chrome-ws fill <tab-index-or-ws-url> <selector> <value>'); process.exit(1); } (async () => { try { await session.fill(resolveTabArg(wsUrlOrIndex), selector, value); console.log(`Filled: ${selector}`); process.exit(0); } catch (e) { console.error('Fill failed:', e.message); process.exit(1); } })(); return; } // Command: select - select dropdown option if (command === 'select') { const [selector, value] = args; if (!wsUrlOrIndex || !selector || value === undefined) { console.error('Usage: chrome-ws select <tab-index-or-ws-url> <selector> <value-or-label-or-json-array>'); process.exit(1); } (async () => { try { // Accept JSON array (multi-select) or plain string (value or label). let selectValue = value; if (typeof value === 'string' && value.trim().startsWith('[')) { try { const parsed = JSON.parse(value); if (Array.isArray(parsed) && parsed.every(v => typeof v === 'string')) { selectValue = parsed; } } catch (_e) { /* not JSON, treat as plain string */ } } const result = await session.selectOption(resolveTabArg(wsUrlOrIndex), selector, selectValue); console.log(JSON.stringify(result.matched.map(o => o.value))); process.exit(0); } catch (e) { console.error('Select failed:', e.message); process.exit(1); } })(); return; } // Command: eval - evaluate JavaScript if (command === 'eval') { const expression = args.join(' '); if (!wsUrlOrIndex || !expression) { console.error('Usage: chrome-ws eval <tab-index-or-ws-url> <js-expression>'); process.exit(1); } (async () => { try { const value = await session.evaluate(resolveTabArg(wsUrlOrIndex), expression); console.log(JSON.stringify(value, null, 2)); process.exit(0); } catch (e) { console.error('Eval failed:', e.message); process.exit(1); } })(); return; } // Command: extract - get element text content if (command === 'extract') { const [selector] = args; if (!wsUrlOrIndex || !selector) { console.error('Usage: chrome-ws extract <tab-index-or-ws-url> <selector>'); process.exit(1); } (async () => { try { const wsUrl = await resolveWsUrl(wsUrlOrIndex); const js = `document.querySelector(${JSON.stringify(selector)})?.textContent`; const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', { expression: js, returnByValue: true }); console.log(result.result.value); } catch (e) { console.error('Extract failed:', e.message); process.exit(1); } })(); return; } // Command: attr - get element attribute if (command === 'attr') { const [selector, attrName] = args; if (!wsUrlOrIndex || !selector || !attrName) { console.error('Usage: chrome-ws attr <tab-index-or-ws-url> <selector> <attribute>'); process.exit(1); } (async () => { try { const wsUrl = await resolveWsUrl(wsUrlOrIndex); const js = `document.querySelector(${JSON.stringify(selector)})?.getAttribute(${JSON.stringify(attrName)})`; const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', { expression: js, returnByValue: true }); console.log(result.result.value); } catch (e) { console.error('Attr failed:', e.message); process.exit(1); } })(); return; } // Command: html - get HTML content if (command === 'html') { const [selector] = args; if (!wsUrlOrIndex) { console.error('Usage: chrome-ws html <tab-index-or-ws-url> [selector]'); process.exit(1); } (async () => { try { const wsUrl = await resolveWsUrl(wsUrlOrIndex); const js = selector ? `document.querySelector(${JSON.stringify(selector)})?.innerHTML` : 'document.documentElement.outerHTML'; const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', { expression: js, returnByValue: true }); console.log(result.result.value); } catch (e) { console.error('HTML failed:', e.message); process.exit(1); } })(); return; } // Command: wait-text - wait for text to appear if (command === 'wait-text') { // Last positional arg is treated as timeout if it parses as a non-negative // integer; otherwise everything is text. This handles both: // wait-text 0 "the text" 3000 // wait-text 0 "text without timeout" if (!wsUrlOrIndex || args.length === 0) { console.error('Usage: chrome-ws wait-text <tab-index-or-ws-url> <text> [timeout-ms]'); process.exit(1); } let textArgs = args; let timeout = 5000; const last = args[args.length - 1]; const parsedLast = parseInt(last, 10); if (args.length >= 2 && Number.isFinite(parsedLast) && parsedLast >= 0 && String(parsedLast) === last.trim()) { timeout = parsedLast; textArgs = args.slice(0, -1); } const text = textArgs.join(' '); if (!text) { console.error('Usage: chrome-ws wait-text <tab-index-or-ws-url> <text> [timeout-ms]'); process.exit(1); } (async () => { try { await session.waitForText(resolveTabArg(wsUrlOrIndex), text, timeout); console.log(`Text found: ${text}`); process.exit(0); } catch (e) { console.error('Wait failed:', e.message); process.exit(1); } })(); return; } // Command: screenshot - capture screenshot if (command === 'screenshot') { const fullPage = args.includes('--fullpage'); const cleanArgs = args.filter(a => a !== '--fullpage'); const [filename] = cleanArgs; if (!wsUrlOrIndex || !filename) { console.error('Usage: chrome-ws screenshot <tab-index-or-ws-url> <filename.png> [--fullpage]'); process.exit(1); } (async () => { try { const savedPath = await session.screenshot(resolveTabArg(wsUrlOrIndex), filename, null, fullPage); console.log(`Screenshot saved to ${savedPath}`); process.exit(0); } catch (e) { console.error('Screenshot failed:', e.message); process.exit(1); } })(); return; } // Command: markdown - save page as markdown if (command === 'markdown') { const [filename] = args; if (!wsUrlOrIndex || !filename) { console.error('Usage: chrome-ws markdown <tab-index-or-ws-url> <filename.md>'); process.exit(1); } (async () => { try { const wsUrl = await resolveWsUrl(wsUrlOrIndex); // Extract page content intelligently const js = ` (() => { const title = document.title; const url = window.location.href; // Get main content (try article, main, or body) let content = document.querySelector('article') || document.querySelector('main') || document.body; // Convert to markdown-ish text function nodeToMarkdown(node, level = 0) { let md = ''; if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent.trim(); return text ? text + ' ' : ''; } if (node.nodeType !== Node.ELEMENT_NODE) return ''; const tag = node.tagName.toLowerCase(); // Headers if (/^h[1-6]$/.test(tag)) { const hLevel = parseInt(tag[1]); md += '\\n' + '#'.repeat(hLevel) + ' ' + node.textContent.trim() + '\\n\\n'; return md; } // Paragraphs if (tag === 'p') { md += node.textContent.trim() + '\\n\\n'; return md; } // Links if (tag === 'a') { const href = node.getAttribute('href') || ''; const text = node.textContent.trim(); return \`[\${text}](\${href}) \`; } // Lists if (tag === 'li') { return '- ' + node.textContent.trim() + '\\n'; } // Code if (tag === 'code' || tag === 'pre') { return '\`' + node.textContent.trim() + '\` '; } // Recurse for other elements for (const child of node.childNodes) { md += nodeToMarkdown(child, level + 1); } if (tag === 'div' || tag === 'section') md += '\\n'; return md; } const markdown = nodeToMarkdown(content); return \`# \${title}\\n\\nSource: \${url}\\n\\n\${markdown}\`; })() `; const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', { expression: js, returnByValue: true }); const fs = require('fs'); fs.writeFileSync(filename, result.result.value); console.log(`Markdown saved to ${filename}`); } catch (e) { console.error('Markdown conversion failed:', e.message); process.exit(1); } })(); return; } // Command: har - save network traffic as HAR if (command === 'har') { const [filename] = args; if (!wsUrlOrIndex || !filename) { console.error('Usage: chrome-ws har <tab-index-or-ws-url> <filename.har>'); console.error('Note: Start recording with "chrome-ws har-start <tab>" first'); process.exit(1); } (async () => { try { const wsUrl = await resolveWsUrl(wsUrlOrIndex); // Get HAR data const js = `window.__chrome_ws_har__ || []`; const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', { expression: js, returnByValue: true }); const har = { log: { version: '1.2', creator: { name: 'chrome-ws', version: '1.0.0' }, entries: result.result.value || [] } }; const fs = require('fs'); fs.writeFileSync(filename, JSON.stringify(har, null, 2)); console.log(`HAR saved to ${filename} (${har.log.entries.length} entries)`); } catch (e) { console.error('HAR export failed:', e.message); process.exit(1); } })(); return; } // Past all the named-command dispatches without a return → either it's the // raw escape hatch or it's a typo. Separate those cases so users get an // actionable error instead of a confusing "Usage: chrome-ws raw ..." banner. if (command !== 'raw') { console.error(`Unknown command: ${command}`); console.error(`Run 'chrome-ws --help' for the list of commands.`); process.exit(1); } if (!wsUrlOrIndex || args.length === 0) { console.error('Usage: chrome-ws raw <tab-index-or-ws-url> <json-rpc-payload>'); process.exit(1); } const payload = args.join(' '); let message; try { message = JSON.parse(payload); } catch (e) { console.error('Invalid JSON payload:', e.message); process.exit(1); } // For raw command, wsUrlOrIndex must be a full WebSocket URL (not an index) // since this is the low-level escape hatch if (!wsUrlOrIndex.startsWith('ws://')) { console.error('raw command requires full WebSocket URL, not tab index'); console.error('Use: chrome-ws tabs # to get WebSocket URLs'); process.exit(1); } (async () => { const ws = new WebSocketClient(wsUrlOrIndex); const timeout = setTimeout(() => { console.error('Timeout after 30s'); ws.close(); process.exit(1); }, 30000); ws.on('message', (data) => { const response = JSON.parse(data); if (response.id === message.id) { clearTimeout(timeout); console.log(JSON.stringify(response, null, 2)); ws.close(); process.exit(0); } }); ws.on('error', (error) => { clearTimeout(timeout); console.error('WebSocket error:', error.message); process.exit(1); }); try { await ws.connect(); ws.send(JSON.stringify(message)); } catch (err) { clearTimeout(timeout); console.error('Connection failed:', err.message); process.exit(1); } })();skills/browsing/chrome-ws-lib.js19 KB07650226d27dupstream ↗
/** * Chrome WebSocket Library - Core CDP automation functions * Used by both CLI and MCP server * * Fixes implemented: * - JRV-130: Connection pooling for persistent focus * - JRV-127: keyboard_press action for special keys * - JRV-123: React-compatible input via Input.insertText * - JRV-124: React-compatible click via Input.dispatchMouseEvent * - JRV-125: Tab key handling (via keyboard_press) * - JRV-126: Better eval return handling * - JRV-128: SPA navigation support * - JRV-129: Multi-element selector warnings */ const { getElementSelector } = require('./lib/element-selector'); const { KEY_DEFINITIONS } = require('./lib/key-definitions'); const { generateHtmlDiff } = require('./lib/html-diff'); const { createState } = require('./lib/session-state'); const { attachCookies } = require('./lib/cookies'); const { attachViewport } = require('./lib/viewport'); const { attachEvaluation } = require('./lib/evaluation'); const { attachMouse } = require('./lib/mouse'); const { attachChromeProcess } = require('./lib/chrome-process'); const { attachCapture } = require('./lib/capture'); const { attachNavigation } = require('./lib/navigation'); const { attachKeyboardInput } = require('./lib/keyboard-input'); const { attachExtraction } = require('./lib/extraction'); const { attachScreenshot } = require('./lib/screenshot'); const { attachTabs, createPageSessionResolver } = require('./lib/tabs'); const { createBrowserSession } = require('./lib/browser-session'); const { attachBrowserBridge } = require('./lib/browser-bridge'); const { attachFileUpload } = require('./lib/file-upload'); const { attachConsoleLogging } = require('./lib/console-logging'); const { attachSelectOption } = require('./lib/select-option'); const { attachSetAttribute } = require('./lib/set-attribute'); const { attachDialogs, DialogRefusedError } = require('./lib/dialogs'); const { renderSyntheticArtifacts } = require('./lib/dialogs-render'); const { getXdgCacheHome, getChromeProfileDir, getProfileMetaPath, readProfileMeta, writeProfileMeta, clearProfileMeta, findAvailablePort, buildChromeArgs, } = require('./lib/chrome-launcher-helpers'); /** * Session methods whose CDP work targets the page (tab) target. * When a native browser dialog is open, these methods will wedge waiting for a * CDP response that never arrives because the dialog blocks the JS runtime. * The session-boundary wrapper below refuses them with a descriptive error * rather than hanging until timeout. * * Browser-target methods (getTabs, newTab, closeTab, startChrome, …) are NOT * listed here — they route through the browser target and work fine while a * dialog is open. */ const PAGE_TARGET_SESSION_METHODS = new Set([ 'navigate', 'back', 'forward', 'click', 'fill', 'selectOption', 'evaluate', 'extractText', 'getHtml', 'getSanitizedHtml', 'getAttribute', 'setAttribute', 'extractPageText', 'waitForElement', 'waitForText', 'screenshot', 'hover', 'drag', 'mouseMove', 'scroll', 'doubleClick', 'rightClick', 'humanType', 'fileUpload', 'keyboardPress', 'clickWithCapture', 'fillWithCapture', 'selectOptionWithCapture', 'evaluateWithCapture', 'setAttributeWithCapture', 'screenshotUnlessCredentialShaped', 'pageTextReadRefused', // captureActionWithDiff is intentionally excluded: it is a meta-wrapper whose // second arg is an action-type string ('type', 'click', …), not a selector. // The inner actions it wraps (humanType, click, hover, etc.) are individually // listed above and each have their own dialog gating via // withDialogAwarenessForSession in capture.js. Re-gating the wrapper at this // boundary would cause it to refuse dialog::* selectors before the inner action // ever sees them (scenario 10C basic-auth typing bug). 'setViewport', 'clearViewport', 'getViewport', ]); /** * Build a fresh Chrome session — a state-bag scoped to a single Chrome target. * * Pre-factory, every consumer that required this file shared module-level * state: the connection pool, console-message buffers, the chosen profile * name, the launched Chrome process handle, the active CDP port, and the * host-override config. Two consumers in the same process therefore drove a * single Chrome — fine for the CLI and the MCP server (each owns its * process), but a hazard for any caller that wants to drive multiple Chromes * concurrently from one Node process (different ports, different profiles). * * `createSession({ host, port })` returns a fresh instance with private state * and methods bound to that state. Two instances do not share a connection * pool, console-message map, profile, Chrome process, or host-override — * mutating one (e.g. setProfileName, startChrome) has no effect on the other. * Pass `host`/`port` to seed the host-override; omit them to seed from the * `CHROME_WS_HOST` / `CHROME_WS_PORT` env vars exactly as before. * * The returned object preserves the legacy module-level export shape — the * one-line consumer migration is `require(...)` becomes * `require(...).createSession()`. */ function createSession({ host, port, _testFakes } = {}) { const state = createState({ host, port }); // ============================================================================= const dialogs = attachDialogs({ state }); const { chromeHttp, resolveWsUrl, getTabs, newTab, closeTab } = attachTabs({ state }); // Bridge primitives — single root WebSocket with flatten-mode page sessions. // The browser-session is constructed immediately (lazy connect on first use). // attachBrowserBridge issues Target.setDiscoverTargets which connects the root // WS, so we defer it behind state.ensureBridge() (lazy). const effectiveChromeHttp = (_testFakes && _testFakes.chromeHttp) ? _testFakes.chromeHttp : chromeHttp; const browserSessionFactory = () => createBrowserSession({ host: state.hostOverride.getHost(), port: state.hostOverride.getPort(), rewriteWsUrl: state.rewriteWsUrl, chromeHttp: effectiveChromeHttp, WebSocketClient: _testFakes && _testFakes.WebSocketClient, }); state.browserSession = browserSessionFactory(); let bridgePromise = null; // Reset all bridge-layer state so the next ensureBridge() call re-attaches from // scratch. Called by killChrome (explicit kill) and ensureBridge (stale detection). // Does NOT call detach on cached pageSessions — the underlying WebSocket is // already dead at call time, so detach would fail. Use resolver.release() per-tab // before calling resetBridge if graceful cleanup is possible. state.resetBridge = () => { if (state.pageSessionResolver) { state.pageSessionResolver.releaseAll(); } state.pageSessionResolver = null; state.browserBridge = null; state.browserSession = browserSessionFactory(); bridgePromise = null; }; state.ensureBridge = () => { // Detect stale bridge: if the cached browserSession is no longer connected, // reset everything so we re-attach to the restarted Chrome process. if (state.browserBridge && state.browserSession && !state.browserSession.isConnected()) { state.resetBridge(); } if (state.browserBridge) return Promise.resolve(state.browserBridge); if (bridgePromise) return bridgePromise; bridgePromise = (async () => { const bridge = await attachBrowserBridge({ browser: state.browserSession, host: state.hostOverride.getHost(), port: state.hostOverride.getPort(), rewriteWsUrl: state.rewriteWsUrl, autoAttach: true, onPageSession: async (ps) => { // Install dialog shim before the paused target resumes. // This gives popups, OAuth windows, and child frames dialog // handling from their very first script. try { await dialogs.attachToPageSession(ps); } catch (e) { console.error('onPageSession dialog attach failed:', e); } // Prime the pageSession resolver cache so subsequent getPageSession(popup) // calls return THIS session rather than issuing a duplicate Target.attachToTarget. // The dialog is registered under THIS session's sessionId; agent commands // must route through the same session to handle the dialog. if (state.pageSessionResolver && ps.targetId) { state.pageSessionResolver.prime(ps.targetId, ps); } }, }); state.browserBridge = bridge; state.pageSessionResolver = createPageSessionResolver({ bridge }); return bridge; })(); // Clear bridgePromise on failure so the next call retries bridgePromise.catch(() => { bridgePromise = null; }); return bridgePromise; }; // getPageSession(tabIndexOrWsUrl) — shared resolver for pageSession-migrated libs. // Accepts either a numeric tab index or a ws:// URL, lazy-boots the bridge, and // returns a cached pageSession for the target. Reused by E2-E13 migration libs. async function getPageSession(tabIndexOrWsUrl) { await state.ensureBridge(); let tab; if (typeof tabIndexOrWsUrl === 'number') { const tabs = await getTabs(); tab = tabs[tabIndexOrWsUrl]; if (!tab) throw new Error(`No tab at index ${tabIndexOrWsUrl}`); } else if (typeof tabIndexOrWsUrl === 'string') { // Extract targetId from a ws URL like ws://host:port/devtools/page/<targetId> const m = /\/devtools\/page\/([^/]+)$/.exec(tabIndexOrWsUrl); if (!m) throw new Error(`Cannot extract targetId from: ${tabIndexOrWsUrl}`); tab = { id: m[1] }; } else if (tabIndexOrWsUrl && tabIndexOrWsUrl.id) { // Already a tab handle tab = tabIndexOrWsUrl; } else { throw new Error('Unrecognized tabIndexOrWsUrl'); } const ps = await state.pageSessionResolver(tab); // Ensure dialog event listeners are wired up on the bridge session the first // time a page session is obtained. This enables Page.javascriptDialogOpening // events to arrive via the bridge path (stored under sessionId). await dialogs.attachToPageSession(ps); return ps; } const { click, hover, drag, mouseMove, scroll, doubleClick, rightClick } = attachMouse({ getPageSession, dialogs }); const { keyboardPress, fill, humanType } = attachKeyboardInput({ state, getPageSession, click, dialogs }); const { fileUpload } = attachFileUpload({ getPageSession }); const { selectOption } = attachSelectOption({ getPageSession }); const { setAttribute } = attachSetAttribute({ getPageSession }); const { evaluate } = attachEvaluation({ getPageSession }); // ============================================================================= const { extractText, getHtml, getSanitizedHtml, getAttribute } = attachExtraction({ getPageSession }); // getSessionDir is a lazy thunk: capture.js populates state.sessionDir via // initializeSession(). We close over `state` so screenshot.js always reads // the freshly-set value. If no capture has happened yet, we delegate to // captureInitializer (set below after attachCapture) to create the dir. // The ref itself must live before attachScreenshot and attachCapture, but the // actual initializeSession function is injected after attachCapture runs. const screenshotDirRef = { initializeSession: null }; const { screenshot } = attachScreenshot({ getPageSession, state, initializeSession: () => { if (screenshotDirRef.initializeSession) return screenshotDirRef.initializeSession(); // Fallback if called before attachCapture (shouldn't happen in normal flow). if (state.sessionDir) return state.sessionDir; throw new Error('Session directory not yet initialized. Call an auto-capture action first.'); }, }); const { startChrome, killChrome, showBrowser, hideBrowser, getBrowserMode, getChromePid, getActivePort, getProfileName, setProfileName } = attachChromeProcess({ state, chromeHttp, getTabs, newTab }); const { enableConsoleLogging, getConsoleMessages, clearConsoleMessages } = attachConsoleLogging({ state, getPageSession }); const { initializeSession, cleanupSession, createCapturePrefix, generateDomSummary, getPageSize, generateMarkdown, capturePageArtifacts, captureActionWithDiff, screenshotUnlessCredentialShaped, clickWithCapture, fillWithCapture, selectOptionWithCapture, evaluateWithCapture, extractPageText, setAttributeWithCapture, getPageUrl, pauseCapture, resumeCapture, isCapturePaused, pageTextReadRefused, } = attachCapture({ state, getPageSession, getHtml, screenshot, actions: { click, fill, selectOption, evaluate, setAttribute }, dialogs, }); // Wire the forward reference so screenshot.js can call initializeSession. screenshotDirRef.initializeSession = initializeSession; const { navigate, waitForElement, waitForText, back, forward } = attachNavigation({ state, getPageSession, capturePageArtifacts, evaluate }); const { setViewport, clearViewport, getViewport } = attachViewport({ getPageSession }); const { clearCookies } = attachCookies({ getPageSession }); // --------------------------------------------------------------------------- // Session-boundary dialog gate // // Wraps every page-target method so that any call issued while a native dialog // is open returns a structured refusal instead of hanging until a CDP timeout. // // Convention (mirrors all other page-target methods in this library): // fn(tabIndexOrWsUrl, selectorOrArg, ...rest) // // If the second argument is a string beginning with "dialog::", it is a // dialog-selector call (e.g. click("dialog::accept")) and must be allowed // through so the existing internal routers in mouse.js and keyboard-input.js // can handle it. // --------------------------------------------------------------------------- function wrapWithDialogGate(_name, fn) { return async function dialogGated(tabIndexOrWsUrl, secondArg, ...rest) { // Resolve the ws URL so we can look up dialog state. // resolveWsUrl may throw (e.g., no Chrome running) — let it propagate // naturally; that's not a dialog problem. let wsUrl; try { wsUrl = await resolveWsUrl(tabIndexOrWsUrl); } catch { // Can't resolve the URL — delegate and let the method surface the error. return fn(tabIndexOrWsUrl, secondArg, ...rest); } // Look up dialog state keyed by sessionId (via targetId→sessionId map populated // by attachToPageSession). dialogs.getOpen() handles both direct sessionId keys // and wsUrl paths by extracting the targetId from the URL. const open = dialogs.getOpen(wsUrl); const isDialogSelector = typeof secondArg === 'string' && secondArg.startsWith('dialog::'); if (open && !isDialogSelector) { // Pause-aware (same rationale as dialogs.js's withDialogAwareness/ // withDialogAwarenessForSession): this session-boundary gate wraps // EVERY PAGE_TARGET_SESSION_METHODS entry, including clickWithCapture // et al, and runs BEFORE those methods' own dialog-aware code ever // sees the call -- so it is a SEPARATE place the same leak can // happen, not a redundant one. While state.capturePaused is set, // narrow `dialog` to {kind} and never render the message/ // defaultPrompt text at all. if (state.capturePaused) { throw new DialogRefusedError({ dialog: { kind: open.kind }, artifacts: null }); } throw new DialogRefusedError({ dialog: open, artifacts: renderSyntheticArtifacts(open) }); } return fn(tabIndexOrWsUrl, secondArg, ...rest); }; } // Build the raw session object, then wrap page-target methods. const rawSession = { // State bag (exposed for bridge consumers and testing) state, // Internal helpers (exported for testing) getElementSelector, // Core browser actions (click/fill now use CDP events by default for React compatibility) getTabs, newTab, closeTab, navigate, click, // Uses CDP mouse events, falls back to el.click() fill, // Uses CDP insertText, falls back to el.value= selectOption, // Warns if selector matches multiple elements evaluate, extractText, getHtml, getSanitizedHtml, getAttribute, setAttribute, waitForElement, waitForText, back, forward, screenshot, // Mouse actions (CDP-level, bypasses synthetic event restrictions) hover, // Move mouse over element (CSS :hover, tooltips) drag, // Drag-and-drop via native mouse event sequence mouseMove, // Raw coordinate mouse movement scroll, // Mouse wheel scrolling doubleClick, // Double-click with dblclick event rightClick, // Right-click with contextmenu event // Human-like typing (individual keyDown/keyUp with realistic timing) humanType, // File upload (DOM.setFileInputFiles — can't be done via JS) fileUpload, // Keyboard support for special keys (Tab, Enter, Escape, Arrow keys, etc.) keyboardPress, KEY_DEFINITIONS, // Chrome lifecycle startChrome, buildChromeArgs, killChrome, showBrowser, hideBrowser, getBrowserMode, getChromePid, // Profile management getChromeProfileDir, getProfileName, setProfileName, // Console logging enableConsoleLogging, getConsoleMessages, clearConsoleMessages, // Session management getXdgCacheHome, initializeSession, cleanupSession, createCapturePrefix, // Auto-capture utilities generateDomSummary, getPageSize, generateMarkdown, capturePageArtifacts, clickWithCapture, fillWithCapture, selectOptionWithCapture, evaluateWithCapture, extractPageText, setAttributeWithCapture, // DOM diff capture (before/after with diff) generateHtmlDiff, captureActionWithDiff, // Credential-shaped content guard (see lib/credential-guard.js) screenshotUnlessCredentialShaped, getPageUrl, // PRI-3360: whole-page text-read gate (sensitive-url.js's // pageTextReadRefused), exposed at tabIndex granularity for // mcp/src/index.ts's EXTRACT markdown branch. pageTextReadRefused, // Pause switch for automatic captures (see lib/capture-pause.js) pauseCapture, resumeCapture, isCapturePaused, // Dynamic port allocation and per-profile meta.json getActivePort, findAvailablePort, getProfileMetaPath, readProfileMeta, writeProfileMeta, clearProfileMeta, // Viewport/device emulation setViewport, clearViewport, getViewport, // Cookie management clearCookies, // Dialog awareness dialogs, }; // Apply the session-boundary dialog gate to every page-target method. for (const name of PAGE_TARGET_SESSION_METHODS) { if (typeof rawSession[name] === 'function') { rawSession[name] = wrapWithDialogGate(name, rawSession[name]); } } return rawSession; } module.exports = { createSession, PAGE_TARGET_SESSION_METHODS, DialogRefusedError };skills/browsing/COMMANDLINE-USAGE.md14 KB97598ab63da5upstream ↗
# Command-Line Usage: chrome-ws Tool Direct command-line access to Chrome DevTools Protocol via the `chrome-ws` bash tool. **Note**: For use within Claude Code, the MCP `use_browser` tool is recommended. This document is for direct command-line usage or integration with other tools. ## Setup ```bash cd ~/.claude/plugins/cache/using-chrome-directly/skills/using-chrome-directly chmod +x chrome-ws ./chrome-ws start # Auto-detects platform, launches Chrome ./chrome-ws tabs # Verify running ``` Chrome starts headed with `--remote-debugging-port=9222`, a separate profile in `/tmp/chrome-debug` (or `C:\temp\chrome-debug` on Windows), and the same automation flag set the MCP launch path uses. ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `CHROME_WS_BROWSER` | (auto-detect) | Path to browser executable. Overrides auto-detection. | | `CHROME_WS_HOST` | `127.0.0.1` | Debug host address | | `CHROME_WS_PORT` | `9222` | Debug port number | | `CHROME_EXTRA_ARGS` | (none) | Whitespace-separated extra Chrome flags appended at launch. Needed on headless Linux / as root, e.g. `--no-sandbox --headless=new --disable-dev-shm-usage`. | | `CHROME_WS_PROFILE` | (auto) | Profile name. Default is `superpowers-chrome`; if another live process holds that profile's lock, the CLI / MCP falls through to `superpowers-chrome-2`, `-3`, etc. Set this to opt out — explicit names always claim the named profile (sharing with whoever else has it). | **Examples:** ```bash # Force Chromium instead of Chrome CHROME_WS_BROWSER=/usr/bin/chromium ./chrome-ws start # Use custom port CHROME_WS_PORT=9333 ./chrome-ws start # Use Brave browser CHROME_WS_BROWSER="/usr/bin/brave-browser" ./chrome-ws start ``` ## Command Reference **Lifecycle:** ```bash chrome-ws start [port] # Launch Chrome (auto-detects platform) chrome-ws stop # Kill Chrome chrome-ws pid # Print Chrome PID chrome-ws info # Print Chrome info (JSON: pid, port, mode, profile, profileDir, running) chrome-ws --help # Show usage chrome-ws --version # Print version ``` `chrome-ws --port=N <command>` overrides `CHROME_WS_PORT` for a single invocation. Unknown commands now print `Unknown command: <name>` and point at `--help` instead of the raw-specific usage banner. **Tab Management:** ```bash chrome-ws tabs # List tabs as TSV (id<TAB>url<TAB>title); use `info` for JSON chrome-ws new <url> # Create tab chrome-ws close <tab> # Close tab (accepts numeric index or ws-url) ``` **Navigation:** ```bash chrome-ws navigate <tab> <url> # Navigate chrome-ws wait-for <tab> <selector> # Wait for element chrome-ws wait-text <tab> <text> # Wait for text ``` **Interaction:** ```bash chrome-ws click <tab> <selector> # Click chrome-ws fill <tab> <selector> <value> # Fill input chrome-ws select <tab> <selector> <value> # Select dropdown ``` **Extraction:** ```bash chrome-ws eval <tab> <js> # Execute JavaScript chrome-ws extract <tab> <selector> # Get text content chrome-ws attr <tab> <selector> <attr> # Get attribute chrome-ws html <tab> [selector] # Get HTML ``` **Export:** ```bash chrome-ws screenshot <tab> <file.png> # Capture screenshot chrome-ws markdown <tab> <file.md> # Save as markdown ``` **Raw Protocol:** ```bash chrome-ws raw <ws-url> <json-rpc> # Direct CDP access ``` `<tab>` accepts either tab index (0, 1, 2) or full WebSocket URL. ## Examples ### Basic Operations **Extract page content:** ```bash chrome-ws navigate 0 "https://example.com" chrome-ws wait-for 0 "h1" # Get page title TITLE=$(chrome-ws eval 0 "document.title") # Get main heading HEADING=$(chrome-ws extract 0 "h1") # Get first link URL LINK=$(chrome-ws attr 0 "a" "href") ``` **Get all links:** ```bash chrome-ws navigate 0 "https://example.com" LINKS=$(chrome-ws eval 0 "Array.from(document.querySelectorAll('a')).map(a => ({ text: a.textContent.trim(), href: a.href }))") echo "$LINKS" ``` **Extract table data:** ```bash chrome-ws navigate 0 "https://example.com/data" chrome-ws wait-for 0 "table" # Convert table to JSON array TABLE=$(chrome-ws eval 0 " Array.from(document.querySelectorAll('table tr')).map(row => Array.from(row.cells).map(cell => cell.textContent.trim()) ) ") ``` ### Form Automation **Simple login:** ```bash chrome-ws navigate 0 "https://app.example.com/login" chrome-ws wait-for 0 "input[name=email]" # Fill credentials chrome-ws fill 0 "input[name=email]" "[email protected]" chrome-ws fill 0 "input[name=password]" "securepass123" # Submit and wait for dashboard chrome-ws click 0 "button[type=submit]" chrome-ws wait-text 0 "Dashboard" ``` **Multi-step form:** ```bash chrome-ws navigate 0 "https://example.com/register" # Step 1: Personal information chrome-ws fill 0 "input[name=firstName]" "John" chrome-ws fill 0 "input[name=lastName]" "Doe" chrome-ws fill 0 "input[name=email]" "[email protected]" chrome-ws click 0 "button.next" # Wait for step 2 to load chrome-ws wait-for 0 "input[name=address]" # Step 2: Address chrome-ws fill 0 "input[name=address]" "123 Main St" chrome-ws select 0 "select[name=state]" "IL" chrome-ws fill 0 "input[name=zip]" "62701" chrome-ws click 0 "button.submit" chrome-ws wait-text 0 "Registration complete" ``` **Search with filters:** ```bash chrome-ws navigate 0 "https://library.example.com/search" chrome-ws wait-for 0 "form" # Select category dropdown chrome-ws select 0 "select[name=category]" "books" # Fill search term chrome-ws fill 0 "input[name=query]" "chrome devtools" # Submit search chrome-ws click 0 "button[type=submit]" chrome-ws wait-for 0 ".results" # Count results RESULTS=$(chrome-ws eval 0 "document.querySelectorAll('.result').length") echo "Found $RESULTS results" ``` ### Web Scraping **Article content:** ```bash chrome-ws navigate 0 "https://blog.example.com/article" chrome-ws wait-for 0 "article" # Extract metadata TITLE=$(chrome-ws extract 0 "article h1") AUTHOR=$(chrome-ws extract 0 ".author-name") DATE=$(chrome-ws extract 0 "time") CONTENT=$(chrome-ws extract 0 "article .content") # Save to file cat > article.txt <<EOF Title: $TITLE Author: $AUTHOR Date: $DATE $CONTENT EOF ``` **Product information:** ```bash chrome-ws navigate 0 "https://shop.example.com/product/123" chrome-ws wait-for 0 ".product-details" NAME=$(chrome-ws extract 0 "h1.product-name") PRICE=$(chrome-ws extract 0 ".price") IMAGE=$(chrome-ws attr 0 ".product-image img" "src") STOCK=$(chrome-ws extract 0 ".stock-status") # Output as JSON cat <<EOF { "name": "$NAME", "price": "$PRICE", "image": "$IMAGE", "in_stock": "$STOCK" } EOF ``` **Batch process URLs:** ```bash URLS=("page1" "page2" "page3") for URL in "${URLS[@]}"; do chrome-ws navigate 0 "https://example.com/$URL" chrome-ws wait-for 0 "h1" TITLE=$(chrome-ws extract 0 "h1") echo "$URL: $TITLE" >> results.txt done ``` ### Multi-Tab Workflows **Email extraction:** ```bash # List all tabs chrome-ws tabs # Use the email tab index from output (e.g., tab 2) EMAIL_TAB=2 # Click specific email chrome-ws click $EMAIL_TAB "a[title*='Organization receipt']" # Wait for email to load chrome-ws wait-for $EMAIL_TAB ".email-body" # Extract donation amount AMOUNT=$(chrome-ws extract $EMAIL_TAB ".donation-amount") echo "Donation: $AMOUNT" ``` **Price comparison:** ```bash chrome-ws navigate 0 "https://store1.com/product" chrome-ws new "https://store2.com/product" chrome-ws new "https://store3.com/product" sleep 3 # Let pages load PRICE1=$(chrome-ws extract 0 ".price") PRICE2=$(chrome-ws extract 1 ".price") PRICE3=$(chrome-ws extract 2 ".price") echo "Store 1: $PRICE1" echo "Store 2: $PRICE2" echo "Store 3: $PRICE3" ``` **Cross-reference between sites:** ```bash # Get phone number from company site chrome-ws navigate 0 "https://company.com/contact" chrome-ws wait-for 0 ".phone" PHONE=$(chrome-ws extract 0 ".phone") # Look up phone number in verification site chrome-ws new "https://lookup.com" chrome-ws fill 1 "input[name=phone]" "$PHONE" chrome-ws click 1 "button.search" chrome-ws wait-for 1 ".results" chrome-ws extract 1 ".verification-status" ``` ### Dynamic Content **Wait for AJAX to complete:** ```bash chrome-ws navigate 0 "https://app.com/dashboard" # Wait for spinner to disappear chrome-ws eval 0 "new Promise(resolve => { const check = () => { if (!document.querySelector('.spinner')) { resolve(true); } else { setTimeout(check, 100); } }; check(); })" # Now safe to extract chrome-ws extract 0 ".dashboard-data" ``` **Infinite scroll:** ```bash chrome-ws navigate 0 "https://example.com/feed" chrome-ws wait-for 0 ".feed-item" # Scroll 5 times for i in {1..5}; do chrome-ws eval 0 "window.scrollTo(0, document.body.scrollHeight)" sleep 2 done # Count loaded items chrome-ws eval 0 "document.querySelectorAll('.feed-item').length" ``` **Monitor for changes:** ```bash chrome-ws navigate 0 "https://example.com/status" END=$(($(date +%s) + 300)) while [ $(date +%s) -lt $END ]; do STATUS=$(chrome-ws extract 0 ".status") echo "[$(date +%H:%M:%S)] $STATUS" if [[ "$STATUS" == *"ERROR"* ]]; then echo "ALERT: Error detected" break fi sleep 10 done ``` ### Advanced Patterns **Multi-step workflow:** ```bash chrome-ws navigate 0 "https://booking.example.com" # Search chrome-ws fill 0 "input[name=destination]" "San Francisco" chrome-ws fill 0 "input[name=checkin]" "2025-12-01" chrome-ws click 0 "button.search" # Select hotel chrome-ws wait-for 0 ".hotel-results" chrome-ws click 0 ".hotel-card:first-child .select" # Choose room chrome-ws wait-for 0 ".room-options" chrome-ws click 0 ".room[data-type=deluxe] .book" # Fill guest info chrome-ws wait-for 0 "form.guest-info" chrome-ws fill 0 "input[name=firstName]" "Jane" chrome-ws fill 0 "input[name=lastName]" "Smith" chrome-ws fill 0 "input[name=email]" "[email protected]" # Review chrome-ws click 0 "button.review" chrome-ws wait-for 0 ".summary" # Extract confirmation HOTEL=$(chrome-ws extract 0 ".hotel-name") TOTAL=$(chrome-ws extract 0 ".total-price") echo "$HOTEL: $TOTAL" ``` **Cookies and localStorage:** ```bash # Get cookies chrome-ws eval 0 "document.cookie" # Set cookie chrome-ws eval 0 "document.cookie = 'theme=dark; path=/'" # Get localStorage chrome-ws eval 0 "JSON.stringify(localStorage)" # Set localStorage chrome-ws eval 0 "localStorage.setItem('lastVisit', new Date().toISOString())" ``` **Handle modals:** ```bash chrome-ws click 0 "button.open-modal" chrome-ws wait-for 0 ".modal.visible" # Fill modal form chrome-ws fill 0 ".modal input[name=username]" "testuser" chrome-ws click 0 ".modal button.submit" # Wait for modal to close chrome-ws eval 0 "new Promise(resolve => { const check = () => { if (!document.querySelector('.modal.visible')) { resolve(true); } else { setTimeout(check, 100); } }; check(); })" ``` **Network monitoring with raw CDP:** ```bash # Enable network monitoring chrome-ws raw 0 '{"id":1,"method":"Network.enable","params":{}}' # Navigate and capture traffic chrome-ws navigate 0 "https://api.example.com" # Get performance metrics chrome-ws raw 0 '{"id":2,"method":"Performance.getMetrics","params":{}}' ``` **Screenshots and PDF:** ```bash # Capture screenshot chrome-ws screenshot 0 "page.png" # Or use raw CDP for more control SCREENSHOT=$(chrome-ws raw 0 '{ "id":1, "method":"Page.captureScreenshot", "params":{"format":"png","quality":80} }') # Extract base64 and save echo "$SCREENSHOT" | node -pe "JSON.parse(require('fs').readFileSync(0)).result.data" | base64 -d > screenshot.png ``` ## Error Handling **Check element exists:** ```bash # Verify button exists EXISTS=$(chrome-ws eval 0 "!!document.querySelector('.important-button')") if [ "$EXISTS" = "true" ]; then chrome-ws click 0 ".important-button" else echo "Button not found on page" fi ``` **Verify command success:** ```bash if ! chrome-ws navigate 0 "https://example.com"; then echo "Navigation failed - Chrome not running?" exit 1 fi ``` **Retry pattern:** ```bash for attempt in {1..3}; do if chrome-ws click 0 ".submit-button"; then echo "Click succeeded" break fi echo "Attempt $attempt failed, retrying..." sleep 2 done ``` ## Best Practices **Always wait before interaction:** ```bash # BAD - might fail if page slow to load chrome-ws navigate 0 "https://example.com" chrome-ws click 0 "button" # May fail! # GOOD - wait for element first chrome-ws navigate 0 "https://example.com" chrome-ws wait-for 0 "button" chrome-ws click 0 "button" ``` **Use specific selectors:** ```bash # BAD - matches first button on page chrome-ws click 0 "button" # GOOD - specific selector chrome-ws click 0 "button[type=submit]" chrome-ws click 0 "button.login-button" chrome-ws click 0 "#submit-form" ``` **Test selectors with html command:** ```bash # Check page structure chrome-ws html 0 | grep "submit" # Check specific element exists chrome-ws html 0 "form" ``` **Escape special characters:** ```bash # Use double quotes for variables chrome-ws fill 0 "input[name=search]" "$SEARCH_TERM" # Use single quotes for literal strings with special chars chrome-ws eval 0 'document.querySelector(".item").textContent' ``` ## Common Pitfalls **Don't cache tab indices** - they change when tabs close: ```bash # BAD - index might be stale TAB=2 # ... much later ... chrome-ws click $TAB "button" # Tab 2 might not exist anymore # GOOD - fetch fresh before use chrome-ws tabs chrome-ws click 2 "button" ``` **Don't forget to wait for dynamic content:** ```bash # BAD - tries to extract before content loads chrome-ws navigate 0 "https://app.com" chrome-ws extract 0 ".user-name" # Might be empty! # GOOD - wait for content chrome-ws navigate 0 "https://app.com" chrome-ws wait-for 0 ".user-name" chrome-ws extract 0 ".user-name" ``` **Handle element state:** ```bash # Check if button is disabled DISABLED=$(chrome-ws eval 0 "document.querySelector('button.submit').disabled") if [ "$DISABLED" = "false" ]; then chrome-ws click 0 "button.submit" else echo "Button is disabled" fi ``` ## Troubleshooting **Connection refused:** Verify Chrome running with `curl http://127.0.0.1:9222/json` **Element not found:** Check page structure with `chrome-ws html 0` **Timeout:** Use `wait-for` before interaction. Chrome has 30s timeout. **Tab index out of range:** Run `chrome-ws tabs` to get current indices. ## Protocol Reference Full CDP documentation: https://chromedevtools.github.io/devtools-protocol/ Common methods via `raw` command: - `Page.navigate` - `Runtime.evaluate` - `Network.enable` - `Performance.getMetrics`skills/browsing/EXAMPLES.md20 KB5d4da7cbe057upstream ↗
# Chrome Direct Access Examples (MCP Tool) Examples using the `use_browser` MCP tool. For command-line bash examples, see [COMMANDLINE-USAGE.md](COMMANDLINE-USAGE.md). ## Table of Contents 1. [Basic Operations](#basic-operations) 2. [Form Automation](#form-automation) 3. [Web Scraping](#web-scraping) 4. [Multi-Tab Workflows](#multi-tab-workflows) 5. [Dynamic Content](#dynamic-content) 6. [Dialogs](#dialogs) — basic-auth, JS confirm, popup-with-confirm 7. [Recovery](#recovery) — auto-restart, kill/restart cycle 8. [Multi-MCP isolation](#multi-mcp-isolation) 9. [Advanced Patterns](#advanced-patterns) --- ## Basic Operations ### Extract Page Content Navigate to a page and extract various elements: ``` {action: "navigate", payload: "https://example.com"} {action: "await_element", selector: "h1"} // Get page title {action: "eval", payload: "document.title"} // Get main heading text {action: "extract", payload: "text", selector: "h1"} // Get first link URL {action: "attr", selector: "a", payload: "href"} ``` ### Get All Links Use JavaScript evaluation to get structured data: ``` {action: "navigate", payload: "https://example.com"} {action: "eval", payload: "Array.from(document.querySelectorAll('a')).map(a => ({ text: a.textContent.trim(), href: a.href }))"} ``` ### Extract Table Data Convert HTML table to structured data: ``` {action: "navigate", payload: "https://example.com/data"} {action: "await_element", selector: "table"} // Convert table to JSON array {action: "eval", payload: "Array.from(document.querySelectorAll('table tr')).map(row => Array.from(row.cells).map(cell => cell.textContent.trim()))"} ``` ### Get Page as Markdown Extract entire page content in markdown format: ``` {action: "navigate", payload: "https://example.com"} {action: "await_element", selector: "body"} {action: "extract", payload: "markdown"} ``` --- ## Form Automation ### Simple Login Navigate, fill credentials, and submit: ``` {action: "navigate", payload: "https://app.example.com/login"} {action: "await_element", selector: "input[name=email]"} // Fill credentials {action: "type", selector: "input[name=email]", payload: "[email protected]"} {action: "type", selector: "input[name=password]", payload: "securepass123\n"} // Wait for successful login {action: "await_text", payload: "Dashboard"} ``` Note: The `\n` at the end of the password submits the form. ### Multi-Step Form Handle forms that show steps progressively: ``` {action: "navigate", payload: "https://example.com/register"} // Step 1: Personal information {action: "type", selector: "input[name=firstName]", payload: "John"} {action: "type", selector: "input[name=lastName]", payload: "Doe"} {action: "type", selector: "input[name=email]", payload: "[email protected]"} {action: "click", selector: "button.next"} // Wait for step 2 to load {action: "await_element", selector: "input[name=address]"} // Step 2: Address {action: "type", selector: "input[name=address]", payload: "123 Main St"} {action: "select", selector: "select[name=state]", payload: "IL"} {action: "type", selector: "input[name=zip]", payload: "62701"} {action: "click", selector: "button.submit"} {action: "await_text", payload: "Registration complete"} ``` ### Search with Filters Use dropdowns and text inputs together: ``` {action: "navigate", payload: "https://library.example.com/search"} {action: "await_element", selector: "form"} // Select category dropdown {action: "select", selector: "select[name=category]", payload: "books"} // Fill search term {action: "type", selector: "input[name=query]", payload: "chrome devtools"} // Submit and count results {action: "click", selector: "button[type=submit]"} {action: "await_element", selector: ".results"} // Count results {action: "eval", payload: "document.querySelectorAll('.result').length"} ``` --- ## Web Scraping ### Article Content Extract article metadata and content: ``` {action: "navigate", payload: "https://blog.example.com/article"} {action: "await_element", selector: "article"} // Extract metadata {action: "extract", payload: "text", selector: "article h1"} {action: "extract", payload: "text", selector: ".author-name"} {action: "extract", payload: "text", selector: "time"} {action: "extract", payload: "text", selector: "article .content"} ``` ### Product Information Scrape product details from e-commerce site: ``` {action: "navigate", payload: "https://shop.example.com/product/123"} {action: "await_element", selector: ".product-details"} // Extract product data {action: "extract", payload: "text", selector: "h1.product-name"} {action: "extract", payload: "text", selector: ".price"} {action: "attr", selector: ".product-image img", payload: "src"} {action: "extract", payload: "text", selector: ".stock-status"} ``` ### Batch Extract Structured Data Get multiple products at once using JavaScript: ``` {action: "navigate", payload: "https://shop.example.com/category/electronics"} {action: "await_element", selector: ".product-grid"} // Extract all products as structured data {action: "eval", payload: ` Array.from(document.querySelectorAll('.product-card')).map(card => ({ name: card.querySelector('.product-name').textContent, price: card.querySelector('.price').textContent, image: card.querySelector('img').src, url: card.querySelector('a').href })) `} ``` --- ## Multi-Tab Workflows ### Email Extraction List tabs, then switch to the correct tab and extract data: ``` // Find email tab {action: "list_tabs"} // Switch to tab 2 (from list_tabs output), then operate on active tab {action: "switch_tab", payload: 2} {action: "click", selector: "a[title*='Organization receipt']"} {action: "await_element", selector: ".email-body"} // Extract donation amount {action: "extract", payload: "text", selector: ".donation-amount"} ``` ### Price Comparison Open multiple stores and compare prices: ``` // Navigate first tab (already active) {action: "navigate", payload: "https://store1.com/product"} // Open additional tabs and navigate each {action: "new_tab"} {action: "navigate", payload: "https://store2.com/product"} {action: "new_tab"} {action: "navigate", payload: "https://store3.com/product"} // Switch back to each tab and extract prices {action: "switch_tab", payload: "store1.com"} {action: "await_element", selector: ".price"} {action: "extract", payload: "text", selector: ".price"} {action: "switch_tab", payload: "store2.com"} {action: "await_element", selector: ".price"} {action: "extract", payload: "text", selector: ".price"} {action: "switch_tab", payload: "store3.com"} {action: "await_element", selector: ".price"} {action: "extract", payload: "text", selector: ".price"} ``` ### Cross-Reference Between Sites Extract data from one site and use in another: ``` // Get phone number from company site {action: "navigate", payload: "https://company.com/contact"} {action: "await_element", selector: ".phone"} {action: "extract", payload: "text", selector: ".phone"} // Store the result, then open verification site in a new tab {action: "new_tab"} {action: "navigate", payload: "https://lookup.com"} {action: "await_element", selector: "input[name=phone]"} // Fill with extracted phone number (new tab is already active) {action: "type", selector: "input[name=phone]", payload: "<phone-from-previous-extract>"} {action: "click", selector: "button.search"} {action: "await_element", selector: ".results"} {action: "extract", payload: "text", selector: ".verification-status"} ``` --- ## Dynamic Content ### Wait for AJAX to Complete Wait for loading spinner to disappear: ``` {action: "navigate", payload: "https://app.com/dashboard"} // Wait for spinner to disappear using custom JavaScript {action: "eval", payload: ` new Promise(resolve => { const check = () => { if (!document.querySelector('.spinner')) { resolve(true); } else { setTimeout(check, 100); } }; check(); }) `} // Now safe to extract {action: "extract", payload: "text", selector: ".dashboard-data"} ``` ### Infinite Scroll Scroll to load more content: ``` {action: "navigate", payload: "https://example.com/feed"} {action: "await_element", selector: ".feed-item"} // Scroll multiple times {action: "eval", payload: "window.scrollTo(0, document.body.scrollHeight)"} {action: "await_element", selector: ".feed-item", timeout: 2000} {action: "eval", payload: "window.scrollTo(0, document.body.scrollHeight)"} {action: "await_element", selector: ".feed-item", timeout: 2000} {action: "eval", payload: "window.scrollTo(0, document.body.scrollHeight)"} {action: "await_element", selector: ".feed-item", timeout: 2000} // Count loaded items {action: "eval", payload: "document.querySelectorAll('.feed-item').length"} ``` ### Wait for Element to Become Enabled Wait for button to be clickable: ``` {action: "click", selector: "button.start"} // Wait for continue button to enable {action: "eval", payload: ` new Promise(resolve => { const check = () => { const btn = document.querySelector('button.continue'); if (btn && !btn.disabled) { resolve(true); } else { setTimeout(check, 100); } }; check(); }) `} {action: "click", selector: "button.continue"} ``` --- ## Dialogs ### HTTP basic-auth (dialog surfaces during navigate) When the page returns 401 + `WWW-Authenticate`, Chrome stages a basic-auth dialog. The bridge intercepts the `Fetch.authRequired` event, holds the navigation, and surfaces a dialog refusal — `navigate` throws with the dialog grammar in the message. The response text contains `basic-auth` and lists the `dialog::username`/`dialog::password`/`dialog::accept` selectors. ``` # Step 1: navigate fails with the dialog payload {action: "navigate", payload: "http://localhost:8766/", timeout: 15000} # (response includes "basic-auth", "dialog::username", "dialog::password") # Step 2-4: stage credentials and submit {action: "type", selector: "dialog::username", payload: "alice"} {action: "type", selector: "dialog::password", payload: "secret"} {action: "click", selector: "dialog::accept"} # Step 5: the original navigation completes; the page is now loaded {action: "extract", selector: "h1", payload: "text"} # → "hi alice" ``` ### JS confirm/alert dispatched by a click A button whose `onclick` calls `confirm()` opens a dialog as soon as the click event fires. The click itself may report a CDP timeout (Chrome pauses the main thread on the dialog); that's expected. The bridge has already populated `state.dialogs[sid]` and any subsequent page-targeted call gets refused with the dialog grammar. ``` {action: "navigate", payload: "<page with onclick=confirm('Proceed?')>"} {action: "click", selector: "#ask"} # Click times out — expected. {action: "extract", selector: "#result", payload: "text"} # Refused: response contains "Page is behind a dialog", "dialog::accept", # "dialog::dismiss", and the prompt "Proceed?". {action: "click", selector: "dialog::accept"} # Dialog accepted; state.dialogs cleared eagerly. {action: "eval", payload: "window.__userChoice"} # → true ``` ### Popup with synchronous dialog (Phase F headline case) A page that opens a popup whose first inline script calls `confirm()` works without races. The bridge attaches to the popup target via `Target.setAutoAttach({waitForDebuggerOnStart: true})`, installs the dialog shim, then resumes execution — so the synchronous confirm is observed. ``` {action: "navigate", payload: "http://localhost:8765/popup-opener.html"} {action: "click", selector: "#open"} # opens window.open('popup.html') {action: "list_tabs"} # popup is enumerated {action: "switch_tab", payload: "Popup"} # route to the popup tab {action: "extract", selector: "*", payload: "text"} # Refused with dialog grammar — the popup's confirm was caught. {action: "click", selector: "dialog::accept"} {action: "eval", payload: "window.__userChoice"} # → true ``` ## Recovery ### Chrome killed externally If something kills your Chrome (`kill -9 <pid>`, OOM killer, a user closing the headed window), the bridge auto-restarts on the next page action. The response is prefixed with a banner so you know the previous URL/tab state is gone. ``` {action: "navigate", payload: "https://example.com"} {action: "extract", selector: "h1", payload: "text"} # → "Example Domain" {action: "browser_mode"} # records pid=N # (from your shell or another process: kill -9 N) {action: "navigate", payload: "https://example.com"} # Response starts with: # [Chrome auto-restarted; URL reset to about:blank. Re-navigate to continue.] # Navigated to https://example.com # ... ``` `browser_mode` also reports the real PID even when the bridge adopted a Chrome it didn't spawn (a leftover from a previous MCP session). So "get pid, kill -9 it, watch the restart" works regardless of how Chrome got there. ### Explicit kill + restart cycle ``` {action: "kill_chrome"} # Chrome killed. {action: "restart_chrome"} # Chrome restarted in headless mode. {action: "navigate", payload: "data:text/html,<h1>fresh</h1>"} ``` ## Multi-MCP isolation By default the bridge handles parallel MCP servers on the same host automatically: the first claims `superpowers-chrome:9222`, the next silently falls through to `superpowers-chrome-2:9223`, then `-3:9224`, etc. Each MCP drives its own Chrome with its own profile directory. To intentionally **share** a Chrome between processes (e.g., a long-lived `chrome-ws start` from the shell + a Claude MCP attaching to it), pick a fixed profile name on both sides: ``` # Shell: CHROME_WS_PROFILE=shared chrome-ws start # In the MCP, on first call: {action: "set_profile", payload: "shared"} {action: "navigate", payload: "https://example.com"} # Reconnects to the shell-started Chrome — same tabs, same cookies. ``` Either set `CHROME_WS_PROFILE=shared` in the MCP's environment, or call `set_profile` at runtime. Both mark the profile as explicit, so the bridge shares rather than disambiguates. --- ## Advanced Patterns ### Multi-Step Workflow Complete booking flow with validation: ``` {action: "navigate", payload: "https://booking.example.com"} // Search {action: "type", selector: "input[name=destination]", payload: "San Francisco"} {action: "type", selector: "input[name=checkin]", payload: "2025-12-01"} {action: "click", selector: "button.search"} // Select hotel {action: "await_element", selector: ".hotel-results"} {action: "click", selector: ".hotel-card:first-child .select"} // Choose room {action: "await_element", selector: ".room-options"} {action: "click", selector: ".room[data-type=deluxe] .book"} // Fill guest info {action: "await_element", selector: "form.guest-info"} {action: "type", selector: "input[name=firstName]", payload: "Jane"} {action: "type", selector: "input[name=lastName]", payload: "Smith"} {action: "type", selector: "input[name=email]", payload: "[email protected]"} // Review (don't complete) {action: "click", selector: "button.review"} {action: "await_element", selector: ".summary"} // Extract confirmation details {action: "extract", payload: "text", selector: ".hotel-name"} {action: "extract", payload: "text", selector: ".total-price"} ``` ### Cookies and LocalStorage Access browser storage: ``` // Get cookies {action: "eval", payload: "document.cookie"} // Set cookie {action: "eval", payload: "document.cookie = 'theme=dark; path=/'"} // Get localStorage {action: "eval", payload: "JSON.stringify(localStorage)"} // Set localStorage {action: "eval", payload: "localStorage.setItem('lastVisit', new Date().toISOString())"} ``` ### Handle Modals Interact with modal dialogs: ``` {action: "click", selector: "button.open-modal"} {action: "await_element", selector: ".modal.visible"} // Fill modal form {action: "type", selector: ".modal input[name=username]", payload: "testuser"} {action: "click", selector: ".modal button.submit"} // Wait for modal to close {action: "eval", payload: ` new Promise(resolve => { const check = () => { if (!document.querySelector('.modal.visible')) { resolve(true); } else { setTimeout(check, 100); } }; check(); }) `} ``` ### Screenshots Capture full page or specific elements: ``` // Full page screenshot {action: "navigate", payload: "https://example.com"} {action: "await_element", selector: "body"} {action: "screenshot", payload: "/tmp/page.png"} // Element-specific screenshot {action: "screenshot", payload: "/tmp/element.png", selector: ".important-section"} ``` ### Check Element State Verify element properties before interaction: ``` // Check if button is disabled {action: "eval", payload: "document.querySelector('button.submit').disabled"} // Check if element is visible {action: "eval", payload: "!!document.querySelector('.important-button') && window.getComputedStyle(document.querySelector('.important-button')).display !== 'none'"} // Check element exists {action: "eval", payload: "!!document.querySelector('.important-button')"} ``` --- ## Tips and Best Practices ### Always Wait Before Interaction Don't interact with elements immediately after navigation: ``` // BAD - might fail if page slow to load {action: "navigate", payload: "https://example.com"} {action: "click", selector: "button"} // May fail! // GOOD - wait for element first {action: "navigate", payload: "https://example.com"} {action: "await_element", selector: "button"} {action: "click", selector: "button"} ``` ### Use Specific Selectors Avoid generic selectors that match multiple elements: ``` // BAD - matches first button on page {action: "click", selector: "button"} // GOOD - specific selector {action: "click", selector: "button[type=submit]"} {action: "click", selector: "button.login-button"} {action: "click", selector: "#submit-form"} ``` ### Verify Selectors First Check page structure before building workflow: ``` // Check page HTML {action: "extract", payload: "html"} // Or check specific element {action: "extract", payload: "html", selector: "form"} ``` ### Handle Dynamic Content Wait for content to load before extraction: ``` // BAD - tries to extract before content loads {action: "navigate", payload: "https://app.com"} {action: "extract", payload: "text", selector: ".user-name"} // Might be empty! // GOOD - wait for content {action: "navigate", payload: "https://app.com"} {action: "await_element", selector: ".user-name"} {action: "extract", payload: "text", selector: ".user-name"} ``` ### Use \n for Form Submission Append newline to auto-submit forms: ``` // Submit search without explicit click {action: "type", selector: "#search-input", payload: "my query\n"} // Submit login form {action: "type", selector: "input[name=email]", payload: "[email protected]"} {action: "type", selector: "input[name=password]", payload: "password123\n"} ``` --- ## Common Pitfalls ### Don't Rely on Tab Indices Tab indices change when tabs close — use URL or title substrings for reliable switching: ``` // BAD - index might be stale after closing tabs {action: "switch_tab", payload: 2} {action: "click", selector: "button"} // GOOD - switch by URL or title substring (stable across tab changes) {action: "switch_tab", payload: "example.com"} {action: "click", selector: "button"} // Or list tabs first to confirm the index {action: "list_tabs"} {action: "switch_tab", payload: 2} {action: "click", selector: "button"} ``` ### Increase Timeout for Slow Pages Default timeout is 5000ms, increase if needed: ``` // For slow-loading elements {action: "await_element", selector: ".lazy-content", timeout: 30000} // For slow AJAX requests {action: "await_text", payload: "Data loaded", timeout: 15000} ``` ### Extract Structured Data with JavaScript For complex data extraction, use JavaScript evaluation: ``` // Instead of multiple extract calls, use one eval {action: "eval", payload: ` { title: document.querySelector('h1').textContent, author: document.querySelector('.author').textContent, date: document.querySelector('time').textContent, links: Array.from(document.querySelectorAll('a')).map(a => a.href) } `} ``` --- ## Reference - [SKILL.md](SKILL.md) - Complete tool reference - [COMMANDLINE-USAGE.md](COMMANDLINE-USAGE.md) - Command-line bash examples - [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) - Full protocol documentationskills/browsing/host-override.js3.2 KB2bfbbb5b869bupstream ↗
const DEFAULT_PORT = 9222; const DEFAULT_HOST = '127.0.0.1'; /** * Build a per-instance host-override configuration. * * The legacy module-level constants and `rewriteWsUrl` above are baked at * module-load time and shared across every consumer that requires this * file. That's fine for the single-Chrome use case — the CLI and the MCP * server each own their process, so module-level state is effectively * per-process. It breaks down when one process needs to drive several * independent Chrome instances concurrently (different host/port pairs): * the load-time constants can only describe one of them. * * `createOverride({ host, port })` returns a fresh state-bag with its own * host/port/override-enabled flag, plus methods (`getHost`, `getPort`, * `getBase`, `isOverrideEnabled`, `rewriteWsUrl`, `setDefaults`) bound to * that state. Two instances do not share state — mutating one via * `setDefaults()` does not affect the other. Callers that don't need * per-instance isolation can keep using the module-level constants and * `rewriteWsUrl` exactly as before; nothing about the legacy API has * changed. * * Defaults: if both `host` and `port` are omitted, the instance seeds from * the `CHROME_WS_HOST` / `CHROME_WS_PORT` env vars. If either argument is * supplied, both are taken from the arguments (filling in defaults for the * missing one) and the instance's `overrideEnabled` flag starts true — * matching `setDefaults()` semantics. */ function createOverride({ host, port } = {}) { let instanceHost; let instancePort; let instanceOverrideEnabled; if (host !== undefined || port !== undefined) { instanceHost = host !== undefined ? host : DEFAULT_HOST; instancePort = port !== undefined ? port : DEFAULT_PORT; instanceOverrideEnabled = true; } else { instanceHost = process.env.CHROME_WS_HOST || DEFAULT_HOST; const parsed = parseInt(process.env.CHROME_WS_PORT || `${DEFAULT_PORT}`, 10); instancePort = Number.isNaN(parsed) ? DEFAULT_PORT : parsed; instanceOverrideEnabled = process.env.CHROME_WS_HOST !== undefined || process.env.CHROME_WS_PORT !== undefined; } function setDefaults(nextHost, nextPort) { instanceHost = nextHost; instancePort = nextPort; instanceOverrideEnabled = true; } function getHost() { return instanceHost; } function getPort() { return instancePort; } function getBase() { return `http://${instanceHost}:${instancePort}`; } function isOverrideEnabled() { return instanceOverrideEnabled; } function instanceRewriteWsUrl(originalUrl, overrideHost, overridePort) { if (!originalUrl || typeof originalUrl !== 'string') { return originalUrl; } if (!instanceOverrideEnabled) { return originalUrl; } const useHost = overrideHost !== undefined ? overrideHost : instanceHost; const usePort = overridePort !== undefined ? overridePort : instancePort; try { const url = new URL(originalUrl); url.hostname = useHost; url.port = `${usePort}`; return url.toString(); } catch { return originalUrl; } } return { setDefaults, getHost, getPort, getBase, isOverrideEnabled, rewriteWsUrl: instanceRewriteWsUrl, }; } module.exports = { createOverride };skills/browsing/lib/browser-bridge.js6.8 KBbddb9941c0f5upstream ↗
'use strict'; const { createCdpRouter } = require('./cdp-router'); const { attachPageSession, buildPageSessionFromAttached } = require('./page-session'); /** * attachBrowserBridge({browser, host, port, rewriteWsUrl}) — consumer-facing * bridge over the browser-session. * * Exposes: * targets.list() — synchronous snapshot of current targets * targets.onCreated(handler) — register listener; returns unsub fn * targets.onDestroyed(handler) * targets.waitForNew(predicate, {timeoutMs}) * createBrowserContext({proxyServer?}) -> {browserContextId, createPage, dispose} * attachPageSession(targetId) — page session over the browser-WS via flatten mode * * host/port/rewriteWsUrl are needed by createBrowserContext.createPage to construct * per-page WS URLs for callers that still want one (the bridge itself never uses * them — page sessions ride the browser-WS). */ async function attachBrowserBridge({ browser, host, port, rewriteWsUrl, autoAttach = false, onPageSession = null }) { // The cdp-router sits between browser-session and bridge consumers. // Page-session-tagged messages dispatch to the right session; root-session // events (Target.*, etc.) fire root listeners. Command responses without // sessionId stay correlated by browser-session.js's pendingRequests // (single source of truth for root-session correlation). const router = createCdpRouter({ browser }); const targetMap = new Map(); // targetId -> targetInfo const onCreatedFns = new Set(); const onDestroyedFns = new Set(); router.getRootListeners().add((msg) => { if (msg.method === 'Target.targetCreated') { const t = msg.params.targetInfo; targetMap.set(t.targetId, t); for (const fn of onCreatedFns) { try { fn(t); } catch (e) { console.error('targets onCreated handler threw:', e); } } } else if (msg.method === 'Target.targetInfoChanged') { const t = msg.params.targetInfo; targetMap.set(t.targetId, t); } else if (msg.method === 'Target.targetDestroyed') { const t = targetMap.get(msg.params.targetId); targetMap.delete(msg.params.targetId); if (t) { for (const fn of onDestroyedFns) { try { fn(t); } catch (e) { console.error('targets onDestroyed handler threw:', e); } } } } }); // Handle auto-attached targets (popups, child frames, etc.) when autoAttach is on. // Chrome emits this with an already-allocated sessionId — no Target.attachToTarget needed. // // Only act on targets that are paused (waitingForDebugger: true). Existing tabs // that Chrome retrospectively reports via attachedToTarget (waitingForDebugger: false) // are not paused and will be set up through the normal getPageSession/attachPageSession // path. Installing a page session here for those targets would register the same // sessionId twice in the cdp-router, causing duplicate-id protocol errors. router.getRootListeners().add(async (msg) => { if (msg.method !== 'Target.attachedToTarget') return; const { sessionId, targetInfo, waitingForDebugger } = msg.params; if (!waitingForDebugger) return; const ps = buildPageSessionFromAttached({ browser, router, sessionId, targetId: targetInfo.targetId }); // Only run the onPageSession hook for page-type targets. Non-page targets // (service_worker, background_page, etc.) don't support the Page CDP domain // and would cause 'Page.enable' wasn't found errors in the hook. if (onPageSession && targetInfo.type === 'page') { try { await onPageSession(ps); } catch (e) { console.error('onPageSession hook threw:', e); } } // Resume the paused target AFTER the hook so any shims (e.g. dialog) are // installed before the page's scripts run. try { await ps.send('Runtime.runIfWaitingForDebugger', {}); } catch (e) { console.error('Runtime.runIfWaitingForDebugger failed:', e); } }); // Subscribe — replays existing targets as targetCreated events. await browser.send('Target.setDiscoverTargets', { discover: true }); if (autoAttach) { await browser.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true, }); } function list() { return Array.from(targetMap.values()); } function onCreated(fn) { onCreatedFns.add(fn); return () => onCreatedFns.delete(fn); } function onDestroyed(fn) { onDestroyedFns.add(fn); return () => onDestroyedFns.delete(fn); } function waitForNew(predicate, { timeoutMs = 15000 } = {}) { return new Promise((resolve, reject) => { let unsub = null; const timeout = setTimeout(() => { if (unsub) unsub(); reject(new Error(`waitForNew: timed out after ${timeoutMs}ms`)); }, timeoutMs); unsub = onCreated((t) => { let match; try { match = predicate(t); } catch (e) { clearTimeout(timeout); if (unsub) unsub(); reject(e); return; } if (match) { clearTimeout(timeout); if (unsub) unsub(); resolve(t); } }); }); } /** * createBrowserContext({proxyServer?}) — creates a Chrome BrowserContext. * Returns {browserContextId, createPage, dispose}. * * createPage(url) calls Target.createTarget({url, browserContextId}) and * constructs a tab-shape-compatible page handle whose webSocketDebuggerUrl * is run through rewriteWsUrl. * * dispose() is atomic — Chrome tears down cookies/storage/IDB/SW for the * context in one call. */ async function createBrowserContext(opts = {}) { const params = {}; if (opts.proxyServer) params.proxyServer = opts.proxyServer; const { browserContextId } = await browser.send('Target.createBrowserContext', params); let disposed = false; async function createPage(url = 'about:blank') { if (disposed) throw new Error('BrowserContext disposed'); const { targetId } = await browser.send('Target.createTarget', { url, browserContextId }); const rawWsUrl = `ws://${host}:${port}/devtools/page/${targetId}`; return { id: targetId, targetId, webSocketDebuggerUrl: rewriteWsUrl(rawWsUrl, host, port), type: 'page', url, browserContextId, }; } async function dispose() { if (disposed) return; disposed = true; try { await browser.send('Target.disposeBrowserContext', { browserContextId }); } catch (e) { console.warn('BrowserContext.dispose() failed:', e && e.message); } } return { browserContextId, createPage, dispose }; } async function attachPage(targetId) { return attachPageSession({ browser, router }, targetId); } return { targets: { list, onCreated, onDestroyed, waitForNew }, createBrowserContext, attachPageSession: attachPage, router, }; } module.exports = { attachBrowserBridge };skills/browsing/lib/browser-session.js5.1 KB707206f6d4bcupstream ↗
'use strict'; const { WebSocketClient: DefaultWebSocketClient } = require('./websocket-client'); /** * createBrowserSession({host, port, rewriteWsUrl, chromeHttp, WebSocketClient?}) -> bridge handle. * * Owns the one root WebSocket to /devtools/browser/<id>. Page-action commands ride * per-page sessions (attached via Target.attachToTarget({flatten:true})) and envelope * messages with a sessionId via sendRaw — the page session manages its own pendingRequests * (in the cdp-router). browser-session correlates ROOT-session command responses only. * * Returned API: * send(method, params?, {timeoutMs?}) -> Promise<result> // root command * onEvent(handler) -> unsub fn * close() -> Promise<void> * isConnected() -> boolean * sendRaw(json) -> void */ function createBrowserSession({ host, port, rewriteWsUrl, chromeHttp, WebSocketClient = DefaultWebSocketClient }) { let ws = null; const pendingRequests = new Map(); // id -> {resolve, reject, timeout} let messageIdCounter = 1; const eventListeners = new Set(); let connectPromise = null; let closed = false; async function ensureConnected() { if (ws && ws.isConnected()) return; if (connectPromise) { await connectPromise; return; } connectPromise = (async () => { try { const versionInfo = await chromeHttp('/json/version'); if (!versionInfo || !versionInfo.webSocketDebuggerUrl) { throw new Error('chromeHttp(/json/version) returned no webSocketDebuggerUrl'); } const url = rewriteWsUrl(versionInfo.webSocketDebuggerUrl, host, port); const next = new WebSocketClient(url); next.on('message', (raw) => { let data; try { data = JSON.parse(raw); } catch (e) { console.error('browser-session: bad JSON from CDP:', e); return; } // Correlate ROOT-session command responses (id without sessionId). Page-session // responses carry {id, result, sessionId} and fall through to event listeners // for the cdp-router to dispatch. if (data.id !== undefined && data.sessionId === undefined) { const pending = pendingRequests.get(data.id); if (pending) { clearTimeout(pending.timeout); pendingRequests.delete(data.id); if (data.error) { pending.reject(new Error(data.error.message || JSON.stringify(data.error))); } else { pending.resolve(data.result); } return; } } for (const fn of eventListeners) { try { fn(data); } catch (e) { console.error('browser-session listener threw:', e); } } }); next.on('close', () => { for (const [, p] of pendingRequests) { clearTimeout(p.timeout); p.reject(new Error('Browser session WS closed')); } pendingRequests.clear(); }); await next.connect(); // Assign ws only after a successful connect so concurrent callers that hit the // `ws && ws.isConnected()` early-return don't see a half-initialized socket. ws = next; } catch (e) { // Allow retry after a transient failure (network blip, Chrome not yet ready, etc.). // We do NOT null on success — leaving the resolved promise in place makes subsequent // ensureConnected() awaits a no-op. connectPromise = null; throw e; } })(); await connectPromise; } async function send(method, params = {}, { timeoutMs = 10000 } = {}) { if (closed) throw new Error('Browser session closed'); await ensureConnected(); if (closed) throw new Error('Browser session closed'); const id = messageIdCounter++; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { pendingRequests.delete(id); reject(new Error(`Browser session timeout: ${method}`)); }, timeoutMs); pendingRequests.set(id, { resolve, reject, timeout }); try { ws.send(JSON.stringify({ id, method, params })); } catch (e) { clearTimeout(timeout); pendingRequests.delete(id); reject(e); } }); } function onEvent(handler) { eventListeners.add(handler); return () => eventListeners.delete(handler); } async function close() { closed = true; if (ws) { ws.close(); ws = null; } for (const [, p] of pendingRequests) { clearTimeout(p.timeout); p.reject(new Error('Browser session closed')); } pendingRequests.clear(); eventListeners.clear(); } function isConnected() { return ws !== null && ws.isConnected(); } function sendRaw(json) { if (closed) throw new Error('Browser session closed'); if (!ws || !ws.isConnected()) { throw new Error('Browser WS not connected (call send() first to lazy-open)'); } ws.send(json); } return { send, onEvent, close, isConnected, sendRaw }; } module.exports = { createBrowserSession };skills/browsing/lib/capture-pause.js3.5 KB69a581e1fdd1upstream ↗
/** * Pause switch for automatic page captures. * * Every auto-capture action (click/fill/select/eval/set_attr/navigate/ * hover/drag/scroll/...) drops a DOM snapshot (.html/.md), a screenshot, * a console-log placeholder, and — when a native dialog is open — a * synthetic dialog artifact into the session dir. None of that is gated * on content: capture.js's credential-shape/marker/URL checks only catch * a secret with a recognizable SHAPE or an opt-in data-sen-secret MARK. * A page that reveals a secret only after a user action (Google Admin's * reset-password dialog, a "show password" toggle, a backup-codes modal) * in a plain element with no secret-looking id/name/class, on a URL with * nothing sensitive in it, slips past every one of those checks — and the * very next auto-capture writes it to disk. * * pause_capture/resume_capture (see lib/capture.js's pauseCapture/ * resumeCapture, wired to session state) is the tool-level answer: an * agent that is about to reveal a secret this way pauses capture first, * performs the reveal, reads/relays the value through the credential * broker, then resumes once the secret is off-screen. State lives on the * session (see session-state.js), so it survives across actions until * resume_capture is called — an agent that forgets to resume simply gets * no more captures for the rest of the session, never a silent leak. * * Deliberately NOT auto-resumed on navigation to a different origin: that * would need to compare the ORIGIN at pause time against the ORIGIN after * every navigation (cross-tab, redirects, client-side routing all count), * and a same-origin page can still carry the secret forward (a SPA's * post-reveal route change, a same-origin redirect back to the dashboard * with the value still in a toast). An agent-driven resume_capture is the * one signal that actually means "the secret is gone now" — the pause * is already fail-safe (captures just stay off) if that call is forgotten. * * Caveat: state.capturePaused lives on the * in-memory per-session state bag (session-state.js), not anywhere * persisted — it survives restart_chrome (same process, same state * object) but NOT the MCP SERVER process restarting and re-adopting an * already-running Chrome (chrome-process.js's orphan-adoption path, or * meta.json). A server restart always comes back with a fresh * capturePaused:false, even if the revealed secret is still sitting on * the readopted page. This is fail-OPEN for that one scenario — the only * signal is that the pause notice stops appearing in responses — and is * a documented gap (see SKILL.md/README.md), not something this module * works around. */ const CAPTURE_PAUSED_NOTICE = '⏸️ Capture is paused for this session: automatic DOM/markdown/screenshot/console/dialog captures are OFF. ' + 'Call resume_capture as soon as the secret is off-screen.'; // Unlike CREDENTIAL_SUPPRESSED_NOTICE (credential-guard.js), there is no // "capture happened but was redacted" middle ground here: pause means NO // auto-capture artifact is produced at all while it's on, by design (see // module comment above). function capturePausedScreenshotRefusal() { return ( 'screenshot refused: capture is paused for this session. ' + 'An explicit screenshot could write the same secret pause_capture exists to keep off disk. ' + 'Call resume_capture once the secret is off-screen, then screenshot.' ); } module.exports = { CAPTURE_PAUSED_NOTICE, capturePausedScreenshotRefusal, };skills/browsing/lib/capture.js45 KB99945663332fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/cdp-router.js2.5 KBc82b1d177933upstream ↗
'use strict'; /** * createCdpRouter({browser}) — sessionId-aware dispatcher for browser-WS messages. * * Routing rules: * - msg.sessionId set -> per-session pendingRequests (if msg.id) or eventListeners (if msg.method) * - msg.method, no sessionId -> root listeners (target events, etc.) * - msg.id, no sessionId -> falls through (browser-session.js owns root correlation) * * Per-session message-id counters are independent. {id:1, sessionId:"A"} and * {id:1, sessionId:"B"} correlate independently on one WS — collapsing id space * across sessions would silently break correlation. */ function createCdpRouter({ browser }) { const sessions = new Map(); // sessionId -> { pendingRequests, eventListeners } const rootListeners = new Set(); browser.onEvent((msg) => { const sid = msg.sessionId; if (sid) { const sess = sessions.get(sid); if (!sess) return; // detached or never registered — drop silently if (msg.id !== undefined) { const pending = sess.pendingRequests.get(msg.id); if (pending) { clearTimeout(pending.timeout); sess.pendingRequests.delete(msg.id); if (msg.error) { pending.reject(new Error(msg.error.message || JSON.stringify(msg.error))); } else { pending.resolve(msg.result); } } } else if (msg.method) { for (const fn of sess.eventListeners) { try { fn(msg); } catch (e) { console.error('cdp-router page listener threw:', e); } } } } else if (msg.method) { for (const fn of rootListeners) { try { fn(msg); } catch (e) { console.error('cdp-router root listener threw:', e); } } } // Untagged responses (msg.id with no sessionId, no method) intentionally // fall through — browser-session.js's pendingRequests Map handles them. }); function registerSession(sessionId) { const sess = { pendingRequests: new Map(), eventListeners: new Set() }; sessions.set(sessionId, sess); return sess; } function unregisterSession(sessionId) { const sess = sessions.get(sessionId); if (!sess) return; for (const [, p] of sess.pendingRequests) { clearTimeout(p.timeout); p.reject(new Error('Page session detached')); } sess.pendingRequests.clear(); sess.eventListeners.clear(); sessions.delete(sessionId); } function getRootListeners() { return rootListeners; } return { registerSession, unregisterSession, getRootListeners }; } module.exports = { createCdpRouter };skills/browsing/lib/cdp-utils.js764 Bf0892f5192e6upstream ↗
/** * Shared utilities for CDP responses. * * `throwIfExceptionDetails(result)` inspects a `Runtime.evaluate` reply and * throws if the page-side JS threw or a Promise rejected. Without this, * callers silently see `undefined` instead of the actual error — which has * caused real bugs (waitForElement timeouts swallowed, evaluate returning {} * for thrown errors). Use after every `sendCdpCommand(...,'Runtime.evaluate',...)`. */ function throwIfExceptionDetails(result) { if (!result || !result.exceptionDetails) return; const desc = result.exceptionDetails.exception?.description || result.exceptionDetails.text || 'unknown evaluation error'; throw new Error(`evaluate failed: ${desc}`); } module.exports = { throwIfExceptionDetails };skills/browsing/lib/chrome-launcher-helpers.js13 KBa5db9af6564cupstream ↗
/** * Pure helpers used by the Chrome launcher: HTTP probing, profile path * resolution, meta.json read/write, port allocation, and Chrome flag list * construction. None of these touch session state — every input is passed * explicitly. Kept together because they share no dependency on the rest * of chrome-ws-lib. */ const http = require('http'); const fs = require('fs'); const os = require('os'); const path = require('path'); const net = require('net'); // HTTP request to Chrome's DevTools endpoint at an explicit host:port. // Used for probing arbitrary ports before settling on activePort. async function chromeHttpAt(host, port, urlPath, method = 'GET') { return new Promise((resolve, reject) => { const options = { hostname: host, port, path: urlPath, method }; const req = http.request(options, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { if (!data) { resolve({}); return; } try { resolve(JSON.parse(data)); } catch (_e) { resolve({ message: data }); } }); }); req.on('error', reject); req.end(); }); } function getXdgCacheHome() { if (process.env.XDG_CACHE_HOME) { return process.env.XDG_CACHE_HOME; } const platform = os.platform(); const homeDir = os.homedir(); if (platform === 'darwin') { return path.join(homeDir, 'Library', 'Caches'); } else if (platform === 'win32') { return process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'); } else { return path.join(homeDir, '.cache'); } } function getChromeProfileDir(profileName = 'superpowers-chrome') { return path.join(getXdgCacheHome(), 'superpowers', 'browser-profiles', profileName); } // --- Per-profile meta.json --- // // Each profile gets a sibling meta.json file next to its data directory: // ~/.cache/superpowers/browser-profiles/superpowers-chrome/ ← profile data // ~/.cache/superpowers/browser-profiles/superpowers-chrome.meta.json ← port/pid tracking // // Enables: reconnection across sessions, parallel Chrome instances per // profile, and collision detection. function getProfileMetaPath(profileName = 'superpowers-chrome') { return path.join(getXdgCacheHome(), 'superpowers', 'browser-profiles', `${profileName}.meta.json`); } function readProfileMeta(profileName = 'superpowers-chrome') { try { const data = fs.readFileSync(getProfileMetaPath(profileName), 'utf8'); return JSON.parse(data); } catch { return null; } } function writeProfileMeta(profileName, data) { const metaPath = getProfileMetaPath(profileName); fs.mkdirSync(path.dirname(metaPath), { recursive: true }); fs.writeFileSync(metaPath, JSON.stringify(data, null, 2) + '\n'); } function clearProfileMeta(profileName) { try { fs.unlinkSync(getProfileMetaPath(profileName)); } catch { // Already absent — nothing to do } } // Check if a port has a live Chrome DevTools instance, optionally verify PID. async function isPortAlive(host, port, expectedPid = null) { try { const data = await chromeHttpAt(host, port, '/json/version'); if (!data || !data.Browser) return false; if (expectedPid) { try { process.kill(expectedPid, 0); } // signal 0 = existence check catch { return false; } } return true; } catch { return false; } } // Probe whether a port is free (no listener) using a temporary TCP server. // "Free" means free on BOTH IPv4 and IPv6 — Chrome may bind ::1 only on // some macOS configurations, and a port bound on ::1 still appears free // from a 127.0.0.1 probe. Without checking both, we'd start a second // Chrome that races the first for the same port number on different // stacks, with non-deterministic answers to /json HTTP requests. function isPortFreeOn(host, port) { return new Promise((resolve) => { const server = net.createServer(); // Resolve with the OS error code so the caller can tell "port in use" // (EADDRINUSE) apart from "this loopback/address family isn't available // here at all" (EADDRNOTAVAIL / EAFNOSUPPORT) — very different signals. server.once('error', (err) => resolve({ free: false, code: err.code })); server.once('listening', () => { server.close(() => resolve({ free: true })); }); server.listen(port, host); }); } // Pure decision over the IPv4 and IPv6 loopback probe results. A port is free // only if IPv4 loopback is free. The IPv6 probe is a race-guard for hosts where // Chrome may bind ::1 only (some macOS configs) — but an UNAVAILABLE IPv6 // loopback (e.g. a container with net.ipv6.conf.lo.disable_ipv6=1, where every // ::1 bind returns EADDRNOTAVAIL) is NOT a port conflict and must not veto the // port. Only a genuine in-use signal on ::1 vetoes. Exported for testing. function portFreeFromProbes(v4, v6) { if (!v4.free) return false; if (v6.free) return true; if (v6.code === 'EADDRNOTAVAIL' || v6.code === 'EAFNOSUPPORT') return true; return false; } async function isPortFree(port) { const v4 = await isPortFreeOn('127.0.0.1', port); if (!v4.free) return false; const v6 = await isPortFreeOn('::1', port); return portFreeFromProbes(v4, v6); } // Port range tried sequentially, starting at 9222 for backward compat. const PORT_RANGE_START = 9222; const PORT_RANGE_END = 12111; // Find first available port in range. Defaults span the full PORT_RANGE. async function findAvailablePort(start = PORT_RANGE_START, end = PORT_RANGE_END) { for (let port = start; port <= end; port++) { if (await isPortFree(port)) return port; } throw new Error(`No available port in range ${start}-${end}`); } // Find the PID of the process holding `port`, or null if none. // Uses platform-native tools — lsof on macOS/Linux, netstat on Windows. // Returns null on any failure (parsing, missing tool, no listener). function findPidOnPort(port) { const { execFileSync } = require('child_process'); // Guard against a non-numeric or out-of-range port before using it. const portNum = Number(port); if (!Number.isInteger(portNum) || portNum <= 0 || portNum > 65535) { return null; } try { if (process.platform === 'darwin' || process.platform === 'linux') { // argv form, no shell: the port can never be shell-interpreted. const out = execFileSync('lsof', [`-ti:${portNum}`, '-sTCP:LISTEN'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); if (!out) return null; const first = out.split('\n')[0]; const pid = parseInt(first, 10); return Number.isFinite(pid) ? pid : null; } if (process.platform === 'win32') { // execFileSync can't express the old `netstat -ano | findstr :PORT` // pipeline (pipes need a shell), so filter in JS instead: LISTENING // lines whose local-address column ends with exactly `:PORT`. const out = execFileSync('netstat', ['-ano'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); const portSuffix = `:${portNum}`; const lines = out.split(/\r?\n/).filter(l => { if (!/LISTENING/i.test(l)) return false; const cols = l.trim().split(/\s+/); return cols.length >= 2 && cols[1].endsWith(portSuffix); }); if (!lines.length) return null; const cols = lines[0].trim().split(/\s+/); const pid = parseInt(cols[cols.length - 1], 10); return Number.isFinite(pid) ? pid : null; } } catch (_e) { return null; } return null; } // Scan running processes for a Chrome holding our profile's lock. // Used to adopt orphan Chrome instances (meta.json missing/stale). // Returns { pid, port } for first match, or null. // // Scans ps output for Chrome processes with: // --user-data-dir=<our profileDir> AND --remote-debugging-port=<N> // Skips Chrome Helper processes (renderer, GPU, etc). function findOrphanChromeForProfile(profileName) { const { execSync } = require('child_process'); try { const profileDir = getChromeProfileDir(profileName); let psOutput; if (process.platform === 'darwin' || process.platform === 'linux') { // ps auxw: full command line per process psOutput = execSync('ps auxw', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); } else if (process.platform === 'win32') { // Windows: use wmic to list processes with their full command line psOutput = execSync('wmic process list full', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); } else { return null; // Unsupported platform } const lines = psOutput.split('\n'); for (const line of lines) { // Skip empty lines and Chrome Helper processes (rendering, GPU, etc) if (!line.trim() || line.includes('Chrome Helper') || line.includes('chrome.exe --type=')) { continue; } // Must contain our profile dir if (!line.includes(profileDir)) { continue; } // Must contain --remote-debugging-port const portMatch = line.match(/--remote-debugging-port=(\d+)/); if (!portMatch || !portMatch[1]) { continue; } const port = parseInt(portMatch[1], 10); // Extract PID: position varies by platform, but it's early in the line. // macOS/Linux: "USER PID ..." — PID is second field after spaces // Windows wmic: "ProcessId=..." or first numeric field let pid; if (process.platform === 'darwin' || process.platform === 'linux') { const fields = line.split(/\s+/); if (fields.length >= 2) { pid = parseInt(fields[1], 10); } } else if (process.platform === 'win32') { const pidMatch = line.match(/ProcessId=(\d+)|^(\d+)\s/); if (pidMatch) { pid = parseInt(pidMatch[1] || pidMatch[2], 10); } } if (Number.isFinite(pid) && Number.isFinite(port)) { return { pid, port }; } } return null; } catch (_e) { // ps or wmic failed, no process info available return null; } } // Chrome's sandbox cannot work as root, and usually not inside containers // (no user namespaces), so --no-sandbox is required there. Everywhere else // the sandbox stays on: this browser navigates to agent-chosen URLs, so // exploit containment matters. Params are injectable for tests; defaults // read the real environment. function sandboxDisableNeeded({ uid = (process.getuid ? process.getuid() : null), dockerEnv = fs.existsSync('/.dockerenv'), cgroup = readInitCgroup(), } = {}) { if (uid === 0) return true; if (dockerEnv) return true; if (cgroup && /docker|kubepods|containerd|lxc/.test(cgroup)) return true; return false; } function readInitCgroup() { try { return fs.readFileSync('/proc/1/cgroup', 'utf8'); } catch (_e) { return null; // not Linux, or unreadable — no container signal } } function buildChromeArgs({ chosenPort, chromeUserDataDir, chromeHeadless, noSandbox = sandboxDisableNeeded() }) { const args = [ `--remote-debugging-port=${chosenPort}`, `--user-data-dir=${chromeUserDataDir}`, '--no-first-run', '--no-default-browser-check', '--disable-search-engine-choice-screen', '--password-store=basic', '--use-mock-keychain', '--disable-background-networking', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-breakpad', '--disable-client-side-phishing-detection', '--disable-component-update', '--disable-default-apps', '--disable-dev-shm-usage', '--disable-extensions', '--disable-features=Translate,TranslateUI,OptimizationHints', '--disable-hang-monitor', '--disable-ipc-flooding-protection', '--disable-popup-blocking', '--disable-prompt-on-repost', '--disable-sync', '--force-color-profile=srgb', '--metrics-recording-only', '--safebrowsing-disable-auto-update', '--disable-blink-features=AutomationControlled' ]; if (noSandbox) { args.push('--no-sandbox'); } if (chromeHeadless) { args.push('--headless=new'); } // CHROME_EXTRA_ARGS: whitespace-separated extra flags to append, e.g. for // software WebGL in headless containers: // CHROME_EXTRA_ARGS="--use-gl=angle --use-angle=swiftshader-webgl --enable-unsafe-swiftshader" const extraArgs = process.env.CHROME_EXTRA_ARGS; if (extraArgs) { const tokens = extraArgs.split(/\s+/).filter(Boolean); args.push(...tokens); } return args; } module.exports = { PORT_RANGE_START, PORT_RANGE_END, chromeHttpAt, getXdgCacheHome, getChromeProfileDir, getProfileMetaPath, readProfileMeta, writeProfileMeta, clearProfileMeta, isPortAlive, isPortFree, portFreeFromProbes, findAvailablePort, findPidOnPort, findOrphanChromeForProfile, buildChromeArgs, sandboxDisableNeeded, };skills/browsing/lib/chrome-process.js17 KBc6d9112688cdupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/code-list-detector.js15 KB4fe5e7b3dda6upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/console-logging.js2.6 KBa43329661240upstream ↗
/** * Page console-message capture. * * `enableConsoleLogging` subscribes to `Runtime.consoleAPICalled` events on * the existing pageSession (bridge) connection and streams console output into * `state.consoleMessages` keyed by `sessionId`. * * `getConsoleMessages` reads the buffer — optionally filtered by timestamp. * `clearConsoleMessages` resets the buffer for a tab. * * `attachConsoleLogging({ state, getPageSession })` returns the bound API. */ function attachConsoleLogging({ state, getPageSession }) { async function enableConsoleLogging(tabIndexOrWsUrl) { const ps = await getPageSession(tabIndexOrWsUrl); if (!state.consoleMessages.has(ps.sessionId)) { state.consoleMessages.set(ps.sessionId, []); } await ps.enableDomain('Runtime'); ps.onEvent((msg) => { if (msg.method === 'Runtime.consoleAPICalled') { const entry = msg.params; const timestamp = new Date().toISOString(); const level = entry.type || 'log'; const args = entry.args || []; const text = args.map(arg => { if (arg.type === 'string') return arg.value; if (arg.type === 'number') return String(arg.value); if (arg.type === 'boolean') return String(arg.value); if (arg.type === 'object') return arg.description || '[Object]'; return String(arg.value || arg.description || arg.type); }).join(' '); const messages = state.consoleMessages.get(ps.sessionId) || []; // Dedup: skip if the last entry has the same level+text at the same // timestamp (prevents double-fire when multiple CDP event listeners // route the same console call through the same handler). const last = messages[messages.length - 1]; if (!last || last.timestamp !== timestamp || last.level !== level || last.text !== text) { messages.push({ timestamp, level, text }); state.consoleMessages.set(ps.sessionId, messages); } } }); } async function getConsoleMessages(tabIndexOrWsUrl, sinceTime = null) { const ps = await getPageSession(tabIndexOrWsUrl); const messages = state.consoleMessages.get(ps.sessionId) || []; if (!sinceTime) { return messages; } return messages.filter(msg => new Date(msg.timestamp) > sinceTime); } async function clearConsoleMessages(tabIndexOrWsUrl) { const ps = await getPageSession(tabIndexOrWsUrl); state.consoleMessages.set(ps.sessionId, []); } return { enableConsoleLogging, getConsoleMessages, clearConsoleMessages }; } module.exports = { attachConsoleLogging };skills/browsing/lib/cookies.js582 Be7e16199adc1upstream ↗
/** * Cookie management — currently just a single "clear everything" action. * * Takes `getPageSession(tabIndexOrWsUrl)`: a resolver provided by chrome-ws-lib * that handles both tab-index and ws-url inputs, lazy-bootstraps the CDP bridge, * and returns a pageSession driving CDP via flatten mode. */ function attachCookies({ getPageSession }) { async function clearCookies(tabIndexOrWsUrl) { const ps = await getPageSession(tabIndexOrWsUrl); await ps.send('Network.clearBrowserCookies', {}); } return { clearCookies }; } module.exports = { attachCookies };skills/browsing/lib/credential-guard.js19 KB72900d16a21cupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/dialogs-render.js5.2 KBabda773d857aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/dialogs-router.js5.1 KBee662fb9d9a2upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/dialogs.js11 KBdd6696d9479fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/element-selector.js3.8 KB2f633956629eupstream ↗
/** * Element selector helpers — pure functions that produce JavaScript source * strings to be evaluated in the page via CDP Runtime.evaluate. * * No closure state, no Chrome session dependency. The returned strings are * interpolated into larger CDP `expression` payloads. */ // Generate element selection code (supports CSS and XPath). // Prefers visible elements (non-zero bounding rect) over hidden ones. // Falls back to first DOM match with a console.warn if all matches are hidden. // For XPath with text()='...', also tries normalize-space() fallback for mixed content elements. function getElementSelector(selector) { if (selector.startsWith('/') || selector.startsWith('//')) { // XPath selector - collect all matches, prefer visible const hasTextEquals = /text\(\)\s*=\s*['"]/.test(selector); const xpaths = [JSON.stringify(selector)]; if (hasTextEquals) { const fallbackSelector = selector.replace(/text\(\)\s*=\s*(['"])(.*?)\1/g, "normalize-space()=$1$2$1"); xpaths.push(JSON.stringify(fallbackSelector)); } return `(() => { var all = []; var seen = new Set(); [${xpaths.join(', ')}].forEach(function(xpath) { var iter = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null); var node; while (node = iter.iterateNext()) { if (!seen.has(node)) { seen.add(node); all.push(node); } } }); if (all.length === 0) return null; var visible = all.find(function(el) { var r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }); if (visible) return visible; console.warn('[superpowers-chrome] All ' + all.length + ' elements matching XPath have zero dimensions; using first match'); return all[0]; })()`; } else { // CSS selector - prefer visible elements return `(() => { var all = document.querySelectorAll(${JSON.stringify(selector)}); if (all.length === 0) return null; var visible = Array.from(all).find(function(el) { var r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }); if (visible) return visible; console.warn('[superpowers-chrome] All ' + all.length + ' elements matching ' + ${JSON.stringify(JSON.stringify(selector))} + ' have zero dimensions; using first match'); return all[0]; })()`; } } // Get all matching elements (used by multi-element warnings). // For XPath with text()='...', also tries normalize-space() fallback for mixed content elements. function getElementSelectorAll(selector) { if (selector.startsWith('/') || selector.startsWith('//')) { // XPath - get all matches, with fallback for text()='...' patterns const hasTextEquals = /text\(\)\s*=\s*['"]/.test(selector); if (hasTextEquals) { const fallbackSelector = selector.replace(/text\(\)\s*=\s*(['"])(.*?)\1/g, "normalize-space()=$1$2$1"); return `(() => { const result = []; const seen = new Set(); for (const xpath of [${JSON.stringify(selector)}, ${JSON.stringify(fallbackSelector)}]) { const iterator = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null); let node; while (node = iterator.iterateNext()) { if (!seen.has(node)) { seen.add(node); result.push(node); } } } return result; })()`; } return `(() => { const result = []; const iterator = document.evaluate(${JSON.stringify(selector)}, document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null); let node; while (node = iterator.iterateNext()) result.push(node); return result; })()`; } else { // CSS selector return `Array.from(document.querySelectorAll(${JSON.stringify(selector)}))`; } } module.exports = { getElementSelector, getElementSelectorAll };skills/browsing/lib/evaluation.js2.8 KB918950453b03upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/extraction.js12 KB760116ca2e94upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/file-upload.js1.8 KB0164592de725upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/html-diff.js3.3 KBd8402b0f1bc3upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/key-definitions.js4.7 KB804f4fd106f0upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/keyboard-input.js10 KB186f3a46ea87upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/mouse.js15 KB0dfa362984edupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/navigation.js11 KB48e1636c3dbcupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/page-scripts/dom-summary.js1.7 KB81b81681ead5upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/page-scripts/html-with-scrub.js32 KBdb9696af8219upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/page-scripts/markdown.js11 KB9cb82b4cfd94upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/page-scripts/permission-shim.js3.0 KBef387c98758cupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/page-scripts/rendered-text.js1.1 KBc5cb2079950bupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/page-session.js3.8 KBa059eb0ac41dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/profile-lock.js6.3 KB3996913266f9upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/screenshot.js6.3 KBac9a994f427eupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/secret-marker.js6.6 KB3fc2f7e0798bupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/secret-pattern.js19 KBdaef7a180ba9upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/select-option.js3.5 KBcea236861ba4upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/sensitive-url.js24 KB3b1694f1fd0fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/session-state.js2.8 KBf4c8b66c8c0aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/set-attribute.js9.9 KBd3e2221b81bfupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/tabs.js5.8 KBaf35f31ab798upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/viewport.js4.1 KB5606b66c509aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/lib/websocket-client.js4.3 KBe821a03a98dbupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/package.json249 Bc951bba0e1bfupstream ↗
{ "name": "chrome-ws", "version": "1.0.0", "description": "Lightweight Chrome DevTools Protocol client for direct browser control - zero dependencies", "bin": { "chrome-ws": "./chrome-ws" }, "engines": { "node": ">=16.0.0" } }skills/browsing/test-chrome-args.js3.2 KBde449c0ebaa9upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/test-cookies.js525 B403296bd964cupstream ↗
/** * Simple test for cookie functions * Run with: node test-cookies.js */ const chromeLib = require('./chrome-ws-lib.js').createSession(); async function testCookieFunctionsExist() { console.log('Testing that cookie functions are exported...'); if (typeof chromeLib.clearCookies !== 'function') { throw new Error('clearCookies not exported'); } console.log('All cookie functions exported'); } testCookieFunctionsExist().catch(err => { console.error('Test failed:', err.message); process.exit(1); });skills/browsing/test-e2e.sh1.1 KB0da934fa0869upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/test-extract.sh399 B3280e0a3f7acupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/test-interact.sh304 B8b4e651295daupstream ↗
#!/usr/bin/env bash # Test interaction commands echo "=== Navigate to search page ===" ./chrome-ws navigate 0 "https://google.com" echo "=== Fill search box ===" ./chrome-ws fill 0 "textarea[name=q]" "chrome devtools protocol" echo "=== Click search button ===" ./chrome-ws click 0 "input[name=btnK]"skills/browsing/test-navigate.sh243 B03c37fa95b73upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/test-raw.sh361 B6a3d46a7a802upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/test-tabs.sh336 Bf66c276a37fdupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/test-viewport.js750 Bced700f37a63upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/browsing/test-wait.sh233 B68c8230056edupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
Plain-text copy, no login: /skill-bundles/obra-superpowers-chrome-skills-browsing.md