CDP — Habilidad browser-harness-js
SDK de CDP personalizado (cada método de browser_protocol.json + js_protocol.json tiene un envoltorio tipado) más un pequeño servidor HTTP que mantiene una Session CDP persistente. La CLI browser-harness-js inicia automáticamente el servidor en el primer uso y envía fragmentos JS a él.
El SDK reside en el directorio sdk/ de la habilidad. En el resto de este documento, <directorio-habilidad> se refiere a donde npx skills add instaló la habilidad (Claude Code: ~/.claude/skills/cdp; Cursor: ~/.cursor/skills/cdp; otros agentes varían). La CLI debería estar en el PATH como browser-harness-js.
Configuración (una vez, primer uso)
npx skills add coloca la habilidad en el directorio de habilidades de tu agente, pero NO pone la CLI en el PATH. Antes de la primera llamada, verifica que sea accesible y crea un enlace simbólico en cualquier directorio de tu PATH si no lo está:
# macOS (Apple Silicon + Homebrew)
command -v browser-harness-js >/dev/null || ln -sf <directorio-habilidad>/sdk/browser-harness-js /opt/homebrew/bin/browser-harness-js
# macOS (Intel) / la mayoría de Linux — puede requerir sudo
command -v browser-harness-js >/dev/null || ln -sf <directorio-habilidad>/sdk/browser-harness-js /usr/local/bin/browser-harness-js
# Linux sin sudo (asegúrate de que ~/.local/bin esté en el PATH)
command -v browser-harness-js >/dev/null || { mkdir -p ~/.local/bin && ln -sf <directorio-habilidad>/sdk/browser-harness-js ~/.local/bin/browser-harness-js; }
La CLI instala automáticamente bun en la primera ejecución si falta (el servidor es nativo de Bun). Establece BROWSER_HARNESS_SKIP_BUN_INSTALL=1 para no hacerlo.
Cómo usar
Simplemente ejecuta browser-harness-js '<JS>'. La primera llamada inicia el servidor en segundo plano; las llamadas siguientes utilizan el mismo proceso y, por lo tanto, reutilizan la misma session, el mismo WebSocket con Chrome y cualquier variable global que hayas definido.
browser-harness-js 'await session.connect()'
browser-harness-js 'await session.Page.navigate({url:"https://example.com"})'
browser-harness-js '(await session.Runtime.evaluate({expression:"document.title",returnByValue:true})).result.value'
La salida es el contenido del resultado bruto — sin envoltorio {ok,result}.
| Tipo de resultado | stdout |
|---|---|
| string | texto sin formato, sin comillas JSON (ej. Example Domain) |
| number / boolean | 42, true |
| object / array (no vacío) | JSON compacto (ej. {"frameId":"..."}, [1,2,3]) |
undefined / null / "" / {} / [] | vacío (sin salida) |
Los errores van a stderr, código de salida 1. El mensaje de error CDP y la traza JS se imprimen textualmente, ej.:
Error: CDP -32602: invalid params
at _call (.../session.ts:117:33)
...
Detecta fallos con if browser-harness-js '...'; then ...; else handle_error; fi o verificando $?.
Fragmentos multi-línea mediante stdin (heredoc). Importante: un fragmento con varias sentencias NO retorna automáticamente la última expresión — escribe return X explícitamente. Los fragmentos de una sola expresión pasados como primer argumento SÍ retornan automáticamente.
browser-harness-js <<'EOF'
const tabs = await listPageTargets();
globalThis.tid = tabs[0].targetId;
await session.use(globalThis.tid);
return globalThis.tid;
EOF
Comandos de la CLI
| Comando | Comportamiento |
|---|---|
browser-harness-js '<js>' | Inicia automáticamente el servidor si es necesario, evalúa el JS, imprime el resultado. |
browser-harness-js <<EOF…EOF | Igual, código desde stdin. |
browser-harness-js --status | Imprime JSON de estado (tiempo de actividad, conectado, sessionId) o sale con código 1 si está caído. |
browser-harness-js --start | Inicio explícito (no hace nada si ya está en ejecución). |
browser-harness-js --stop | Apagado controlado. Borra el estado de la sesión. |
browser-harness-js --restart | Detener + iniciar de nuevo. |
browser-harness-js --logs | tail -f del registro del servidor (/tmp/browser-harness-js.log). |
Variables de entorno: CDP_REPL_PORT (por defecto 9876), CDP_REPL_LOG (por defecto /tmp/browser-harness-js.log).
Superficie API dentro de los fragmentos
Estas globales están precargadas — no se necesitan imports:
session— laSessionpersistente. Tiene todos los dominios CDP montados:session.Page,session.DOM,session.Runtime,session.Network, … 56 dominios, 652 métodos en total.listPageTargets()— lista los targets de página reales medianteTarget.getTargetsde CDP (funciona también en Chrome 144+), filtrando las URLchrome://ydevtools://. Sin argumentos — usa la sesión conectada.detectBrowsers()— escanea los directorios de perfil específicos del SO en busca de navegadores basados en Chromium en ejecución con depuración remota activada. Devuelve[{name, profileDir, port, wsPath, wsUrl, mtimeMs}], ordenados por el más recientemente lanzado.resolveWsUrl(opts)— resuelve una URL WS a partir de{wsUrl}|{port, host?}|{profileDir}. Para el flujo de detección automática sin argumentos, llama directamente asession.connect().CDP— los espacios de nombres generados (CDP.Page,CDP.Runtime, …) para referencia de nombres de tipos.
Llamar a un método CDP
Cada método toma un único argumento de objeto que coincide con los parámetros wire de CDP; se resuelve al valor de retorno tipado (sin envoltorio result, sin correlación id — se maneja por ti).
// sin parámetros
await session.DOM.enable()
// parámetros requeridos
await session.Page.navigate({ url: 'https://example.com' })
// parámetros todos opcionales (el objeto también es opcional)
await session.Page.captureScreenshot()
await session.Page.captureScreenshot({ format: 'png', quality: 80 })
// los retornos se reducen a la forma tipada
const { root } = await session.DOM.getDocument()
const { nodeId } = await session.DOM.querySelector({ nodeId: root.nodeId, selector: 'h1' })
Conexión
Por defecto: simplemente llama a session.connect() sin argumentos. Detecta automáticamente los navegadores basados en Chromium en ejecución (Chrome, Chromium, Edge, Brave, Arc, Vivaldi, Opera, Comet, Canary) escaneando los directorios de perfil específicos del SO en busca de un archivo DevToolsActivePort, ordenados por el más recientemente lanzado, y elige el primero cuyo WebSocket acepte la conexión. Agnóstico al SO — funciona en macOS, Linux y Windows.
await session.connect() // detección automática
Usa detectBrowsers() primero si quieres ver qué está disponible (o dejar que el usuario elija) antes de conectarte:
const found = await detectBrowsers()
// [{ name: 'Google Chrome', profileDir, port, wsPath, wsUrl, mtimeMs }, ...]
Formas explícitas — úsalas solo cuando la detección automática elija el navegador incorrecto, o cuando ya sepas dónde conectarte:
| Forma | Cuándo usarla |
|---|---|
{ profileDir } | Apuntar a un navegador específico cuando hay varios en ejecución. Lee <profileDir>/DevToolsActivePort directamente. |
{ wsUrl } | Ya tienes ws://…/devtools/browser/<uuid> (ej. canalizado desde otro lugar). |
await session.connect({ profileDir: '/Users/<tu>/Library/Application Support/Google/Chrome' })
await session.connect({ wsUrl: 'ws://127.0.0.1:9222/devtools/browser/<uuid>' })
Rutas de perfil por SO — úsalas con { profileDir }:
- macOS:
~/Library/Application Support/<Navegador>(ej.Google/Chrome,Comet,BraveSoftware/Brave-Browser,Arc/User Data) - Linux:
~/.config/<navegador>(ej.google-chrome,chromium,BraveSoftware/Brave-Browser) - Windows:
%LOCALAPPDATA%\<Navegador>\User Data(ej.Google\Chrome,Microsoft\Edge,BraveSoftware\Brave-Browser)
El tiempo de espera por candidato para la apertura WS es por defecto de 5s — los navegadores activos responden con open/close en ~100ms, por lo que 5s ya es generoso. El único caso en que 5s es demasiado corto es cuando Chrome muestra la ventana emergente Permitir y espera a que el usuario haga clic. Si esperas eso, pasa timeoutMs: 30000:
await session.connect({ profileDir: '/Users/<tu>/Library/Application Support/Google/Chrome', timeoutMs: 30_000 })
Si ves No detected browser accepted a connection — los navegadores tienen archivos DevToolsActivePort, pero ninguno está sirviendo WS actualmente. La causa más común: la depuración remota está habilitada pero el usuario no ha hecho clic en Permitir en la solicitud. Indícale que haga clic en Permitir y luego reintente (o aumenta timeoutMs).
Seleccionar un target (pestaña)
Después de connect(), llama a session.use(targetId) una vez; las llamadas posteriores a nivel de página (Page/DOM/Runtime/Network/etc.) se enrutan automáticamente al sessionId de ese target. Las llamadas Browser.* y Target.* siempre van al endpoint del navegador.
const tabs = await listPageTargets() // sin argumentos; usa la sesión conectada
const sid = await session.use(tabs[0].targetId)
await session.Page.enable()
await session.Page.navigate({ url: 'https://example.com' })
listPageTargets() usa Target.getTargets de CDP (no /json), por lo que funciona también en Chrome 144+. Ya filtra las URL chrome:// y devtools://. Llamada bruta equivalente:
const { targetInfos } = await session.Target.getTargets({})
const tabs = targetInfos.filter(t => t.type === 'page' && !t.url.startsWith('chrome://') && !t.url.startsWith('devtools://'))
Para cambiar de pestaña: session.use(otroTargetId). Para desconectar: session.setActiveSession(undefined).
Eventos
// Suscribirse (devuelve una función para cancelar la suscripción)
const off = session.onEvent((method, params, sessionId) => { ... })
// O esperar un único evento que coincida con un predicado opcional y tiempo de espera
await session.Network.enable()
const ev = await session.waitFor(
'Page.frameNavigated',
(p) => p.frame.url.includes('example.com'),
10_000
)
Persistir el estado entre llamadas
Cada fragmento se ejecuta dentro de su propio envoltorio asíncrono, por lo que sus declaraciones let/const desaparecen cuando retorna. Para transportar datos hacia adelante, asigna a globalThis:
browser-harness-js '(await listPageTargets()).forEach((t,i)=>globalThis["tab"+i]=t.targetId)'
browser-harness-js 'await session.use(globalThis.tab0)'
browser-harness-js 'await session.Page.navigate({url:"https://example.com"})'
La propia session, el sessionId activo y los suscriptores de eventos ya son preservados por el servidor — las globales solo son necesarias para datos ad-hoc.
Conectarse a un Chrome en ejecución (flujo chrome://inspect)
Al adjuntarse al navegador ya en ejecución del usuario:
- Prueba
await session.connect()primero (sin argumentos) — la detección automática maneja todos los navegadores basados en Chromium medianteDevToolsActivePort. Si retorna, ya está. - Si la detección automática falla con
No running browser with remote debugging detected, el usuario debe activarlo. Abre la página de inspección:Solo la ruta de AppleScript en macOS evita el selector de perfiles; Linux/Windows pueden solicitar al usuario que elija un perfil primero.# macOS — prefiere AppleScript sobre `open -a` (reutiliza el perfil actual, evita el selector de perfiles) osascript -e 'open location "chrome://inspect/#remote-debugging"' # Linux google-chrome 'chrome://inspect/#remote-debugging' # o: chromium, google-chrome-stable # Windows (PowerShell) Start-Process chrome 'chrome://inspect/#remote-debugging' - Marca "Descubrir destinos de red" en chrome://inspect, luego haz clic en Permitir cuando Chrome lo solicite.
- Si la detección automática elige el navegador incorrecto (varios en ejecución, quieres uno específico): enuméralos con
await detectBrowsers(), luegoawait session.connect({ profileDir: <el que quieres> }). - Si
session.connect()retornaNo detected browser accepted a connection, el usuario tiene la depuración remota activada pero aún no ha hecho clic en Permitir. Indícale que haga clic y reintente, o pasatimeoutMs: 30000para esperar el clic.
Trabajar con targets (pestañas)
- Filtrar internos de Chrome.
listPageTargets()ya elimina las URLchrome://ydevtools://. Si llamas aTarget.getTargets()directamente, filtra manualmente. - El orden de los targets CDP ≠ el orden visible de la barra de pestañas. Cuando el usuario dice "la primera pestaña que veo", usa una captura de pantalla o el título de la página para identificarla —
Target.activateTargetsolo cambia a un targetId conocido.
Buscar un método
La superficie tipada completa está en <directorio-habilidad>/sdk/generated.ts (~655 KB, solo se carga si la lees). Cada método tiene su descripción CDP como comentario JSDoc más las interfaces *Params / *Return tipadas en los espacios de nombres por dominio.
grep -n "navigate" <directorio-habilidad>/sdk/generated.ts | head
Regenerar el SDK
Cuando cambien los JSON del protocolo upstream, reemplaza sdk/browser_protocol.json y/o sdk/js_protocol.json y vuelve a ejecutar:
cd <directorio-habilidad>/sdk && bun gen.ts
browser-harness-js --restart # recoge los nuevos bindings
Archivos
Todas las rutas son relativas a <directorio-habilidad> (la ruta de instalación — ver el inicio de este documento).
/usr/local/bin/browser-harness-js→<directorio-habilidad>/sdk/browser-harness-js(la CLI)sdk/repl.ts— Servidor HTTP (Bun.serveen127.0.0.1:9876)sdk/session.ts— ClaseSession(transporte, conexión, enrutamiento de targets, eventos)sdk/generated.ts— Salida de generación de código: cada método CDP como un envoltorio tipadosdk/gen.ts— Script de generación de códigosdk/{browser,js}_protocol.json— Protocolo upstream (vendido)