AI Browser

Agentes de navegador Electron nativos de IA operan a través de un árbol de accesibilidad semántica: 17 herramientas MCP browse_*, sin capturas de pantalla/OCR/adivinanzas de coordenadas. Entrada CDP confiable, verificación de acciones mediante instantáneas/diferencias, y una ventana compartida humano/IA que nunca roba el foco. stdio local vía npx.

Documentación

AI Browser

Un navegador real cuyo único operador es tu agente.
Lee las páginas como un árbol semántico en lugar de capturas de pantalla, actúa mediante entrada de navegador confiable — y como es una ventana normal de Electron visible, un humano puede tomar el mouse en cualquier momento.

CI MIT Electron Platforms Tests MCP

Un agente, sin coordenadas: abre Bing → escribe mediante eventos de teclado confiables (sugerencias en vivo) → desplázate por los resultados → abre la página 2 → vuelve a la página 1 → abre el sitio del proyecto en una pestaña nueva → sigue desplazándote allí. Cada objetivo se encontró en el árbol semántico por rol/etiqueta.
AI Browser demo: search, paginate, and open a website — all driven by the semantic tree
Reproducir: npm start luego node examples/demo.mjs

Qué es. Una aplicación Electron que expone sus pestañas a través de dos interfaces equivalentes: un servidor MCP (17 browse_* herramientas) y una API JSON-RPC WebSocket sin procesar (ws://localhost:9223, métodos ui.*). Tu agente navega, lee un árbol semántico estructurado (role / label / value / states / actions), y hace clic o escribe mediante data-ai-id. No hay paso de captura de pantalla ni OCR: src/ no contiene ninguna llamada de captura en absoluto.

Qué no es. No es headless. La ventana realmente se abre, realmente renderiza y permanece interactiva — consulta Dejar que un humano intervenga. Tampoco "conduce contigo": la transferencia es una conversación entre el agente y el usuario, no una API del navegador.

Nota de seguridad. El servidor WebSocket no tiene autenticación: escucha en localhost y confía en quien se conecte. No expongas el puerto 9223 más allá de loopback y evita redes no confiables.


Instalación

Requiere Node ≥ 18 y una sesión de escritorio (esta es una aplicación GUI).

Opción A — npx (recomendada para usuarios de MCP)

El paquete se publica como @glittering0717/ai-browser; no se necesita clonar:

npx -y @glittering0717/ai-browser --help

Para abrir la ventana visible del navegador directamente:

npx -y @glittering0717/ai-browser start

El primer lanzamiento prepara el motor Electron (~100 MB, descargado una vez). npm ≥ 11 bloquea los scripts de instalación de dependencias por defecto: el lanzador lo detecta y ejecuta el descargador propio de Electron, así que no se necesita nada extra. Si la descarga desde los lanzamientos de GitHub es lenta o está bloqueada (común en redes de China continental), apúntala a un espejo:

ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npx -y @glittering0717/ai-browser start

Opción B — desde el código fuente

git clone https://github.com/Glittering/ai-browser.git
cd ai-browser
npm install

Si la descarga del binario de Electron es lenta o falla por completo, usa el espejo:

ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm install

O hazlo permanente creando un .npmrc en la raíz del proyecto:

electron_mirror=https://npmmirror.com/mirrors/electron/

Confirma que el binario realmente aterrizó antes de culpar a otra cosa: un binario faltante aún te deja con una "instalación que tuvo éxito":

ls node_modules/electron/dist

Para verificar la instalación de extremo a extremo:

npm start

Se abre una ventana de 1280×800 y la terminal imprime AI Browser WS server listening on ws://localhost:9223. (La primera pestaña se abre en la página de inicio predeterminada; ignórala y navega a otro lugar).


Conecta tu agente

Agrega esto al archivo de configuración de tu cliente MCP: la configuración de Claude Desktop, ~/.cursor/mcp.json, o un .mcp.json a nivel de proyecto:

{
  "mcpServers": {
    "ai-browser": {
      "command": "npx",
      "args": ["-y", "@glittering0717/ai-browser", "mcp"]
    }
  }
}

Una copia lista está en examples/mcp-config.json. Los usuarios desde el código fuente pueden en su lugar apuntar command: "node" a /absolute/path/to/ai-browser/src/main/mcp_server.js.

No necesitas iniciar el navegador tú mismo. En la primera llamada a browse_*, el servidor MCP sondea el puerto 9223; si nada está escuchando, lanza Electron de forma separada y espera hasta 30 segundos por el servidor WebSocket (la primera llamada puede además dedicar tiempo a preparar el motor). Si prefieres mantener una ventana de larga duración compartida por todos los clientes, ejecuta npx -y @glittering0717/ai-browser start (o npm start desde el código fuente) en una terminal: un segundo lanzamiento solo enfoca la ventana existente.

examples/AGENT_INSTRUCTIONS.md tiene un prompt de copiar y pegar que puedes colocar en un CLAUDE.md, Cursor Rule, o cualquier prompt de sistema; enseña el bucle de herramientas, los parámetros que ahorran tokens y cuándo preguntar a un humano. Los agentes que se instalan a sí mismos (Cline, etc.) pueden leer el breve llms-install.md orientado a máquinas en lugar de este README.


Las herramientas MCP

Fuente de verdad: src/main/mcp_tools.js. Hay 17: una superficie deliberadamente pequeña, ya que cada esquema de herramienta se inyecta en el contexto de tu agente en cada turno.

HerramientaUna línea
browse_navigateCarga una URL en la pestaña activa (o dada).
browse_get_treeLee la página como un árbol semántico + contexto de página (modales, mensajes, estadísticas).
browse_actclick / type (agregar en el cursor) / setContent (reemplazar todo) / clear / focus / hover / scroll_to / upload / get_value (solo lectura) / drag / press / wheel en un data-ai-id.
browse_diffsnapshot una línea base, luego diff para obtener rutas agregadas / eliminadas / cambiadas con valores antes/después. Así es como demuestras que una acción hizo algo.
browse_capabilitiesSondea qué ofrece una página y qué ruta tomar en ella (ver abajo).
browse_repaintFuerza al compositor a redibujar la pestaña cuando la ventana muestra una página obsoleta; devuelve repaint_count.
browse_downloadsLista las descargas de una pestaña con sus rutas en disco. Las descargas son asíncronas: sondea esto después de hacer clic en un botón de exportación.
browse_canvasLee <canvas> a través de llamadas de dibujo capturadas: list / read / configure / capture. Experimental: consulta la sección de canvas abajo.
browse_evaluateEjecuta una expresión JS en el contexto de la página (límite de 5000 caracteres, identificadores de Node rechazados).
browse_scrollDesplázate por la página o desplaza un elemento a la vista.
browse_waitSondea hasta que button_enabled / modal_appeared / text_contains / url_contains.
browse_list_tabsLista las pestañas abiertas (id, url, título, activa).
browse_new_tabAbre una pestaña nueva, opcionalmente en una URL.
browse_close_tabCierra una pestaña por id.
browse_set_active_tabCambia en qué pestaña actúan las llamadas posteriores.
browse_networkInspección de red estilo Chrome-DevTools: list (filtrar por método / URL / estado / tipo), get (encabezados y cuerpos de solicitud + respuesta), clear, configure.
browse_quitApaga todo el navegador y libera el proceso.

Pruébalo: browse_diff

Una herramienta que informa success no es evidencia de que algo cambió: el navegador te dirá felizmente que un clic aterrizó en un elemento que lo ignoró, que un arrastre se completó cuando la aplicación colocó el nodo en otro lugar por completo, o que una pulsación de tecla entró en un campo que luego se limpió solo. Cada escritura debe envolverse en un par de instantánea/diferencia:

const base = await browse_diff({ operation: "snapshot", js: "app.graph.serialize()" });
await browse_act({ action: "click", target: "e:button-25" });
const d = await browse_diff({ operation: "diff", snapshot: base.snapshot });
// d.verdict === "changed"
// d.changed_paths → [{ path: "nodes[7].widgets.steps", from: 20, to: 21, reason: "value" }]
  • verdict es un "changed" / "unchanged" explícito, así que "no pasó nada" nunca se infiere de un array vacío que podrías haber construido mal.
  • Cada entrada lleva un path más ambos valores, para que puedas verificar por qué cambió.
  • Los arrays se comparan elemento por elemento por índice, no como conjuntos: un nodo movido se lee como entradas cambiadas, nunca como "uno eliminado + uno agregado" (que parecería dos eventos en lugar de un reordenamiento).
  • El ruido de flotantes se tolera (201354.48743718592 vs ...1859 no es un cambio), pero un desplazamiento real de 22 unidades de coordenadas sí lo es.
  • Omite js para una instantánea DOM independiente del sitio: rol / etiqueta / valor / deshabilitado / tamaño por elemento más el texto visible de la página. No se requiere JS por sitio, y los cambios simples de texto de estado <div> se detectan.
  • Omite js y también toma la huella del modelo de datos propio de la aplicación cuando la página cuelga uno de window. El DOM de una aplicación renderizada en canvas es una fotografía fija: arrastrar un nodo o editar un widget repinta <canvas> y no mueve nada que el recorrido de elementos pueda ver, así que una diferencia solo-DOM responde unchanged a una escritura que demostrablemente ocurrió. La ruta de escritura documentada de ComfyUI (node.widgets[].value) movió el DOM exactamente cero bytes. Con la huella, esa misma escritura aparece como model.fp (y model.count cuando el conteo de nodos se movió). Es de solo lectura (loadGraphData es un cargador y nunca se llama), elige el candidato que realmente se puede leer de vuelta en lugar del que parece más prometedor, y un espacio de nombres de biblioteca — que puntúa bien en nombres y no tiene modelo que devolver — se omite.
  • rearm: true mueve la línea base hacia adelante para la próxima diferencia, así puedes hacer un bucle de "afirmar que nada cambió" a lo largo de muchos pasos; forget: true la elimina.
  • Una expresión que lanza, un id faltante o una comparación entre pestañas devuelven un error explícito. Nunca se degradan a una instantánea vacía, lo que haría que cada diferencia posterior informara "sin cambios": el único modo de fallo que convierte toda verificación en verde falso.

¿Qué ruta en esta página? browse_capabilities

Antes de improvisar en un sitio desconocido, pregunta. Una llamada informa el tamaño del árbol semántico y su histograma de roles/acciones, el conteo de canvas, los iframes del mismo origen y su tamaño interno, los objetos que la aplicación cuelga de window (auto-detectados por forma: has .graph / ._nodes / getState / serialize), los endpoints JSON que ya ha llamado, y una ruta recomendada con la evidencia para ella:

  • L1_structured_data — la aplicación expone su propio modelo; lee y escribe todo el grafo como JSON.
  • L2_semantic_tree — suficientes elementos para encontrar objetivos por rol/etiqueta y manejarlos mediante data-ai-id.
  • L3_pointer_gestures — solo para lo que no tiene ruta estructurada (arrastrar un nodo, dibujar una conexión, desplazamiento con rueda/zoom). Siempre sigue con browse_diff.

También señala trampas explícitamente: "el body está en un iframe del mismo origen" o "hay 7 canvas pero solo 17 elementos semánticos — deja de intentar golpear elementos dibujados en canvas con coordenadas y ve a buscar el modelo de datos en su lugar".

Notas que ahorran viajes de ida y vuelta:

  • browse_get_tree acepta subset: "interactive" para descartar ramas solo de diseño, mode: "diff" para devolver solo nodos que aparecieron/desaparecieron desde la última lectura, y ax: true para leer a través de la capa de accesibilidad (los enlaces entonces llevan url).
  • type añade; setContent reemplaza. Antes eran idénticos (ambos reemplazaban todo el contenido), lo que hacía imposible la edición incremental — inserta una etiqueta de tema estilo Weibo y el siguiente type la borraba. Ahora type escribe en el cursor (params.at: end (predeterminado) / start / cursor), así que "inserta un tema, luego sigue escribiendo el cuerpo" funciona. Para rellenar un campo, usa setContent (o clear primero).
  • browse_act también acepta action: "get_value" — una forma solo de lectura de obtener el texto completo de un elemento, lo cual importa porque browse_get_tree trunca valores largos a 200 caracteres (ver abajo).
  • Los gestos de puntero son de primera clase: drag, press y wheel. Las interfaces de canvas / pizarra / ordenables se construyen sobre ellos, y ninguna cantidad de evaluate puede sustituirlos — los PointerEvent sintéticos no mueven un framework que usa Pointer Capture (medido en un canvas de React Flow: la posición del nodo cambió en exactamente 0,0), así que estos pasan por entrada confiable de CDP.
    • drag: dx/dy, to_x/to_y, o to_target (soltar sobre otro elemento). from_anchor / to_anchor (left/right/top/bottom/center) agarran un lado — un lado prefiere el manejador de conexión de ese elemento, pero solo si el manejador es realmente comprobable por hit-test (ver la nota abajo). from_x/from_y comienzan desde un punto desnudo (panorámica de canvas vacío), en cuyo caso no se necesita target.
    • press: key (Delete, Escape, ArrowRight, Enter, … o una combinación como "Meta+a"), o keys para una secuencia. Eliminar un nodo, deshacer, deseleccionar — todo solo con teclado.
    • wheel: delta_x / delta_y más hold: ["Control"] para zoom del canvas. browse_scroll no puede hacer esto — llama a window.scrollBy, que un canvas infinito ignora por completo.
    • El resultado del arrastre informa drag_from / drag_to y qué elemento realmente está en cada punto (from_at_id), además de warning + start_hit_target: false cuando la presión aterrizó en un elemento diferente — la superposición visual de otro modo parece exactamente "arrastrar no hace nada".
  • actions se infiere del comportamiento, no de role. Un elemento cuyo cursor calculado es un cursor de arrastre, o que tiene draggable="true" / cursor: pointer / un onclick, ahora anuncia drag / click en el árbol incluso cuando su rol es group. Sin esto, un canvas lleno de nodos role: group informaba actions: [] y el agente no podía saber que eran interactivos en absoluto.

  • browse_network captura solicitudes desde el momento en que se abre una pestaña, incluidos los cuerpos de solicitudes POST — no se necesita suscripción. Los encabezados de solicitud están redactados por defecto (authorization / cookie → [REDACTED]); pasa include_sensitive_headers: true para verlos.
  • La mayoría de las herramientas aceptan un tab opcional para trabajo con múltiples pestañas; sin él, apuntan a la pestaña activa.

Dejar que un humano intervenga

No roba tu foco

El agente funciona bien mientras la ventana está en segundo plano. La entrada confiable de CDP (Input.dispatchKeyEvent / Input.dispatchMouseEvent) no necesita que la ventana esté al frente, y la aplicación ya se ejecuta con disable-backgrounding-occluded-windows, disable-renderer-backgrounding y disable-background-timer-throttling, así que una ventana en segundo plano no se limita ni pierde eventos.

Eso ya era cierto para act / get_tree / evaluate / peek / scroll — pero dos rutas solían arrastrar la ventana al frente en cada llamada: new_tab y set_active_tab (llamaban a webContents.focus(), que activa toda la ventana). Además, la ventana se mostraba con show() al arrancar incluso cuando el agente la lanzaba. Ambos están corregidos:

  • new_tab / set_active_tab ya no toman el foco de la ventana. La página aún piensa que está enfocada — Emulation.setFocusEmulationEnabled mantiene document.hasFocus() y la carga diferida dependiente del foco comportándose con normalidad — mientras el foco del teclado permanece donde lo dejaste.
  • Cuando el agente lanza el navegador (a través de MCP), la ventana aparece mediante showInactive(): visible, pero sin robar el foco. Cuando tú ejecutas npm start, aparece al frente como siempre.

Así que la ventana permanece visible — ese es el punto — pero no se arrastra al frente mientras el agente la conduce. Puedes seguir trabajando.

AI_BROWSER_FOCUS=always npm start   # pre-1.2 behaviour: show + moveTop + focus on every action
AI_BROWSER_FOCUS=never npm start    # never change focus (the window is still shown)

El valor predeterminado es auto: mantener la ventana visible (restaurarla si estaba minimizada u oculta), pero nunca robar el foco. Cualquiera que sea el valor, la ventana siempre se muestra — eso es un invariante del producto.

npm run focus afirma todo esto desde el exterior (macOS lsappinfo, sin necesidad de permiso de accesibilidad) e incluye un grupo de control deliberado configurado para robar el foco — así que la suite se pondría en rojo si la medición dejara de funcionar, en lugar de pasar de forma vacua.

También prueba la afirmación más fuerte — que la ventana no necesita estar en primer plano en absoluto. Con otra aplicación manteniendo el primer plano, ejecuta una porción representativa de la API y verifica el resultado real de cada una: la escritura aterriza, un clic se dispara exactamente una vez, get_tree devuelve un árbol, el canvas getImageData devuelve píxeles reales, network_list ve la solicitud, navigate se completa — y después de permanecer en segundo plano durante 10 segundos, aún hace todo lo anterior sin salir al frente.

Nota sobre document.hasFocus(): devuelve true incluso cuando la ventana está en segundo plano, porque Emulation.setFocusEmulationEnabled está deliberadamente activado — eso es lo que mantiene la carga diferida dependiente del foco y las animaciones sin detenerse. Así que no es una forma válida de detectar "¿estoy en primer plano?"; la prueba usa el observador externo en su lugar.


Esta es la parte más fácil de malinterpretar, así que claramente: el agente conduce el navegador, y el humano siempre puede tomar el control de la ventana.

Debido a que es una ventana real de Electron, una persona puede hacer clic, escribir, desplazarse e iniciar sesión exactamente como lo haría en Chrome — con el mouse y el teclado, en cualquier momento, sin necesidad de pedir permiso. Eso cubre las cosas que un agente genuinamente no puede hacer:

  • Inicio de sesión / SSO y cualquier cosa detrás de una contraseña que el agente no debería tener
  • Escaneos de códigos QR (autorización de app a web)
  • CAPTCHAs de deslizador e imagen, códigos SMS o de correo electrónico
  • Confirmación de pago, intersticiales de control de riesgo, pantallas de OTP
  • Cualquier cosa donde continuar significaría fabricar credenciales

Cómo funciona la transferencia: no existe — en el navegador. Nada la coordina del lado de AI Browser, y nada necesita hacerlo. Es una conversación entre el agente y su usuario:

  1. El agente encuentra algo que no puede hacer.
  2. Lo dice en el chat, claramente: "Por favor, escanea el código QR en la ventana del navegador, luego responde 'listo'."
  3. Se detiene y espera la respuesta — sin bucles de reintento en clics que no pueden funcionar.
  4. Después de "listo", vuelve a leer con browse_get_tree para confirmar el nuevo estado, y luego continúa.

Consulta examples/with-human.mjs para el equivalente con script: imprime un mensaje y espera Enter a través de readline. El navegador no tiene idea de que eso sucedió.

Dos consecuencias prácticas para el agente:

  • Nunca llames a browse_quit mientras un humano trabaja en la ventana. Derriba la ventana y sale del proceso — cualquier cosa a medio resolver se pierde. Sal cuando la tarea esté terminada, no antes.
  • El estado de inicio de sesión persiste. Las pestañas viven en la partición de sesión persist:ai-browser, así que las cookies y las sesiones sobreviven a un reinicio; usualmente un humano solo tiene que intervenir una vez por sitio por máquina.

API WebSocket cruda

MCP es un adaptador delgado de stdio. Cualquier cosa que MCP pueda hacer es alcanzable directamente a través de JSON-RPC 2.0 en ws://localhost:9223 desde cualquier lenguaje. Fuente de verdad: el interruptor de métodos en src/main/ws_server.js.

MétodoPropósitoSe asigna a
ui.get_treeÁrbol semántico (+ contexto). Parámetros: focusedOnly, ax, subset, mode, tabbrowse_get_tree
ui.actparams.action + params.target; argumentos por acción anidados bajo params.paramsbrowse_act
ui.navigateCargar una URL (params.url, tab opcional). Devuelve {ok, error?} — {ok:false} siempre lleva una razón.browse_navigate
ui.snapshot / ui.diffCapturar una línea base (params.js, label opcional) y comparar contra ella (params.snapshot, rearm, forget)browse_diff
ui.capabilitiesSondear la página: histograma del árbol, canvas, iframes, globales de la app, endpoints, ruta recomendadabrowse_capabilities
ui.repaintForzar al compositor a redibujar una pestaña; devuelve repaint_count para que un repintado sea comprobablebrowse_repaint
ui.evaluateEjecutar JS en el contexto de la página, misma protección que MCPbrowse_evaluate
ui.scrollDesplazar página o elemento (direction, amount, target). Solo desplazamiento de documento — para apps de canvas usa ui.act{action:'wheel'}.browse_scroll
ui.waitSondeo hasta una condición o tiempo de esperabrowse_wait
ui.new_tab / ui.close_tab / ui.list_tabs / ui.set_active_tabGestión de pestañasbrowse_*_tab
ui.subscribe / ui.unsubscribeSuscripción a eventos; params.events, "*" para todos— (solo WS — ver abajo)
ui.downloadsDescargas de esta pestaña con rutas en disco (state, received_bytes, dir)browse_downloads
ui.get_focusedEl subárbol del elemento enfocado—
ui.peekPasar el cursor sobre una función plegada/solo hover y devolver el diff, sin confirmar—
ui.network_bodyCuerpo de respuesta por subcadena de URL (url_pattern)— (superado por browse_network)
ui.quitApagado elegantebrowse_quit

Nota dos diferencias con la ortografía de MCP, ambas visibles en el interruptor anterior: es focusedOnly (camelCase), no focused_only; y ui.act anida sus argumentos por acción bajo params.params. ui.subscribe / ui.unsubscribe también son válidos como notificaciones sin id.

ui.subscribe está deliberadamente no expuesto como herramienta MCP: la ruta stdio de MCP descarta las notificaciones de servidor→cliente, así que un llamador de MCP se suscribiría y luego nunca recibiría un solo evento. Los eventos en tiempo real son una capacidad solo de WebSocket crudo. Para cualquier cosa basada en sondeo, usa browse_network en su lugar.

import WebSocket from 'ws';

const ws = new WebSocket('ws://localhost:9223');
let id = 0;
const call = (method, params) => new Promise((resolve, reject) => {
  const myId = ++id;
  const onMsg = (raw) => {
    const m = JSON.parse(raw.toString());
    if (m.id === myId) { ws.off('message', onMsg); m.error ? reject(new Error(m.error.message)) : resolve(m.result); }
  };
  ws.on('message', onMsg);
  ws.send(JSON.stringify({ jsonrpc: '2.0', id: myId, method, params }));
});

ws.on('open', async () => {
  await call('ui.navigate', { url: 'https://example.com' });
  const { tree } = await call('ui.get_tree', { subset: 'interactive' });
  console.log(tree.role, tree.children?.length);
  ws.close();
});

Los códigos de error siguen JSON-RPC: -32700 error de análisis, -32601 método desconocido, -32602 parámetros inválidos (la protección ui.evaluate devuelve esto), -32603 error interno.


Cómo funciona

   agent ── MCP stdio (17 browse_*) ──┐
   agent ── WS JSON-RPC :9223 (ui.*) ─┤
                                      ▼
 ┌──────────────────────────────────────────────────┐
 │ Electron main                                    │
 │   index.js        window · tab strip · watchdog  │
 │   ws_server.js    JSON-RPC routing, event stream │
 │   page_manager.js tabs · id-matched IPC · CDP    │
 └───────────────┬──────────────────────────────────┘
                 │ IPC (request-id matched)
 ┌───────────────▼──────────────────────────────────┐
 │ preload (contextIsolation:true, nodeIntegration:false)
 │   bridge.cjs · extractor.cjs · actions.cjs ·     │
 │   watcher.cjs  (captcha / message / state scan)  │
 └───────────────┬──────────────────────────────────┘
                 ▼
              live DOM → semantic tree

Lectura. Cada nodo en el árbol devuelto lleva un data-ai-id — el id del DOM propio del elemento cuando lo tiene, de lo contrario un e:tag-N generado. Los ids son estables entre lecturas: un elemento que ya lleva uno lo conserva, y los ids generados provienen de un contador monótono, por lo que insertar un elemento por encima de otros no los renumerará. (Antes sí lo hacía: un id obsoleto se resolvía silenciosamente a un elemento diferente, y la acción reportaba éxito mientras golpeaba lo incorrecto). Se reinician en la navegación. Junto al id vienen role, label, value, states y actions, más un bloque context — modales abiertos, mensajes transitorios, sugerencias de campos obligatorios, estadísticas aproximadas. Existen dos rutas de lectura: el extractor de precarga predeterminado, y la capa de accesibilidad detrás de ax: true (que además da a los enlaces su url).

La primera lectura de una página anota elementos con data-ai-id; esa es la única escritura que realiza una lectura, y es lo que hace que las acciones posteriores sean resolubles. Las lecturas repetidas no escriben nada. Un atributo solo se establece cuando su valor realmente cambia, porque Chromium real entrega un registro MutationObserver incluso cuando estableces un atributo al valor que ya tiene — medido en 4001 registros por lectura en una página de 4000 elementos antes de que existiera esta protección. Una lectura que inunda los observadores de la página es una lectura que perturba lo que está midiendo.

Escritura. Las acciones son entrada de navegador confiable, no JS sintetizado. El proceso principal impulsa la página a través del Protocolo de Chrome DevTools — Input.dispatchMouseEvent para clics, y por carácter Input.dispatchKeyEvent (rawKeyDown → char → keyUp) para escribir — luego valida que el clic realmente aterrizó a través de elementFromPoint. Debido a que los eventos llegan como entrada real de teclado y mouse, Draft.js, ProseMirror, CKEditor, Quill y <textarea> simple funcionan a través de la misma ruta sin ramas específicas por framework.

Donde es delgado. extractor.cjs es una heurística sobre el DOM, no una especificación de sala limpia: maneja páginas ordinarias bien y etiquetará mal las exóticas. La ventana se ejecuta con aceleración de hardware desactivada más tres interruptores de Chromium (--disable-backgrounding-occluded-windows, --disable-renderer-backgrounding, --disable-background-timer-throttling) para que una ventana de IDE encima del navegador no cause que la entrada inyectada se descarte silenciosamente.


Ejemplos

Los scripts ejecutables viven en examples/ y se conectan directamente a ws://localhost:9223:

ArchivoQué muestra
mcp-config.jsonConfiguración mínima de cliente MCP; reemplaza la ruta del marcador de posición.
AGENT_INSTRUCTIONS.mdInstrucciones de copiar y pegar para tu agente, incl. el protocolo de traspaso humano.
raw-ws.mjsCliente WebSocket sin procesar más pequeño posible.
read-page.mjsCarga una página, imprime sus elementos interactivos.
with-human.mjsFlujo completo para cuando un humano tiene que intervenir.
node examples/read-page.mjs

Solución de problemas

La página se actualizó pero la ventana aún muestra la anterior. Esto solía requerir hacer clic en las pestañas de un lado a otro, porque lo único que forzaba al compositor a redibujar un BrowserView era setActiveTab haciendo removeBrowserView + addBrowserView + setBounds — la navegación nunca lo hacía. Ahora cada señal de navegación lo activa: did-navigate, did-navigate-in-page (los cambios de ruta SPA no disparan ningún evento de carga), did-frame-finish-load (un editor de canvas dentro de un iframe arranca mucho después de que el documento superior terminó), más la ventana show / restore / focus — lo que importa aquí porque esta ventana está diseñada para nunca robar tu enfoque, por lo que se sienta detrás de tu editor durante minutos a la vez. Si aún se ve obsoleto, ui.repaint lo fuerza bajo demanda y devuelve repaint_count para que puedas confirmar que un repintado realmente ocurrió.

Una acción reportó éxito pero nada cambió. Dos causas que vale la pena conocer:

  • Sobre WS sin procesar, ui.act anida sus argumentos por acción bajo params.params — {action:"type", target:"<id>", params:{text:"…"}}. Flattening it to {text:"…"} pasa una cadena vacía, que solía golpear el cortocircuito idempotente y reportar success sin escribir nada. Ahora devuelve un error explícito en su lugar.
  • A veces la acción genuinamente no hizo nada (una superposición se comió el clic, la aplicación revirtió el cambio). Para eso es browse_diff — envuelve la escritura en un par de instantánea/diff en lugar de confiar en el valor de retorno.

Una acción nunca regresa en absoluto. Cada ruta que entra a la página está limitada por un plazo, por lo que una página cuyo hilo principal está bloqueado (un recálculo pesado, un JSON.parse enorme, un script de terceros en un bucle) nunca puede colgar tu llamada de herramienta. Medido contra una página que ocupa su hilo principal durante 120s:

LlamadaComportamiento
ui.get_tree / ui.get_focusederrores a los 5s (getTree timeout)
ui.evaluateerrores a los 10s (CDP 5s, luego el respaldo de precarga 5s)
ui.actregresa a los 15s con success:false, timed_out:true
ui.capabilitiesregresa normalmente (~15s)
ui.list_tabs / ui.network_listinstantáneo — nunca tocan la página

El presupuesto de ui.act escala con el trabajo solicitado, por lo que una operación larga legítima no se mata: type /setContent presupuesto text.length × interval_ms, drag presupuestos duration_ms + steps, press presupuestos repeat × interval_ms, clear obtiene 20s. Anula con params.params.timeout_ms (piso de 1s, tope de 300s) o globalmente con AI_BROWSER_ACTION_TIMEOUT_MS. Un tiempo de espera advierte explícitamente que la acción puede haberse aplicado parcialmente — puntero ya abajo, la mitad de los caracteres escritos — por lo que vuelve a leer antes de reintentar en lugar de disparar la misma escritura dos veces.

La aplicación se reinicia sola. El NetworkService de Electron puede atascarse como un todo: el puerto aún escucha, cada navegación falla con ERR_FAILED, y curl funciona bien. Un vigilante sondea desde el proceso principal cada 30s; después de 5 barridos fallidos consecutivos transmite network_wedged y relanza (el cliente MCP se reconecta solo). Con un plazo de 5s por solicitud, eso pone un atasco genuino en aproximadamente dos minutos.

  • Cambia lo que sondea: AI_BROWSER_WATCHDOG_URLS=https://www.baidu.com/,https://example.com/ (separados por comas). El predeterminado es example.com más www.baidu.com, y esa pluralidad es deliberada — la red cuenta como viva si cualquier objetivo responde, por lo que un host inestable o bloqueado geográficamente no puede desencadenar un bucle de reinicio. Nunca establezcas un solo objetivo que tu red no pueda alcanzar.
  • Apágalo por completo: AI_BROWSER_WATCHDOG=0 (también acepta off / false / no).

El puerto 9223 ya está ocupado. Otra instancia — a menudo una obsoleta de una sesión anterior — posee el puerto, o algo más lo hace. O sal de esa instancia, o mueve AI Browser fuera de él:

AI_BROWSER_PORT=9224 npm start

Mantén el puerto consistente en cada cliente; tanto la aplicación como el servidor MCP leen AI_BROWSER_PORT de src/shared/config.js.

Electron falta o el lanzamiento falla inmediatamente. El motor de ~100 MB nunca llegó. Con el paquete npm, simplemente lanza de nuevo — el lanzador lo descarga él mismo (establece ELECTRON_MIRROR para una red lenta, ver Instalación). Desde la fuente, verifica ls node_modules/electron/dist y vuelve a ejecutar npm install con el espejo. Establecer AI_BROWSER_PORT no ayudará aquí — esto es un binario faltante, no un conflicto de puerto.

La entrada se acepta pero no pasa nada. Generalmente la ventana está ocluida. Los tres interruptores disable-*-backgrounding anteriores existen para prevenirlo, pero si aún ves clics que reportan éxito mientras nada se mueve, trae la ventana de AI Browser al frente e intenta de nuevo.

El estado de inicio de sesión se pierde continuamente. Las sesiones persisten bajo ~/.ai-browser. Si lo anulas, ten en cuenta que los directorios AI_BROWSER_USER_DATA separados son almacenes de sesión separados — "iniciado sesión" en uno no es "iniciado sesión" en otro:

AI_BROWSER_USER_DATA=~/.ai-browser-work npm start

Pruebas y desarrollo

npm test             # vitest — 411 tests across 27 files
npm run smoke        # Electron contract layer, offline fixture      — 31 checks
npm run capabilities # text fields, canvas, page source, network, DOM editing — 54 checks
npm run richtext     # rich-text editors, nested menus, forms, errors, tags, upload — 117 checks
npm run network      # request log, POST bodies, headers, pagination, redaction — 41 checks
npm run value        # value truncation contract + read-only get_value — 15 checks
npm run canvas       # canvas draw-call capture — 13 checks
npm run modeldiff    # canvas app data-model diff (the "unchanged" false green) — 12 checks
npm run audit        # every advertised action must exist; wait/downloads/permissions/quit — 35 checks
npm run focus        # no focus stealing + full capability while backgrounded — 32 checks
npm run interact     # trusted drag / key press / wheel (pointer gestures) — 31 checks
npm run view         # viewport repaint + snapshot/diff verification — 28 checks
npm run realsites    # navigate/read/act against a matrix of real sites

Todos los conjuntos e2e arrancan un Electron genuino contra accesorios sin conexión y salen con código distinto de cero en caso de fallo. Juntos cubren lo que jsdom estructuralmente no puede: integridad de precarga, ui.evaluate bajo un CSP estricto, prueba de impacto de clic CDP, ciclo de vida de múltiples pestañas, fan-out de eventos y las barreras de seguridad evaluate.

Todos necesitan el puerto WS libre — detén un npm start en ejecución primero, o apúntalos a otro lugar:

AI_BROWSER_PORT=9333 npm run smoke

Arrancan su propio Electron en un perfil aislado (/tmp/ai-browser-e2e-<port> por defecto), por lo que nunca tocan tu perfil ~/.ai-browser real — el que tiene tus sesiones iniciadas. Ellos también limpian su Electron al salir, incluso en Ctrl-C / tiempo de espera / bloqueo: un proceso huérfano de lo contrario mantendría el puerto y el bloqueo de instancia única, y encontrarías npm start negándose a lanzar con "ya hay otra instancia de AI Browser en ejecución".

Lo que demostró cada conjunto de capacidades

npm run view — el conjunto para las dos cosas que silenciosamente producen falsa confianza.

Viewport. Un BrowserView puede estar completamente cargado y aún mostrar píxeles obsoletos, y lo único que forzaba un repintado era cambiar de pestaña — que es exactamente por qué "haz clic en las pestañas unas cuantas veces" solía arreglarlo. Ahora se dispara en cada señal de navegación y en la ventana show/restore/focus. El conjunto lo prueba desde el contador acumulativo de repintados, no desde una captura de pantalla: una captura necesita ojos, un contador falsifica. También prueba que el debounce no se autoactiva (un bucle de repintado parpadearía para siempre) y que repintar no cambia datos de la página.

Verificación. ui.diff solo es útil si es correcto en ambas direcciones, por lo que cada afirmación viene en pares: un cambio real debe reportarse y una página sin cambios no debe. Los controles negativos son el punto — un diff que inventó cambios, o una instantánea que silenciosamente se volvió vacía en error (haciendo que cada diff posterior reporte "sin cambios"), convertiría toda la verificación en verde falso. El conjunto también fija el modo de fallo donde una capa de parámetros mal escrita hizo que type reportara éxito sin escribir nada.

npm run richtext — el conjunto más profundo. Editores de texto enriquecido y formato, menús anidados de dos y tres niveles, campos obligatorios vs opcionales (required nativo, aria-required, asteriscos rojos), superficies de error y advertencia (a nivel de campo, a nivel de formulario, toasts, diálogos modales), radio / checkbox / select / combobox, entradas de etiquetas e inserción de temas estilo Weibo, y carga de imágenes. Cada afirmación de acción se verifica contra el DOM real, no solo el árbol semántico.

Dos cosas que cambió en el producto:

  • Un clic en un botón que se cierra solo solía dispararse dos veces (la prueba de impacto se ejecutaba después del envío, encontraba el objetivo desaparecido y volvía a un segundo clic). Envíos duplicados, menciones duplicadas. Ahora los clics se prueban antes de enviar y ya no retroceden.
  • Los errores a nivel de campo solían ser invisibles: se transportan mediante <span> sin rol ARIA y se podaban como diseño. Ahora aparecen en context.errors / context.field_errors, escaneados en todo el documento en lugar de solo dentro de contenedores de diálogo.
  • Tres lecturas solían depender del nombre de clase de un contenedor. Los asteriscos rojos se escaneaban solo dentro de contenedores cuya clase contenía modal/dialog/popup/…, por lo que los campos obligatorios de una página simple eran invisibles; las entradas de modals[].fields[] no llevaban id, por lo que una lectura de error para un campo no podía asignarse de vuelta a él; y el texto auxiliar en <span>/<div> sin formato (contadores de caracteres, sugerencias de formato) se podaba del árbol semántico sin una segunda ruta. Las tres ahora se encuentran en el nivel superior de context: required_hints se escanea en todo el documento, las entradas de fields[] tienen la misma forma en modals[] y forms[] (id incluido, verificado contra el DOM real), y el texto de span sin formato adyacente al formulario termina en hints — el árbol semántico en sí mantiene su poda, por lo que el costo de tokens no cambia.

npm run capabilities — escribir en todo tipo de campo de texto (entradas simples, password, number, textarea, contenteditable, además de readonly / disabled / maxlength — incluyendo entrada CJK y texto de 1000 caracteres), leer el código fuente de la página, capturar cuerpos de respuesta y editar la página a través de ui.evaluate.

npm run network — que un POST se puede capturar con su cuerpo de solicitud completo, que los encabezados se redactan por defecto y que los cuerpos se paginan en lugar de truncarse silenciosamente.

npm run modeldiff — la suite para el único lugar donde ui.diff solía mentir. El DOM de una aplicación renderizada en canvas es una fotografía fija: arrastrar un nodo o editar un widget repinta <canvas> y mueve nada que el recorrido de elementos pueda ver, por lo que la instantánea predeterminada solo con DOM respondía unchanged a una escritura que demostrablemente ocurrió — incluida la ruta de escritura documentada de ComfyUI, node.widgets[].value, que mueve el DOM exactamente cero bytes. La suite demuestra la huella del modelo en un accesorio autoconstruido (nunca el proyecto en vivo de alguien, donde el ajuste y la cuantización hacen que "moverlo de vuelta" sea falso):

  • agregar un nodo → verdict: changed, y las rutas de diff son solo model.count / model.fp — cero nodes.* y cero text*, que es como la prueba demuestra que el crédito pertenece al modelo en lugar de al DOM que casualmente se mueve;
  • el caso de ComfyUI — cambiar un valor de widget con el recuento de nodos sin cambios → aún changed, y específicamente model.fp sin model.count;
  • ambas direcciones: una página sin tocar debe permanecer unchanged, y una página simple sin objeto de aplicación nunca debe adquirir un campo model (sin falsos rojos de maquinaria que inventó un candidato);
  • la señal del modelo no debe enmascarar cambios en el DOM — cuando ambos se mueven, ambos se informan.

Dos puntos de diseño que la suite fija, ambos aprendidos por las malas:

  • la selección es por lo que realmente se puede leer de vuelta, no por lo prometedor que parezca el nombre. Un espacio de nombres de biblioteca puntúa bien en nombres y no tiene modelo que devolver, por lo que devolver null es lo que lo descalifica;
  • el rango proviene del acceso más fuerte que el objeto tiene, mientras que la lectura pasa por serialize() cuando existe (devuelve datos seguros para JSON; las colecciones en vivo suelen ser circulares). Colapsar los dos hace que cada candidato informe serialize, todos empatan, y un mero serializador puede superar al modelo real — ese error fue detectado por la prueba de desempate, no por revisión.

Canvas: leer a través de llamadas de dibujo, no píxeles

El árbol semántico informa un <canvas> como role: canvas y nada más — sin texto, sin píxeles. browse_canvas corrige eso sin tomar una captura de pantalla: se inyecta un grabador en el mundo principal de la página y envuelve la API de Canvas, capturando las llamadas de dibujo en sí.

El punto es que un canvas se dibuja con código, por lo que el código ya sabe lo que dibujó. fillText nos entrega la cadena literal — el texto dentro de un canvas no necesita OCR:

// browse_canvas(operation: "read", canvas_id: "canvas:nav-…:frame-main:1")
{
  "kind": "2d", "readability": "semantic",
  "texts": [
    { "seq": 2, "method": "fillText", "text": "确认支付",
      "bounds_canvas": { "x": 20, "y": 40, "width": 120, "height": 30 },
      "style": { "font": "20px sans-serif", "fill_style": "#000000" } }
  ],
  "regions": [ { "seq": 3, "kind": "rect", "method": "fillRect", "bounds_canvas": {…} } ]
}

El grabador es una derivación, nunca una toma de control: llama a la API nativa primero (Reflect.apply(original, …)) y solo luego graba, dentro de su propio try/catch. Una falla de grabación nunca puede cambiar un valor de retorno, una excepción, this o el orden de dibujo.

Dos límites que debes conocer:

  • El hook se instala después de la primera carga de una página, por lo que cualquier cosa dibujada durante esa carga inicial no es recuperable. browse_canvas(operation: "configure", reload: true) recarga con el hook en su lugar y te da el historial completo. (Causa raíz: los comandos CDP en espera se cuelgan en una pestaña recién creada en Electron, por lo que la instalación tiene que esperar a did-finish-load.)
  • WebGL es opaco. Los vértices, texturas y sombreadores no son reversibles a significado comercial, y el texto suele ser un atlas de glifos. Tales canvases se marcan opaque en lugar de adivinarse. Para ese caso solo, operation: "capture" devuelve un PNG de la región del canvas — desactivado por defecto, invocado explícitamente, y cuesta los tokens de visión de tu agente.

Desactiva por completo con AI_BROWSER_CANVAS_HOOK=0.

Límites que vale la pena conocer antes de confiar en ellos

  • node.value se trunca a 200 caracteres. Ese es nuestro límite, no el de CDP. Cuando se trunca, el nodo lleva value_truncated: true, value_full_length y una sugerencia de value_fetch lista para llamar — usa browse_act(action: "get_value"), que es de solo lectura y pagina.
  • Los campos de contraseña no exponen ningún valor — solo value_sensitive: true. El árbol semántico se alimenta directamente al contexto de tu agente, por lo que no debe llevar secretos.
  • js_error se transmite para cada pestaña (cada evento lleva tabId), por lo que el orden y los índices no están garantizados. Filtra por tabId; no confíes en "el primer error es el que me importa".
  • Un "manejador de conexión" solo se usa si un puntero real pudiera alcanzarlo. Muchos sitios renderizan manejadores como decoraciones de pointer-events: none + opacity: 0 (verificado en el canvas de rhtv: cada manejador es pe:none / op:0). Anclar en tal elemento presionaría el cuerpo del nodo en su lugar — por lo que el resolvedor se autoverifica con elementFromPoint y retrocede al punto medio del borde del elemento, informando via: "edge". Si ves via: "edge" donde esperabas un manejador, esa es la respuesta honesta: arrastrar para conectar no está disponible en ese punto, y el sitio debe cablear nodos de otra manera.
  • ui.scroll es el desplazamiento del documento. Hace window.scrollBy, por lo que es un no-op en editores basados en canvas, mapas y gráficos. Usa ui.act{action:'wheel'} allí.

npm run realsites está impulsado por tablas desde e2e/realsites/sites.cjs, actualmente 17 sitios en 8 categorías (búsqueda, finanzas, medios, desarrollo, comercio electrónico, SPA, editor, mercado). La ejecución imprime dos números, y deliberadamente nunca los mezcla en uno:

  • 行为正确率 / tasa de comportamiento — ¿se comportó el navegador como se esperaba? Los sitios que requieren inicio de sesión o responden con un muro anti-bot cuentan como aprobados cuando el muro se detecta correctamente, porque eso significa que la detección funciona.
  • 内容成功率 / tasa de contenido — ¿realmente leímos el contenido? Esto cuenta solo sitios donde el árbol volvió por encima del minNodes del sitio; un muro no es contenido.

Informar un solo "porcentaje de éxito" permitiría que un muro de inicio de sesión detectado correctamente inflara el número de contenido. La ejecución también imprime el tamaño del corpus, ejecuciones por sitio, recuento de ejecuciones y marca de tiempo, por lo que cualquier porcentaje en un informe se puede re-derivar en lugar de tomarse por fe.

Diseño:

src/main/      Electron main — ws_server · page_manager (tabs, CDP) · mcp_server · mcp_tools · axExtractor
src/preload/   bridge · extractor (semantic tree, data-ai-id) · actions · watcher
src/renderer/  tab bar UI
src/shared/    config (AI_BROWSER_* env) · guards (evaluate safety) · protocol · watchdog
e2e/           smoke_ws.cjs · realsites/
tests/         vitest unit suites
skills/        optional higher-level recipes built on the tools

src/shared/config.js es la única fuente para el puerto, el directorio de datos de usuario y las anulaciones de UA; guards.js es la única fuente para los límites de evaluate, aplicados de manera idéntica en ambos transportes.


Compatibilidad

DimensiónSoporte
AgentesCualquier cliente MCP stdio; alternativamente WS crudo desde cualquier lenguaje
Node≥ 18
SOmacOS y Linux — desarrollado y probado en estos. Windows: experimental y no probado. Es Electron simple más CDP, por lo que debería compilar, pero nadie lo ha ejecutado; espera depurar npm install y la capa de ventanas tú mismo. Por favor, informa de cualquier manera.
Frameworks de páginaReact / Vue / Angular / DOM vanilla — la ubicación de elementos se basa en DOM, no en frameworks
EditoresEditores enriquecidos impulsados a través de entrada confiable en lugar de rutas de código por framework
Tipos de páginaEstáticas y SPA; actualizaciones dinámicas observables a través del árbol, eventos dom_change / state_changed

Contribuciones

Las issues y PRs son bienvenidas, especialmente casos de reproducción donde el árbol semántico se equivoca en una página — esos son los informes de errores más útiles que este proyecto puede recibir.

Lo que intentamos mantener firme:

  • Mantén el núcleo genérico. El conocimiento específico del sitio pertenece fuera de src/main y src/preload.
  • IPC permanece emparejado por ID de solicitud, y las sesiones de CDP permanecen con conteo de referencias — de lo contrario, las pestañas se filtran.
  • Sin capturas de pantalla. Si un cambio necesita píxeles para funcionar, la pregunta de diseño está aguas arriba.
  • evaluate permanece protegido (límite de longitud, sin identificadores de Node), en ambos transportes a la vez.
  • Las instrucciones del agente cambian cuando cambia el comportamiento. La lista de herramientas MCP, los métodos de ws_server.js, y examples/AGENT_INSTRUCTIONS.md deben coincidir; una doc que se desvía de mcp_tools.js es un error.

Licencia

MIT © Glittering