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.
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.

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.
| Herramienta | Una línea |
|---|---|
browse_navigate | Carga una URL en la pestaña activa (o dada). |
browse_get_tree | Lee la página como un árbol semántico + contexto de página (modales, mensajes, estadísticas). |
browse_act | click / 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_diff | snapshot 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_capabilities | Sondea qué ofrece una página y qué ruta tomar en ella (ver abajo). |
browse_repaint | Fuerza al compositor a redibujar la pestaña cuando la ventana muestra una página obsoleta; devuelve repaint_count. |
browse_downloads | Lista 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_canvas | Lee <canvas> a través de llamadas de dibujo capturadas: list / read / configure / capture. Experimental: consulta la sección de canvas abajo. |
browse_evaluate | Ejecuta una expresión JS en el contexto de la página (límite de 5000 caracteres, identificadores de Node rechazados). |
browse_scroll | Desplázate por la página o desplaza un elemento a la vista. |
browse_wait | Sondea hasta que button_enabled / modal_appeared / text_contains / url_contains. |
browse_list_tabs | Lista las pestañas abiertas (id, url, título, activa). |
browse_new_tab | Abre una pestaña nueva, opcionalmente en una URL. |
browse_close_tab | Cierra una pestaña por id. |
browse_set_active_tab | Cambia en qué pestaña actúan las llamadas posteriores. |
browse_network | Inspección de red estilo Chrome-DevTools: list (filtrar por método / URL / estado / tipo), get (encabezados y cuerpos de solicitud + respuesta), clear, configure. |
browse_quit | Apaga 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" }]
verdictes 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
pathmá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.48743718592vs...1859no es un cambio), pero un desplazamiento real de 22 unidades de coordenadas sí lo es. - Omite
jspara 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
jsy también toma la huella del modelo de datos propio de la aplicación cuando la página cuelga uno dewindow. 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 respondeunchangeda 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 comomodel.fp(ymodel.countcuando el conteo de nodos se movió). Es de solo lectura (loadGraphDataes 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: truemueve 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: truela 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 mediantedata-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 conbrowse_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_treeaceptasubset: "interactive"para descartar ramas solo de diseño,mode: "diff"para devolver solo nodos que aparecieron/desaparecieron desde la última lectura, yax: truepara leer a través de la capa de accesibilidad (los enlaces entonces llevanurl).typeañade;setContentreemplaza. 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 siguientetypela borraba. Ahoratypeescribe en el cursor (params.at:end(predeterminado) /start/cursor), así que "inserta un tema, luego sigue escribiendo el cuerpo" funciona. Para rellenar un campo, usasetContent(oclearprimero).browse_acttambién aceptaaction: "get_value"— una forma solo de lectura de obtener el texto completo de un elemento, lo cual importa porquebrowse_get_treetrunca valores largos a 200 caracteres (ver abajo).- Los gestos de puntero son de primera clase:
drag,pressywheel. Las interfaces de canvas / pizarra / ordenables se construyen sobre ellos, y ninguna cantidad deevaluatepuede sustituirlos — losPointerEventsinté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, oto_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_ycomienzan desde un punto desnudo (panorámica de canvas vacío), en cuyo caso no se necesitatarget.press:key(Delete,Escape,ArrowRight,Enter, … o una combinación como"Meta+a"), okeyspara una secuencia. Eliminar un nodo, deshacer, deseleccionar — todo solo con teclado.wheel:delta_x/delta_ymáshold: ["Control"]para zoom del canvas.browse_scrollno puede hacer esto — llama awindow.scrollBy, que un canvas infinito ignora por completo.- El resultado del arrastre informa
drag_from/drag_toy qué elemento realmente está en cada punto (from_at_id), además dewarning+start_hit_target: falsecuando la presión aterrizó en un elemento diferente — la superposición visual de otro modo parece exactamente "arrastrar no hace nada".
actionsse infiere del comportamiento, no derole. Un elemento cuyocursorcalculado es un cursor de arrastre, o que tienedraggable="true"/cursor: pointer/ unonclick, ahora anunciadrag/clicken el árbol incluso cuando su rol esgroup. Sin esto, un canvas lleno de nodosrole: groupinformabaactions: []y el agente no podía saber que eran interactivos en absoluto.
browse_networkcaptura 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]); pasainclude_sensitive_headers: truepara verlos.- La mayoría de las herramientas aceptan un
tabopcional 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_tabya no toman el foco de la ventana. La página aún piensa que está enfocada —Emulation.setFocusEmulationEnabledmantienedocument.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ú ejecutasnpm 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(): devuelvetrueincluso cuando la ventana está en segundo plano, porqueEmulation.setFocusEmulationEnabledestá 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:
- El agente encuentra algo que no puede hacer.
- Lo dice en el chat, claramente: "Por favor, escanea el código QR en la ventana del navegador, luego responde 'listo'."
- Se detiene y espera la respuesta — sin bucles de reintento en clics que no pueden funcionar.
- Después de "listo", vuelve a leer con
browse_get_treepara 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_quitmientras 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étodo | Propósito | Se asigna a |
|---|---|---|
ui.get_tree | Árbol semántico (+ contexto). Parámetros: focusedOnly, ax, subset, mode, tab | browse_get_tree |
ui.act | params.action + params.target; argumentos por acción anidados bajo params.params | browse_act |
ui.navigate | Cargar una URL (params.url, tab opcional). Devuelve {ok, error?} — {ok:false} siempre lleva una razón. | browse_navigate |
ui.snapshot / ui.diff | Capturar una línea base (params.js, label opcional) y comparar contra ella (params.snapshot, rearm, forget) | browse_diff |
ui.capabilities | Sondear la página: histograma del árbol, canvas, iframes, globales de la app, endpoints, ruta recomendada | browse_capabilities |
ui.repaint | Forzar al compositor a redibujar una pestaña; devuelve repaint_count para que un repintado sea comprobable | browse_repaint |
ui.evaluate | Ejecutar JS en el contexto de la página, misma protección que MCP | browse_evaluate |
ui.scroll | Desplazar página o elemento (direction, amount, target). Solo desplazamiento de documento — para apps de canvas usa ui.act{action:'wheel'}. | browse_scroll |
ui.wait | Sondeo hasta una condición o tiempo de espera | browse_wait |
ui.new_tab / ui.close_tab / ui.list_tabs / ui.set_active_tab | Gestión de pestañas | browse_*_tab |
ui.subscribe / ui.unsubscribe | Suscripción a eventos; params.events, "*" para todos | — (solo WS — ver abajo) |
ui.downloads | Descargas de esta pestaña con rutas en disco (state, received_bytes, dir) | browse_downloads |
ui.get_focused | El subárbol del elemento enfocado | — |
ui.peek | Pasar el cursor sobre una función plegada/solo hover y devolver el diff, sin confirmar | — |
ui.network_body | Cuerpo de respuesta por subcadena de URL (url_pattern) | — (superado por browse_network) |
ui.quit | Apagado elegante | browse_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:
| Archivo | Qué muestra |
|---|---|
mcp-config.json | Configuración mínima de cliente MCP; reemplaza la ruta del marcador de posición. |
AGENT_INSTRUCTIONS.md | Instrucciones de copiar y pegar para tu agente, incl. el protocolo de traspaso humano. |
raw-ws.mjs | Cliente WebSocket sin procesar más pequeño posible. |
read-page.mjs | Carga una página, imprime sus elementos interactivos. |
with-human.mjs | Flujo 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.actanida sus argumentos por acción bajoparams.params—{action:"type", target:"<id>", params:{text:"…"}}. Flattening it to{text:"…"}pasa una cadena vacía, que solía golpear el cortocircuito idempotente y reportarsuccesssin 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:
| Llamada | Comportamiento |
|---|---|
ui.get_tree / ui.get_focused | errores a los 5s (getTree timeout) |
ui.evaluate | errores a los 10s (CDP 5s, luego el respaldo de precarga 5s) |
ui.act | regresa a los 15s con success:false, timed_out:true |
ui.capabilities | regresa normalmente (~15s) |
ui.list_tabs / ui.network_list | instantá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 esexample.commáswww.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 aceptaoff/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 encontext.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 demodals[].fields[]no llevabanid, 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 decontext:required_hintsse escanea en todo el documento, las entradas defields[]tienen la misma forma enmodals[]yforms[](idincluido, verificado contra el DOM real), y el texto de span sin formato adyacente al formulario termina enhints— 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 solomodel.count/model.fp— ceronodes.*y cerotext*, 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íficamentemodel.fpsinmodel.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 campomodel(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 informeserialize, 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 adid-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
opaqueen 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.valuese trunca a 200 caracteres. Ese es nuestro límite, no el de CDP. Cuando se trunca, el nodo llevavalue_truncated: true,value_full_lengthy una sugerencia devalue_fetchlista para llamar — usabrowse_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_errorse transmite para cada pestaña (cada evento llevatabId), por lo que el orden y los índices no están garantizados. Filtra portabId; 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 espe:none / op:0). Anclar en tal elemento presionaría el cuerpo del nodo en su lugar — por lo que el resolvedor se autoverifica conelementFromPointy retrocede al punto medio del borde del elemento, informandovia: "edge". Si vesvia: "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.scrolles el desplazamiento del documento. Hacewindow.scrollBy, por lo que es un no-op en editores basados en canvas, mapas y gráficos. Usaui.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
minNodesdel 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ón | Soporte |
|---|---|
| Agentes | Cualquier cliente MCP stdio; alternativamente WS crudo desde cualquier lenguaje |
| Node | ≥ 18 |
| SO | macOS 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ágina | React / Vue / Angular / DOM vanilla — la ubicación de elementos se basa en DOM, no en frameworks |
| Editores | Editores enriquecidos impulsados a través de entrada confiable en lugar de rutas de código por framework |
| Tipos de página | Está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/mainysrc/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.
evaluatepermanece 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, yexamples/AGENT_INSTRUCTIONS.mddeben coincidir; una doc que se desvía demcp_tools.jses un error.
Licencia
MIT © Glittering