---
id: 0038
title: Browser externo lanzable desde el app + control via CDP
status: pending
priority: high
created: 2026-05-04
depends_on: [0014, 0029]
supersedes: [0032]
---

## Contexto y objetivo

En lugar de embeber un browser dentro del app (CEF/WebView2) o automatizar
Chromium oculto via Playwright (issue 0032), el modelo elegido es:

1. **El usuario navega en su browser real** (Chrome / Edge / Brave / Vivaldi
   instalado en el sistema), con su layout, sus extensiones, sus marcadores.
2. **graph_explorer lanza una instancia controlable** del browser cuando
   hace falta, con `--remote-debugging-port` y `--user-data-dir` apuntando
   a un profile gestionado por el app.
3. **El app habla con esa instancia via CDP** (Chrome DevTools Protocol)
   para los enrichers que necesiten JS/cookies/auth.
4. **La extension del issue 0014** cierra el loop por el lado humano: lo
   que el usuario lee en cualquier pestaña se manda al grafo con un click.

Beneficio: cero bytes de bundle (browser ya instalado), UX nativa,
control programatico cuando se necesita.

## Decision de stack

| Aspecto | Decision |
|---|---|
| Browser objetivo | Chromium-based detectado en runtime: Chrome > Edge > Brave > Vivaldi |
| Protocolo control | CDP (websocket a `localhost:<port>`) |
| Persistencia | `--user-data-dir=<app>/local_files/browser_profiles/<name>/` |
| **Cliente CDP** | **Go** — binario `cdp-cli` que envuelve las funciones del registry (issue 0029). El app C++ lo invoca via subprocess. |
| Descarga binaria | Ninguna — se exige browser instalado, error claro si no se detecta |

### Por que Go y no C++ in-process

Las funciones CDP (`cdp_connect`, `cdp_navigate`, `cdp_get_html`,
`cdp_evaluate`, `cdp_screenshot`, ...) ya estan planificadas en Go en
`0029`. Implementar un cliente CDP nativo en C++ duplicaria ~1500 LoC
(websocket + state machine + JSON dispatch) sin ganancia real:

- Las operaciones reales (get-html, screenshot, evaluate) tardan
  500-3000 ms. El overhead de spawn de subprocess (~30-50 ms) es ruido.
- Aislamiento de fallos: un cuelgue del websocket muere con el
  subprocess en vez de bloquear el render loop de ImGui.
- Reuso: el mismo `cdp-cli` lo invocan los enrichers Python, scripts
  manuales y otras apps del registry. Una sola implementacion mantenida.
- Bundle: +8 MB de binario Go vs +deps C++ (websocket lib + JSON) que
  hay que integrar en CMake.

Playwright (0032) queda **descartado** como dependencia del app: el
binario de 200 MB y la complejidad de gestionar profiles via Python no
compensan cuando el SO ya tiene browser. Se mantiene 0032 como nota
historica.

### Cliente C++ in-process: reservado para streaming (futuro)

La unica operacion donde un cliente CDP **nativo en C++** ganaria es
**streaming en vivo** del browser dentro del app (ej. `Page.startScreencast`
para mostrar la pestaña actual a 30fps en un panel ImGui, o capturar
eventos `Network.responseReceived` / `Page.frameNavigated` en tiempo
real). Para eso si hace falta un websocket persistente en el render
thread, sin overhead de spawn.

Cuando aparezca ese caso (no esta en `0038` ni `0039`), se abre un
issue separado para añadir un cliente C++ minimo orientado **solo** a
streaming. El flujo sera:

- Mantener `cdp-cli` Go para todo lo one-shot (sigue siendo la via
  default).
- Añadir `cpp/functions/browser/cdp_stream_cpp_browser.{h,cpp}` con
  websocket persistente, suscrito a un subset de eventos CDP.
- El stream se conecta al MISMO `--remote-debugging-port` que ya tiene
  abierto el browser lanzado desde `0038`. Coexisten sin conflicto: CDP
  permite multiples clientes contra la misma instancia.

Es decir: la arquitectura de `0038` no cierra la puerta — la deja
abierta. Empezamos en Go porque es la decision pragmatica hoy, y
añadimos C++ in-process **solo** si aparece un caso de streaming
concreto que lo justifique.

## Arquitectura

### Componentes

```
graph_explorer (C++)
  ├── browser_panel.{h,cpp}        ← UI: detectar/lanzar/listar instancias
  ├── browser_control.{h,cpp}      ← Wrapper sobre cdp-cli (subprocess Go)
  └── local_files/browser_profiles/
        ├── default/                ← user-data-dir Chrome
        ├── linkedin/
        └── osint_anon/

cpp/functions/browser/             ← funciones del registry (lang: cpp)
  ├── browser_detect.cpp           ← localiza chrome.exe / brave.exe / ...
  └── browser_launch.cpp           ← spawn con flags --remote-debugging-port + --user-data-dir

functions/browser/  (Go, ya en 0029)
  ├── chrome_launch.go             ← reusable
  ├── cdp_connect.go
  ├── cdp_navigate.go
  ├── cdp_get_html.go
  ├── cdp_evaluate.go
  └── cdp_screenshot.go

apps/graph_explorer/cdp-cli/       ← binario Go que envuelve las funciones CDP
                                     y expone subcomandos invocables desde C++
```

### Flujo lanzar browser

1. Usuario abre panel **Browsers** → selecciona profile (`default` o crea
   uno nuevo) → click **Launch**.
2. `browser_detect` localiza el binario (busca registry de Windows /
   `~/.local/share/applications` / `where chrome.exe`).
3. `browser_launch` hace spawn:
   ```
   chrome.exe \
     --remote-debugging-port=9222 \
     --user-data-dir=<app>/local_files/browser_profiles/<profile> \
     --no-first-run \
     --no-default-browser-check \
     about:blank
   ```
4. Puerto se asigna dinamicamente desde un pool (9222..9322) para soportar
   varios profiles en paralelo.
5. App registra la instancia en una tabla `browser_sessions` de
   `graph_explorer.db`: `{profile, pid, port, browser_path, started_at}`.

### Flujo enricher con CDP

1. Enricher (Python o Go) recibe el `profile` como param.
2. Llama a `cdp-cli` con el subcomando que necesite:
   ```
   cdp-cli get-html --profile linkedin --url https://...
   cdp-cli screenshot --profile default --url https://... --out cache/x.png
   cdp-cli evaluate --profile osint --js "document.title"
   ```
3. `cdp-cli` localiza el puerto del profile (lee `browser_sessions`),
   abre websocket CDP, navega, espera `Page.loadEventFired`, devuelve.
4. Si el profile no esta vivo, `cdp-cli` lanza el browser primero
   (mismo path que el panel, pero headless: opcional con `--headless=new`).

### Coordinacion con la extension (issue 0014)

La extension del usuario (Firefox/Chrome) NO usa CDP — usa el endpoint
HTTP `POST /ingest/text|/ingest/url` (issue 0012). Es el camino humano:
"estoy leyendo esto, mandalo al grafo".

CDP es el camino programatico: "el app necesita el HTML post-JS de
esta URL para extraer entidades". Los dos son complementarios y
comparten el mismo profile (cookies/login compartidos):
- Usuario hace login en LinkedIn manualmente (extension activa).
- Enrichers posteriores con `profile=linkedin` heredan la sesion.

## Panel UI: Browsers

Este panel muestra **runtime** (instancias vivas, puertos, PIDs). La
gestion logica de profiles (metadata, templates, seleccion, project
defaults, clone/rename/export) vive en el panel **Profiles** del
issue `0040`. Aqui solo aparece lo que esta corriendo ahora.

Layout en un panel ImGui (toggle via `cfg.panels`):

```
┌─ Browsers ─────────────────────────────────────────┐
│ Detected: Chrome 130, Edge 132, Brave (not found)  │
│                                                     │
│ Profiles                                            │
│ ┌─────────────────────────────────────────────────┐ │
│ │ default     Chrome  port 9222  ●  [open] [stop]│ │
│ │ linkedin    Chrome  port 9223  ●  [open] [stop]│ │
│ │ osint_anon  Edge    -      ○  [launch]         │ │
│ └─────────────────────────────────────────────────┘ │
│                                                     │
│ [+ New profile]   [Open profiles folder]            │
└─────────────────────────────────────────────────────┘
```

- ● = vivo, ○ = parado.
- Click en filas = expande con stats (pages abiertas, cookies count,
  ultima URL — todo via CDP `Target.getTargets` + `Network.getCookies`).
- Doble click en profile abre el browser con ese profile (sin headless).
- Boton "Console" abre `chrome://inspect/#devices` para debug.

## Plan de implementacion

| Fase | Entregable |
|---|---|
| 0038a | `cpp/functions/browser/browser_detect.cpp` — localiza binarios chromium-based en el sistema. Tests con `which`/registry de Windows. |
| 0038b | `cpp/functions/browser/browser_launch.cpp` — spawn con flags. Stub de `kill_pid` para parar. |
| 0038c | `apps/graph_explorer/cdp-cli/` — binario Go que envuelve `chrome_launch_go_browser`, `cdp_*` (issue 0029). Subcomandos: `get-html`, `screenshot`, `evaluate`, `cookies`. |
| 0038d | `browser_panel.{h,cpp}` — panel ImGui con detect/launch/list. Tabla `browser_sessions` en `graph_explorer.db`. |
| 0038e | `enrichers/fetch_webpage_browser/` — port del enricher para invocar `cdp-cli get-html`. |
| 0038f | `enrichers/fetch_screenshot/` — invoca `cdp-cli screenshot`, guarda en `cache/<sha>.png`, escribe `screenshot_path` en metadata. |
| 0038g | Auto-start del browser cuando un enricher pide un profile que no esta vivo. |
| 0038h | Tests E2E con un Chrome local y `httpbin.org` (gateado por env var, no en CI por defecto). |

## Riesgos y mitigaciones

| Riesgo | Mitigacion |
|---|---|
| No hay browser instalado | Mensaje claro en el panel + link a chrome.com/edge. App sigue funcional sin browser para enrichers no-CDP. |
| Varias instancias del mismo profile | Chrome bloquea automaticamente el `user-data-dir`. Capturar el error de spawn y mostrar "profile already in use, port=X". |
| Puerto CDP ocupado | Pool dinamico 9222..9322 con probe `tcp.Dial` antes de spawnear. |
| El usuario cierra el browser | Heartbeat cada 5s desde el panel (`Target.getTargets`). Si falla N veces, marcar como dead. |
| Updates de Chrome rompen flags | Ninguno de los flags usados es experimental. `--remote-debugging-port` lleva 10+ anos. |
| CDP version skew | Usar el subset estable: `Page.*`, `Runtime.evaluate`, `Network.getCookies`. Evitar APIs experimentales. |

## Definicion de hecho

- Desde el panel **Browsers**, click en **Launch default** abre Chrome
  con `local_files/browser_profiles/default/` como user-data-dir y queda
  registrado en `browser_sessions` con su puerto.
- `cdp-cli get-html --profile default --url https://example.com` devuelve
  el DOM post-JS por stdout en menos de 5s.
- Un enricher `fetch_webpage_browser` llamado con `profile=linkedin`
  lanza el browser si no esta vivo y devuelve el HTML autenticado.
- La extension de issue 0014 sigue funcionando en paralelo sin
  interferir (usan el mismo profile pero por mecanismos distintos).
- Cerrar el app NO mata los browsers lanzados — son procesos
  independientes, persisten hasta que el usuario los cierra. (Decision
  consciente: el usuario quiere seguir navegando aunque el app se reinicie.)

## Fuera de alcance

- Firefox / Safari / no-chromium — descartados (Firefox tiene su propio
  protocolo, demasiado divergente). Solo navegadores chromium-based.
- Browser headless en CI — los tests E2E que requieran browser quedan
  fuera del CI por defecto.
- Browser as a service (lanzar en VPS y conectar remoto) — fuera de v1.
- **Cliente CDP nativo en C++ para streaming** (`Page.startScreencast`,
  eventos `Network.*` en vivo, mirror de pestaña en panel ImGui).
  Reservado para un issue futuro cuando aparezca el caso de uso. La
  arquitectura Go elegida aqui no lo bloquea — coexistirian contra el
  mismo `--remote-debugging-port` (CDP soporta multiples clientes).
