chore: sync from fn-registry agent

This commit is contained in:
fn-registry agent
2026-05-09 18:11:21 +02:00
commit 822cbbd450
11 changed files with 1378 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# navegator_dashboard — Windows-only por diseño.
# Linux: skip silenciosamente para que cpp/build/ no falle.
if(NOT WIN32)
message(STATUS "navegator_dashboard: skipping (Windows-only).")
return()
endif()
add_imgui_app(navegator_dashboard
main.cpp
chrome_scanner.cpp
chrome_launcher.cpp
local_api.cpp
panels.cpp
)
target_include_directories(navegator_dashboard PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(navegator_dashboard PRIVATE ws2_32)
set_target_properties(navegator_dashboard PROPERTIES WIN32_EXECUTABLE TRUE)
+94
View File
@@ -0,0 +1,94 @@
---
name: navegator_dashboard
lang: cpp
domain: tools
description: "Cuadro de mandos para gestionar instancias Chrome con remote debugging. Lista navegadores corriendo (visibles + headless), permite lanzar/matar perfiles, inspeccionar pestañas, ejecutar JS, ver peticiones de red. Puente WSL→Windows que centraliza el control que hoy hacemos por scripts dispersos."
tags: [imgui, browser, cdp, dashboard, windows, navegator]
uses_functions: []
uses_types: []
framework: "imgui"
entry_point: "main.cpp"
dir_path: "projects/navegator/apps/navegator_dashboard"
repo_url: ""
e2e_checks:
- id: build_windows
cmd: "cd /home/lucas/fn_registry && ./fn run compile_cpp_app navegator_dashboard"
timeout_s: 600
- id: exe_present
cmd: "test -f /mnt/c/Users/lucas/Desktop/apps/navegator_dashboard/navegator_dashboard.exe"
---
## Proposito
Centralizar el control de **todas** las instancias de Chrome con `--remote-debugging-port` que se lancen en el equipo. Sustituye los scripts sueltos `start.sh`/`stop.sh`/`nav.sh` de `projects/navegator/scripts/` por una UI unificada + API HTTP local.
Casos de uso:
- Ver de un vistazo cuantos Chrome estan vivos, en que puerto, con que profile, headless o no.
- Lanzar/matar perfiles desde la UI sin tocar terminal.
- Listar pestañas de cada instancia, navegar, cerrar, capturar HTML/screenshot.
- Inspeccionar peticiones de red en vivo (CDP `Network.*` events).
- Exponer todo eso como API HTTP local para que scripts/agentes/cdp-cli/graph_explorer hablen con un solo endpoint en vez de cada uno re-implementar el control.
## Arquitectura
```
┌───────────── navegator_dashboard.exe (Windows) ─────────────┐
│ │
│ UI ImGui (panels) HTTP API (127.0.0.1:1923X) │
│ ├── Browsers GET /browsers │
│ ├── Tabs POST /browser/spawn │
│ ├── Tab Detail POST /browser/{port}/kill │
│ └── Network GET /browser/{port}/tabs │
│ POST /browser/{port}/navigate │
│ Worker threads: GET /browser/{port}/tab/{id}/html│
│ ├── ChromeScanner GET /browser/{port}/network/log │
│ ├── CdpHttpClient ... │
│ └── CdpWsStream │
└──────────────────────────────────────────────────────────────┘
↓ scan/spawn/kill ↓ /json + ws
┌─────────── chrome.exe N instancias (Windows) ───────────────┐
│ perfil=default port=19222 [visible] │
│ perfil=osint port=19223 [visible] │
│ perfil=headless_scrape port=19224 [headless] │
└──────────────────────────────────────────────────────────────┘
```
## Panels (v0 → v1)
| Panel | v0 | v1 | v2 |
|---|---|---|---|
| **Browsers** | scan + spawn + kill | filtro/search | grupos (perfiles favoritos) |
| **Tabs** | stub | listar + navigate + close | drag&drop entre instancias |
| **Tab Detail** | stub | HTML preview + screenshot + JS REPL | live mirror de pestaña (CDP screencast) |
| **Network** | stub | request log + headers + body | timeline + waterfall + filtro |
## Stack
- `fn::run_app` (cpp/framework/app_base.h) — shell estandar (PATTERNS.md).
- ImGui + ImPlot (chart Network).
- ChromeScanner: `Get-CimInstance Win32_Process` invocado con `_popen`. Itera ~1 Hz.
- ChromeLauncher: `CreateProcess` Windows. Argumentos derivados de `chrome_launch_go_browser` (registry).
- CDP HTTP: GET `/json/version`, `/json` por puerto via WinSock raw o `WinHttp`. v1.
- CDP WS: handshake RFC 6455 + framing manual (mismo esquema que `cdp_conn.go`). v1.
- HTTP API local: `cpp-httplib` (header-only) bind 127.0.0.1. v0 stub, funcional v1.
## Decisiones consciente (cpp_apps.md §9)
- `viewports`: `true` (default) — la app puede arrastrar paneles fuera del main.
- `init_gl_loader`: `false` — solo ImGui, sin OpenGL custom.
- `local_files/`: la app guarda su `app_settings.ini` + `navegator_dashboard.db` con historial de instancias y perfiles favoritos.
- Modo CLI: `--api-only --port N` para correr sin UI (daemon puro). v1+.
## Plataforma
- **Windows-only**. La app vive en WSL como codigo pero solo se compila como binario Windows (mingw-w64). En Linux, `CMakeLists.txt` hace `return()` antes de `add_imgui_app` para que `cpp/build/` no falle.
- Razon: 90% de la logica usa Win32 (CreateProcess, WMI, taskkill). Linux Chrome es secundario y ya esta cubierto por `cdp-cli` directo.
## Roadmap
- v0 (este commit): skeleton + Browsers panel funcional + 3 stubs.
- v1: CDP HTTP/WS in-process + Tabs + Tab Detail + Network panel.
- v2: HTTP API local + integracion con `cdp-cli` (cdp-cli puede delegar al dashboard si esta vivo).
- v3: streaming live de pestañas (CDP `Page.startScreencast`) — arquitectura ya prevista en issue 0038.
+132
View File
@@ -0,0 +1,132 @@
#include "chrome_launcher.h"
#include <cstdio>
#include <string>
#ifdef _WIN32
# define WIN32_LEAN_AND_MEAN
# include <windows.h>
#endif
namespace navegator {
namespace {
#ifdef _WIN32
const char* kChromePaths[] = {
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
};
bool file_exists_w(const char* path) {
DWORD attrs = GetFileAttributesA(path);
return (attrs != INVALID_FILE_ATTRIBUTES) && !(attrs & FILE_ATTRIBUTE_DIRECTORY);
}
std::string find_chrome() {
for (const char* p : kChromePaths) {
if (file_exists_w(p)) return p;
}
return "";
}
// Quote para argv en CreateProcess (rules de MSVCR / Chromium):
// envolver en " y escapar las " internas con \" y los \ que preceden a " con \\.
std::string quote_arg(const std::string& a) {
if (a.empty()) return "\"\"";
bool needs = a.find_first_of(" \t\"") != std::string::npos;
if (!needs) return a;
std::string out = "\"";
int backslashes = 0;
for (char c : a) {
if (c == '\\') {
++backslashes;
out += c;
} else if (c == '"') {
// Duplicar las \\ acumuladas + escapar la "
for (int i = 0; i < backslashes; ++i) out += '\\';
backslashes = 0;
out += "\\\"";
} else {
backslashes = 0;
out += c;
}
}
// Cerrar: duplicar \\ pendientes.
for (int i = 0; i < backslashes; ++i) out += '\\';
out += '"';
return out;
}
#endif
} // namespace
LaunchResult launch_chrome(const LaunchOpts& opts) {
LaunchResult r;
#ifndef _WIN32
(void)opts;
r.error = "launch_chrome: solo Windows en v0";
return r;
#else
std::string chrome = opts.chrome_path.empty() ? find_chrome() : opts.chrome_path;
if (chrome.empty()) {
r.error = "chrome.exe no encontrado en Program Files";
return r;
}
if (opts.user_data_dir.empty()) {
r.error = "user_data_dir obligatorio para aislar perfil";
return r;
}
// Crear el directorio si no existe (ignoramos errores, Chrome se queja si no puede).
CreateDirectoryA(opts.user_data_dir.c_str(), nullptr);
char port_buf[32];
std::snprintf(port_buf, sizeof(port_buf), "--remote-debugging-port=%d", opts.port);
std::string cmd;
cmd = quote_arg(chrome);
cmd += " ";
cmd += quote_arg(port_buf);
cmd += " --remote-allow-origins=*";
cmd += " ";
cmd += quote_arg(std::string("--user-data-dir=") + opts.user_data_dir);
cmd += " --no-first-run --no-default-browser-check";
if (opts.headless) {
cmd += " --headless=new --disable-gpu";
}
if (!opts.start_url.empty()) {
cmd += " ";
cmd += quote_arg(opts.start_url);
} else {
cmd += " about:blank";
}
STARTUPINFOA si{};
si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
// CreateProcess modifica el commandline buffer — copia mutable.
std::string mutable_cmd = cmd;
BOOL ok = CreateProcessA(
nullptr,
mutable_cmd.data(),
nullptr, nullptr, FALSE,
CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS,
nullptr, nullptr, &si, &pi);
if (!ok) {
DWORD err = GetLastError();
char buf[64];
std::snprintf(buf, sizeof(buf), "CreateProcess failed (err=%lu)", (unsigned long)err);
r.error = buf;
return r;
}
r.ok = true;
r.pid = (uint32_t)pi.dwProcessId;
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return r;
#endif
}
} // namespace navegator
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <cstdint>
#include <string>
namespace navegator {
struct LaunchOpts {
std::string chrome_path; // por defecto auto-detect
int port = 19222; // remote-debugging-port
std::string user_data_dir; // ruta Windows (C:\...) — obligatorio para aislar
bool headless = false;
std::string start_url; // URL inicial; vacio = about:blank
};
struct LaunchResult {
bool ok = false;
uint32_t pid = 0;
std::string error; // descripcion humana si ok=false
};
// Lanza chrome.exe con los flags de remote debugging que YA descubrimos
// que funcionan en Chrome 147 (ver projects/navegator/notes/2026-05-09-...):
// --remote-debugging-port=<port>
// --remote-allow-origins=* [obligatorio Chrome 111+]
// --user-data-dir=<dir>
// --no-first-run --no-default-browser-check
// + flags si headless.
//
// NO usa --remote-debugging-address=0.0.0.0 (rompe bind en Chrome 147).
LaunchResult launch_chrome(const LaunchOpts& opts);
} // namespace navegator
+203
View File
@@ -0,0 +1,203 @@
#include "chrome_scanner.h"
#include <cstdio>
#include <cstring>
#include <sstream>
#include <string>
#ifdef _WIN32
# define WIN32_LEAN_AND_MEAN
# include <windows.h>
#endif
namespace navegator {
namespace {
#ifdef _WIN32
// Ejecuta un comando con CreateProcess + pipe stdout, OCULTANDO la consola.
// _popen siempre muestra cmd.exe en GUI apps WIN32_EXECUTABLE — para auto-rescan
// cada 2s eso parpadea sin parar. CREATE_NO_WINDOW evita el flicker.
std::string run_capture(const std::string& cmd) {
HANDLE r_pipe = nullptr;
HANDLE w_pipe = nullptr;
SECURITY_ATTRIBUTES sa{};
sa.nLength = sizeof(sa);
sa.bInheritHandle = TRUE;
if (!CreatePipe(&r_pipe, &w_pipe, &sa, 0)) return "";
SetHandleInformation(r_pipe, HANDLE_FLAG_INHERIT, 0);
STARTUPINFOA si{};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;
si.wShowWindow = SW_HIDE;
si.hStdOutput = w_pipe;
si.hStdError = w_pipe;
si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
PROCESS_INFORMATION pi{};
std::string mutable_cmd = cmd;
BOOL ok = CreateProcessA(
nullptr,
mutable_cmd.data(),
nullptr, nullptr, TRUE,
CREATE_NO_WINDOW,
nullptr, nullptr, &si, &pi);
CloseHandle(w_pipe); // padre no escribe
if (!ok) {
CloseHandle(r_pipe);
return "";
}
std::string out;
char buf[4096];
DWORD nread = 0;
while (ReadFile(r_pipe, buf, sizeof(buf), &nread, nullptr) && nread > 0) {
out.append(buf, nread);
}
CloseHandle(r_pipe);
WaitForSingleObject(pi.hProcess, 30000);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return out;
}
#else
std::string run_capture(const std::string& cmd) {
FILE* pipe = popen(cmd.c_str(), "r");
if (!pipe) return "";
std::string out;
char buf[4096];
while (fgets(buf, sizeof(buf), pipe)) out.append(buf);
pclose(pipe);
return out;
}
#endif
// Extrae el valor de --flag=VALUE o --flag VALUE de una commandline.
// Devuelve cadena vacia si no se encuentra.
std::string extract_flag(const std::string& cmd, const std::string& flag) {
// Busca "--flag=" primero.
std::string needle_eq = flag + "=";
auto p = cmd.find(needle_eq);
if (p != std::string::npos) {
size_t start = p + needle_eq.size();
// Si empieza con comilla, leer hasta la siguiente.
char quote = 0;
if (start < cmd.size() && (cmd[start] == '"' || cmd[start] == '\'')) {
quote = cmd[start];
++start;
}
size_t end = start;
while (end < cmd.size()) {
if (quote) {
if (cmd[end] == quote) break;
} else {
if (cmd[end] == ' ') break;
}
++end;
}
return cmd.substr(start, end - start);
}
// Fallback: "--flag VALUE".
auto p2 = cmd.find(flag + " ");
if (p2 != std::string::npos) {
size_t start = p2 + flag.size() + 1;
size_t end = cmd.find(' ', start);
if (end == std::string::npos) end = cmd.size();
return cmd.substr(start, end - start);
}
return "";
}
std::string basename_of_path(const std::string& path) {
size_t s1 = path.find_last_of('\\');
size_t s2 = path.find_last_of('/');
size_t s = std::string::npos;
if (s1 != std::string::npos) s = s1;
if (s2 != std::string::npos && (s == std::string::npos || s2 > s)) s = s2;
if (s == std::string::npos) return path;
return path.substr(s + 1);
}
} // namespace
std::vector<ChromeInstance> scan_chrome_instances() {
#ifndef _WIN32
return {};
#else
// Format: por cada chrome.exe que tenga --remote-debugging-port, escribimos
// dos lineas: "PID:<n>" y "CMD:<commandline>", separadas por "---".
// Quoting: el CommandLine puede tener caracteres raros, pero lo escribimos
// tal cual y parseamos por prefijo. Sin JSON => sin dependencias.
// Filtrar SOLO el master process: tiene --remote-debugging-port y NO tiene
// --type= (los renderers/gpu-process/utility heredan el cmdline del padre
// pero llevan ademas --type=renderer / --type=gpu-process / etc).
const char* ps_script =
"powershell.exe -NoProfile -Command \""
"Get-CimInstance Win32_Process -Filter \\\"Name='chrome.exe'\\\" "
"| Where-Object { $_.CommandLine -like '*--remote-debugging-port=*' "
" -and $_.CommandLine -notlike '*--type=*' } "
"| ForEach-Object { "
" Write-Output '---'; "
" Write-Output ('PID:' + $_.ProcessId); "
" Write-Output ('CMD:' + $_.CommandLine) "
"}\"";
std::string out = run_capture(ps_script);
std::vector<ChromeInstance> result;
std::istringstream ss(out);
std::string line;
ChromeInstance cur;
bool have_cur = false;
while (std::getline(ss, line)) {
// Trim CR si viene con \r\n.
while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) line.pop_back();
if (line == "---") {
if (have_cur && cur.port > 0) result.push_back(cur);
cur = ChromeInstance{};
have_cur = true;
} else if (line.rfind("PID:", 0) == 0) {
try { cur.pid = (uint32_t)std::stoul(line.substr(4)); } catch (...) { cur.pid = 0; }
} else if (line.rfind("CMD:", 0) == 0) {
cur.command_line = line.substr(4);
std::string port_str = extract_flag(cur.command_line, "--remote-debugging-port");
try { cur.port = std::stoi(port_str); } catch (...) { cur.port = 0; }
cur.user_data_dir = extract_flag(cur.command_line, "--user-data-dir");
cur.profile_name = basename_of_path(cur.user_data_dir);
cur.headless =
cur.command_line.find("--headless") != std::string::npos;
}
}
if (have_cur && cur.port > 0) result.push_back(cur);
return result;
#endif
}
int kill_chromes_by_userdata(const std::string& user_data_dir_substr) {
#ifndef _WIN32
(void)user_data_dir_substr;
return -1;
#else
if (user_data_dir_substr.empty()) return -1;
// Escapamos comillas simples duplicandolas (PowerShell single-quote rule).
std::string esc;
esc.reserve(user_data_dir_substr.size() * 2);
for (char c : user_data_dir_substr) {
if (c == '\'') esc += "''";
else esc += c;
}
std::string cmd =
"powershell.exe -NoProfile -Command \""
"$n = (Get-CimInstance Win32_Process -Filter \\\"Name='chrome.exe'\\\" "
"| Where-Object { $_.CommandLine -like '*" + esc + "*' } "
"| ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; 1 } "
"| Measure-Object).Count; Write-Output $n\"";
std::string out = run_capture(cmd);
try { return std::stoi(out); } catch (...) { return -1; }
#endif
}
} // namespace navegator
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <string>
#include <vector>
#include <cstdint>
namespace navegator {
// Una instancia de chrome.exe detectada con --remote-debugging-port.
struct ChromeInstance {
uint32_t pid = 0;
int port = 0; // valor de --remote-debugging-port, 0 si no se pudo parsear
std::string user_data_dir; // valor de --user-data-dir (Windows path), vacio si no aplica
std::string profile_name; // basename de user_data_dir, util como label
bool headless = false; // si tiene --headless o --headless=new
std::string command_line; // crudo, para diagnostico
};
// Escanea procesos chrome.exe del sistema y devuelve solo los que tienen
// --remote-debugging-port. Implementacion v0: invoca PowerShell con
// Get-CimInstance. Es lenta (~500ms) — el panel debe llamar en thread aparte.
//
// Si la consulta falla, retorna lista vacia (no lanza). Linux: stub vacio.
std::vector<ChromeInstance> scan_chrome_instances();
// Mata todos los chrome.exe cuya commandline contenga 'user_data_dir_substr'
// como substring. Util para cerrar perfiles aislados sin afectar al Chrome
// normal del usuario. Devuelve numero de procesos matados o -1 en error.
int kill_chromes_by_userdata(const std::string& user_data_dir_substr);
} // namespace navegator
+355
View File
@@ -0,0 +1,355 @@
// local_api.cpp — Servidor HTTP minimo (WinSock) para que scripts/agentes
// hablen con el dashboard. Sin dependencias externas: parser de request +
// dispatch + JSON escapado a mano. ~250 LoC.
#include "local_api.h"
#include "chrome_scanner.h"
#include "chrome_launcher.h"
#include <atomic>
#include <cstdio>
#include <cstring>
#include <map>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
#ifdef _WIN32
# define WIN32_LEAN_AND_MEAN
# include <winsock2.h>
# include <ws2tcpip.h>
# pragma comment(lib, "Ws2_32.lib")
#endif
namespace navegator {
std::atomic<bool> g_api_running{false};
std::atomic<int> g_api_port{0};
std::atomic<int> g_api_request_count{0};
namespace {
// ---------- JSON helpers ----------
std::string json_escape(const std::string& s) {
std::string out; out.reserve(s.size() + 8);
for (char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\b': out += "\\b"; break;
case '\f': out += "\\f"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if ((unsigned char)c < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", (unsigned)c);
out += buf;
} else {
out += c;
}
}
}
return out;
}
std::string instance_to_json(const ChromeInstance& i) {
std::ostringstream os;
os << "{\"pid\":" << i.pid
<< ",\"port\":" << i.port
<< ",\"profile\":\"" << json_escape(i.profile_name) << "\""
<< ",\"user_data_dir\":\"" << json_escape(i.user_data_dir) << "\""
<< ",\"headless\":" << (i.headless ? "true" : "false")
<< "}";
return os.str();
}
// ---------- URL decoder + query parser ----------
std::string url_decode(const std::string& s) {
std::string out; out.reserve(s.size());
for (size_t i = 0; i < s.size(); ++i) {
if (s[i] == '+') {
out += ' ';
} else if (s[i] == '%' && i + 2 < s.size()) {
int hi = 0, lo = 0;
auto hex = [](char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return 0;
};
hi = hex(s[i + 1]);
lo = hex(s[i + 2]);
out += (char)((hi << 4) | lo);
i += 2;
} else {
out += s[i];
}
}
return out;
}
std::map<std::string, std::string> parse_query(const std::string& q) {
std::map<std::string, std::string> m;
size_t start = 0;
while (start < q.size()) {
size_t amp = q.find('&', start);
std::string pair = q.substr(start, amp == std::string::npos ? std::string::npos : amp - start);
size_t eq = pair.find('=');
if (eq != std::string::npos) {
m[url_decode(pair.substr(0, eq))] = url_decode(pair.substr(eq + 1));
} else if (!pair.empty()) {
m[url_decode(pair)] = "";
}
if (amp == std::string::npos) break;
start = amp + 1;
}
return m;
}
// ---------- Default user_data_dir resolver ----------
std::string default_user_data_dir(const std::string& profile) {
#ifdef _WIN32
char buf[MAX_PATH] = {0};
DWORD n = GetEnvironmentVariableA("USERPROFILE", buf, sizeof(buf));
std::string base = (n > 0 && n < sizeof(buf)) ? buf : "C:\\Users\\Public";
return base + "\\AppData\\Local\\navegator_profiles\\" + profile;
#else
(void)profile;
return "";
#endif
}
// ---------- Endpoint handlers ----------
struct Response {
int status = 200;
std::string content_type = "application/json";
std::string body;
};
Response handle_health() {
Response r;
r.body = "{\"ok\":true,\"app\":\"navegator_dashboard\"}";
return r;
}
Response handle_browsers() {
Response r;
auto list = scan_chrome_instances();
std::ostringstream os;
os << "[";
for (size_t i = 0; i < list.size(); ++i) {
if (i) os << ",";
os << instance_to_json(list[i]);
}
os << "]";
r.body = os.str();
return r;
}
Response handle_spawn(const std::map<std::string, std::string>& q) {
Response r;
LaunchOpts o;
auto profile_it = q.find("profile");
std::string profile = (profile_it != q.end() && !profile_it->second.empty())
? profile_it->second : "default";
auto port_it = q.find("port");
if (port_it != q.end() && !port_it->second.empty()) {
try { o.port = std::stoi(port_it->second); } catch (...) {}
}
auto headless_it = q.find("headless");
if (headless_it != q.end()) {
const std::string& v = headless_it->second;
o.headless = (v == "1" || v == "true" || v == "yes");
}
auto udd_it = q.find("user_data_dir");
o.user_data_dir = (udd_it != q.end() && !udd_it->second.empty())
? udd_it->second : default_user_data_dir(profile);
auto url_it = q.find("url");
if (url_it != q.end()) o.start_url = url_it->second;
auto res = launch_chrome(o);
std::ostringstream os;
os << "{\"ok\":" << (res.ok ? "true" : "false")
<< ",\"pid\":" << res.pid
<< ",\"port\":" << o.port
<< ",\"profile\":\"" << json_escape(profile) << "\""
<< ",\"user_data_dir\":\"" << json_escape(o.user_data_dir) << "\"";
if (!res.ok) {
os << ",\"error\":\"" << json_escape(res.error) << "\"";
r.status = 500;
}
os << "}";
r.body = os.str();
return r;
}
Response handle_kill(const std::map<std::string, std::string>& q) {
Response r;
std::string udd;
auto udd_it = q.find("user_data_dir");
if (udd_it != q.end() && !udd_it->second.empty()) {
udd = udd_it->second;
} else {
auto p_it = q.find("profile");
if (p_it != q.end() && !p_it->second.empty()) {
udd = default_user_data_dir(p_it->second);
}
}
if (udd.empty()) {
r.status = 400;
r.body = "{\"ok\":false,\"error\":\"need profile= or user_data_dir=\"}";
return r;
}
int killed = kill_chromes_by_userdata(udd);
std::ostringstream os;
os << "{\"ok\":" << (killed >= 0 ? "true" : "false")
<< ",\"killed\":" << (killed < 0 ? 0 : killed)
<< ",\"user_data_dir\":\"" << json_escape(udd) << "\"}";
r.body = os.str();
return r;
}
Response handle_not_found(const std::string& path) {
Response r;
r.status = 404;
r.body = std::string("{\"ok\":false,\"error\":\"not found: ") + json_escape(path) + "\"}";
return r;
}
Response dispatch(const std::string& method, const std::string& path, const std::string& query) {
auto q = parse_query(query);
if (method == "GET" && path == "/health") return handle_health();
if (method == "GET" && path == "/browsers") return handle_browsers();
if (method == "POST" && path == "/spawn") return handle_spawn(q);
if (method == "POST" && path == "/kill") return handle_kill(q);
return handle_not_found(path);
}
// ---------- HTTP request parsing ----------
struct Request {
std::string method;
std::string path;
std::string query;
};
bool parse_request_line(const std::string& line, Request& req) {
size_t sp1 = line.find(' ');
if (sp1 == std::string::npos) return false;
size_t sp2 = line.find(' ', sp1 + 1);
if (sp2 == std::string::npos) return false;
req.method = line.substr(0, sp1);
std::string url = line.substr(sp1 + 1, sp2 - sp1 - 1);
size_t qm = url.find('?');
if (qm == std::string::npos) {
req.path = url;
req.query = "";
} else {
req.path = url.substr(0, qm);
req.query = url.substr(qm + 1);
}
return true;
}
#ifdef _WIN32
void send_all(SOCKET s, const std::string& data) {
size_t sent = 0;
while (sent < data.size()) {
int n = ::send(s, data.data() + sent, (int)(data.size() - sent), 0);
if (n <= 0) return;
sent += (size_t)n;
}
}
void handle_connection(SOCKET client) {
// Leer hasta encontrar \r\n\r\n (cabeceras). Body POST: ignoramos para v0
// (los params van en query string, mas simple).
std::string buf;
char tmp[4096];
for (int i = 0; i < 16; ++i) {
int n = ::recv(client, tmp, sizeof(tmp), 0);
if (n <= 0) break;
buf.append(tmp, n);
if (buf.find("\r\n\r\n") != std::string::npos) break;
if (buf.size() > 65536) break;
}
Request req;
Response resp;
size_t end_line = buf.find("\r\n");
if (end_line == std::string::npos || !parse_request_line(buf.substr(0, end_line), req)) {
resp.status = 400;
resp.body = "{\"ok\":false,\"error\":\"bad request\"}";
} else {
resp = dispatch(req.method, req.path, req.query);
}
std::ostringstream out;
const char* status_text = "OK";
if (resp.status == 400) status_text = "Bad Request";
else if (resp.status == 404) status_text = "Not Found";
else if (resp.status == 500) status_text = "Internal Server Error";
out << "HTTP/1.1 " << resp.status << " " << status_text << "\r\n"
<< "Content-Type: " << resp.content_type << "\r\n"
<< "Content-Length: " << resp.body.size() << "\r\n"
<< "Access-Control-Allow-Origin: *\r\n"
<< "Connection: close\r\n"
<< "\r\n"
<< resp.body;
send_all(client, out.str());
closesocket(client);
g_api_request_count.fetch_add(1);
}
void server_loop(int port) {
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) return;
SOCKET listener = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (listener == INVALID_SOCKET) { WSACleanup(); return; }
BOOL yes = TRUE;
setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, (const char*)&yes, sizeof(yes));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons((u_short)port);
addr.sin_addr.s_addr = htonl(0x7F000001); // 127.0.0.1 (loopback)
if (::bind(listener, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) {
closesocket(listener); WSACleanup();
g_api_running.store(false);
return;
}
if (::listen(listener, 16) == SOCKET_ERROR) {
closesocket(listener); WSACleanup();
g_api_running.store(false);
return;
}
g_api_running.store(true);
g_api_port.store(port);
while (true) {
sockaddr_in client_addr{};
int alen = sizeof(client_addr);
SOCKET client = ::accept(listener, (sockaddr*)&client_addr, &alen);
if (client == INVALID_SOCKET) continue;
std::thread(handle_connection, client).detach();
}
}
#endif
} // namespace
void start_api_server(int port) {
#ifdef _WIN32
if (g_api_running.load()) return;
std::thread([port]{ server_loop(port); }).detach();
#else
(void)port;
#endif
}
} // namespace navegator
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <string>
#include <atomic>
namespace navegator {
// Inicia un servidor HTTP local en 127.0.0.1:port en un thread detached.
// Idempotente — si ya esta corriendo, no hace nada.
//
// Endpoints v1:
// GET /health -> "ok"
// GET /browsers -> JSON array
// POST /spawn?profile=X&port=N&headless=0|1 -> JSON {ok,pid,port}
// POST /kill?user_data_dir=... -> JSON {killed:N}
// POST /kill?profile=NAME -> JSON {killed:N}
//
// El servidor solo bindea a 127.0.0.1 (loopback), no expone fuera del PC.
// WSL2 con mirrored networking ya alcanza este bind desde WSL.
void start_api_server(int port = 19333);
// Estado para mostrar en la UI (panel Info).
extern std::atomic<bool> g_api_running;
extern std::atomic<int> g_api_port;
extern std::atomic<int> g_api_request_count;
} // namespace navegator
+59
View File
@@ -0,0 +1,59 @@
// navegator_dashboard — cuadro de mandos para gestionar instancias Chrome con CDP.
//
// v0: Browsers panel funcional + 3 stubs (Tabs, Tab Detail, Network).
// Ver projects/navegator/apps/navegator_dashboard/app.md para arquitectura completa.
#include "app_base.h"
#include "core/icons_tabler.h"
#include "core/panel_menu.h"
#include "imgui.h"
#include "local_api.h"
namespace navegator {
void render_browsers_panel(bool* p_open);
void render_tabs_panel(bool* p_open);
void render_tab_detail_panel(bool* p_open);
void render_network_panel(bool* p_open);
}
namespace {
bool show_browsers = true;
bool show_tabs = true;
bool show_tab_detail = false;
bool show_network = false;
constexpr fn_ui::PanelToggle k_panels[] = {
{"Browsers", "Ctrl+1", &show_browsers},
{"Tabs", "Ctrl+2", &show_tabs},
{"Tab Detail", "Ctrl+3", &show_tab_detail},
{"Network", "Ctrl+4", &show_network},
};
} // namespace
static void render_dashboard() {
ImGui::DockSpaceOverViewport(0, ImGui::GetMainViewport());
if (show_browsers) navegator::render_browsers_panel(&show_browsers);
if (show_tabs) navegator::render_tabs_panel(&show_tabs);
if (show_tab_detail) navegator::render_tab_detail_panel(&show_tab_detail);
if (show_network) navegator::render_network_panel(&show_network);
}
int main() {
fn::AppConfig cfg;
cfg.title = "Navegator Dashboard";
cfg.about = {
"Navegator Dashboard",
"0.1.0",
"Cuadro de mandos para Chrome con remote debugging — v0 Browsers panel."
};
cfg.panels = k_panels;
cfg.panel_count = sizeof(k_panels) / sizeof(k_panels[0]);
cfg.init_gl_loader = false;
// HTTP API local (loopback). 127.0.0.1:19333.
// Endpoints: /health, /browsers, /spawn, /kill — ver local_api.h.
navegator::start_api_server(19333);
return fn::run_app(cfg, render_dashboard);
}
+264
View File
@@ -0,0 +1,264 @@
// Panels v0:
// - Browsers: scan + spawn + kill (funcional)
// - Tabs / Tab Detail / Network: stubs anunciando v1.
#include "imgui.h"
#include "core/icons_tabler.h"
#include "core/tokens.h"
#include "chrome_scanner.h"
#include "chrome_launcher.h"
#include "local_api.h"
#include <atomic>
#include <chrono>
#include <cstdio>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#ifdef _WIN32
# define WIN32_LEAN_AND_MEAN
# include <windows.h>
#endif
namespace navegator {
// ---------- Estado compartido del panel Browsers ----------
namespace {
struct BrowsersState {
std::mutex mu;
std::vector<ChromeInstance> instances;
std::chrono::steady_clock::time_point last_scan;
std::atomic<bool> scanning{false};
std::atomic<bool> ever_scanned{false};
std::atomic<int> selected{-1};
char new_profile[128] = "default";
int new_port = 19222;
bool new_headless = false;
std::string last_error;
};
BrowsersState g_browsers;
void rescan_async() {
if (g_browsers.scanning.exchange(true)) return;
std::thread([]{
auto v = scan_chrome_instances();
{
std::lock_guard<std::mutex> lk(g_browsers.mu);
g_browsers.instances = std::move(v);
g_browsers.last_scan = std::chrono::steady_clock::now();
}
g_browsers.ever_scanned.store(true);
g_browsers.scanning.store(false);
}).detach();
}
std::string default_user_data_dir(const std::string& profile) {
// Resolver USERPROFILE si esta disponible.
#ifdef _WIN32
char buf[MAX_PATH] = {0};
DWORD n = GetEnvironmentVariableA("USERPROFILE", buf, sizeof(buf));
std::string base = (n > 0 && n < sizeof(buf)) ? buf : "C:\\Users\\Public";
return base + "\\AppData\\Local\\navegator_profiles\\" + profile;
#else
return std::string("/tmp/navegator_profiles/") + profile;
#endif
}
} // namespace
// ---------- Browsers panel ----------
void render_browsers_panel(bool* p_open) {
if (!ImGui::Begin(TI_BROWSER " Browsers", p_open)) {
ImGui::End();
return;
}
// Auto-rescan cada 2s.
auto now = std::chrono::steady_clock::now();
{
std::lock_guard<std::mutex> lk(g_browsers.mu);
if (now - g_browsers.last_scan > std::chrono::seconds(2) && !g_browsers.scanning.load()) {
rescan_async();
}
}
// API status badge.
if (g_api_running.load()) {
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::success);
ImGui::Text("API: 127.0.0.1:%d", g_api_port.load());
ImGui::PopStyleColor();
ImGui::SameLine();
ImGui::TextDisabled("(reqs: %d)", g_api_request_count.load());
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
} else {
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::error);
ImGui::TextUnformatted("API: down");
ImGui::PopStyleColor();
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
}
// Toolbar.
if (ImGui::Button(TI_REFRESH " Rescan")) rescan_async();
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
ImGui::TextUnformatted("New profile:");
ImGui::SameLine();
ImGui::SetNextItemWidth(160);
ImGui::InputText("##profile", g_browsers.new_profile, sizeof(g_browsers.new_profile));
ImGui::SameLine();
ImGui::TextUnformatted("Port:");
ImGui::SameLine();
ImGui::SetNextItemWidth(80);
ImGui::InputInt("##port", &g_browsers.new_port, 0, 0);
ImGui::SameLine();
ImGui::Checkbox("Headless", &g_browsers.new_headless);
ImGui::SameLine();
if (ImGui::Button(TI_PLAYER_PLAY " Launch")) {
LaunchOpts o;
o.port = g_browsers.new_port;
o.headless = g_browsers.new_headless;
std::string profile = g_browsers.new_profile;
if (profile.empty()) profile = "default";
o.user_data_dir = default_user_data_dir(profile);
auto r = launch_chrome(o);
g_browsers.last_error = r.ok ? "" : r.error;
// Forzar rescan inmediato (con pequeño delay para que Chrome aparezca en CIM).
std::thread([]{
std::this_thread::sleep_for(std::chrono::milliseconds(800));
rescan_async();
}).detach();
}
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
if (ImGui::Button(TI_X " Kill all navegator")) {
kill_chromes_by_userdata("navegator_profiles");
std::thread([]{
std::this_thread::sleep_for(std::chrono::milliseconds(500));
rescan_async();
}).detach();
}
if (!g_browsers.last_error.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::error);
ImGui::TextWrapped("Error: %s", g_browsers.last_error.c_str());
ImGui::PopStyleColor();
}
ImGui::Separator();
// Tabla.
std::lock_guard<std::mutex> lk(g_browsers.mu);
// Anti-flicker: solo mostrar "Scanning..." en el primer scan (cuando aun
// no tenemos datos). Una vez tenemos al menos un resultado, mantener el
// empty-state estable; el badge "(reqs:N)" + el rescan async siguen
// corriendo en background sin tocar la UI.
if (!g_browsers.ever_scanned.load() && g_browsers.instances.empty()) {
ImGui::TextUnformatted("Scanning...");
} else if (g_browsers.instances.empty()) {
ImGui::TextDisabled("No Chrome instances with --remote-debugging-port detected.");
ImGui::TextDisabled("Lanza una con el formulario de arriba o con scripts/start.sh.");
} else {
const ImGuiTableFlags flags =
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
ImGuiTableFlags_Resizable | ImGuiTableFlags_Sortable;
if (ImGui::BeginTable("##browsers", 6, flags)) {
ImGui::TableSetupColumn("PID", ImGuiTableColumnFlags_WidthFixed, 64);
ImGui::TableSetupColumn("Port", ImGuiTableColumnFlags_WidthFixed, 64);
ImGui::TableSetupColumn("Profile");
ImGui::TableSetupColumn("Mode", ImGuiTableColumnFlags_WidthFixed, 80);
ImGui::TableSetupColumn("user-data-dir");
ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed, 110);
ImGui::TableHeadersRow();
int idx = 0;
for (const auto& inst : g_browsers.instances) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::Text("%u", inst.pid);
ImGui::TableNextColumn();
ImGui::Text("%d", inst.port);
ImGui::TableNextColumn();
ImGui::TextUnformatted(inst.profile_name.empty() ? "(none)" : inst.profile_name.c_str());
ImGui::TableNextColumn();
if (inst.headless) {
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::warning);
ImGui::TextUnformatted("headless");
ImGui::PopStyleColor();
} else {
ImGui::TextUnformatted("visible");
}
ImGui::TableNextColumn();
ImGui::TextUnformatted(inst.user_data_dir.c_str());
ImGui::TableNextColumn();
ImGui::PushID(idx);
if (ImGui::SmallButton("Kill")) {
if (!inst.user_data_dir.empty()) {
kill_chromes_by_userdata(inst.user_data_dir);
}
std::thread([]{
std::this_thread::sleep_for(std::chrono::milliseconds(500));
rescan_async();
}).detach();
}
ImGui::SameLine();
if (ImGui::SmallButton("Inspect")) {
g_browsers.selected = idx;
}
ImGui::PopID();
++idx;
}
ImGui::EndTable();
}
}
ImGui::End();
}
// ---------- Tabs panel (stub) ----------
void render_tabs_panel(bool* p_open) {
if (!ImGui::Begin(TI_LIST " Tabs", p_open)) {
ImGui::End();
return;
}
ImGui::TextDisabled("Coming in v1");
ImGui::TextWrapped("Listara las pestañas de la instancia seleccionada en Browsers via "
"CDP /json y permitira navigate/close/focus.");
ImGui::End();
}
// ---------- Tab Detail panel (stub) ----------
void render_tab_detail_panel(bool* p_open) {
if (!ImGui::Begin(TI_FILE_INFO " Tab Detail", p_open)) {
ImGui::End();
return;
}
ImGui::TextDisabled("Coming in v1");
ImGui::TextWrapped("HTML preview, screenshot live y REPL Runtime.evaluate sobre la pestaña "
"seleccionada.");
ImGui::End();
}
// ---------- Network panel (stub) ----------
void render_network_panel(bool* p_open) {
if (!ImGui::Begin(TI_ACTIVITY " Network", p_open)) {
ImGui::End();
return;
}
ImGui::TextDisabled("Coming in v1");
ImGui::TextWrapped("Log de peticiones HTTP/WS en vivo via CDP Network.* events. "
"Headers, body, timing, filtros.");
ImGui::End();
}
} // namespace navegator
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# e2e_api.sh — Suite end-to-end de la API HTTP de navegator_dashboard.
#
# Asume que `navegator_dashboard.exe` esta corriendo con la API en 127.0.0.1:19333.
# Si no esta, lanza la app primero (no la mata al final — dejarla viva para iterar).
#
# Cubre:
# 1. /health responde OK
# 2. /browsers retorna JSON array
# 3. POST /spawn lanza chrome.exe + retorna {ok:true,pid,port}
# 4. CDP responde en el puerto retornado
# 5. cdp-cli puede navegar/evaluar sobre la instancia
# 6. /browsers ahora contiene la instancia (1 entrada por chrome master, no N)
# 7. POST /kill mata la instancia
# 8. CDP deja de responder
# 9. /browsers vuelve a no contenerla
#
# Exit 0 en exito, !=0 en fallo. Cada paso imprime "OK" o "FAIL: ..." a stderr.
set -euo pipefail
API="${NAVD_API:-http://127.0.0.1:19333}"
EXE="${NAVD_EXE:-/mnt/c/Users/lucas/Desktop/apps/navegator_dashboard/navegator_dashboard.exe}"
CDP_CLI="${NAVD_CDP_CLI:-/home/lucas/fn_registry/projects/osint_graph/apps/graph_explorer/cdp-cli/cdp-cli}"
PROFILE="e2e_$(date +%s)"
PORT=19299
failures=0
step() { echo; echo "==[ $1 ]==" >&2; }
ok() { echo " OK: $1" >&2; }
fail() { echo " FAIL: $1" >&2; failures=$((failures + 1)); }
# ---------------------------------------------------------
step "0. dashboard reachable"
# ---------------------------------------------------------
if ! curl -sf --max-time 2 "$API/health" >/dev/null 2>&1; then
if [[ -x "$EXE" ]]; then
echo " app no responde, lanzando $EXE..." >&2
powershell.exe -NoProfile -Command "Start-Process -FilePath '$(wslpath -w "$EXE" 2>/dev/null || echo "$EXE")'" >/dev/null 2>&1 || true
for _ in $(seq 1 20); do
if curl -sf --max-time 1 "$API/health" >/dev/null 2>&1; then break; fi
sleep 0.5
done
fi
fi
if curl -sf --max-time 2 "$API/health" >/dev/null 2>&1; then
ok "dashboard up"
else
fail "dashboard no responde en $API/health"
exit 1
fi
# ---------------------------------------------------------
step "1. /health"
# ---------------------------------------------------------
body="$(curl -sf --max-time 2 "$API/health")"
echo " body: $body" >&2
[[ "$body" == *'"ok":true'* ]] && ok "/health responde ok=true" || fail "/health body inesperado"
# ---------------------------------------------------------
step "2. /browsers (initial)"
# ---------------------------------------------------------
body="$(curl -sf --max-time 2 "$API/browsers")"
echo " body: $body" >&2
[[ "$body" == \[* ]] && ok "/browsers retorna array" || fail "/browsers no retorna JSON array"
# ---------------------------------------------------------
step "3. POST /spawn"
# ---------------------------------------------------------
body="$(curl -sf --max-time 10 -X POST "$API/spawn?profile=$PROFILE&port=$PORT")"
echo " body: $body" >&2
if [[ "$body" == *'"ok":true'* ]]; then
ok "/spawn launched"
SPAWNED_PID="$(echo "$body" | sed -nE 's/.*"pid":([0-9]+).*/\1/p')"
echo " pid=$SPAWNED_PID port=$PORT profile=$PROFILE" >&2
else
fail "/spawn fallo"
exit 1
fi
# ---------------------------------------------------------
step "4. CDP responde en puerto $PORT"
# ---------------------------------------------------------
ok_cdp=""
for _ in $(seq 1 20); do
if curl -sf --max-time 1 "http://127.0.0.1:$PORT/json/version" >/dev/null 2>&1; then
ok_cdp=1; break
fi
sleep 0.5
done
if [[ -n "$ok_cdp" ]]; then
browser="$(curl -sf "http://127.0.0.1:$PORT/json/version" 2>/dev/null | grep -i '"Browser"' | head -1 | tr -d '\r' || true)"
ok "CDP up —${browser}"
else
fail "CDP no responde en $PORT"
fi
# ---------------------------------------------------------
step "5. cdp-cli navigate + evaluate"
# ---------------------------------------------------------
if [[ -x "$CDP_CLI" ]]; then
"$CDP_CLI" navigate --port "$PORT" --url "data:text/html,<h1 id=t>e2e ok</h1>" >/dev/null 2>&1 \
&& ok "navigate OK" || fail "navigate fallo"
title="$("$CDP_CLI" evaluate --port "$PORT" --js "document.getElementById('t').innerText" 2>/dev/null || echo "")"
[[ "$title" == "e2e ok" ]] && ok "evaluate retorno texto correcto" || fail "evaluate retorno: '$title'"
else
fail "cdp-cli no encontrado en $CDP_CLI"
fi
# ---------------------------------------------------------
step "6. /browsers contiene instancia (filtro --type=)"
# ---------------------------------------------------------
sleep 2 # dar tiempo a que se note en el rescan async del dashboard
body="$(curl -sf --max-time 5 "$API/browsers")"
echo " body: $body" >&2
count_match="$(echo "$body" | grep -c "\"profile\":\"$PROFILE\"" || true)"
echo " matches profile $PROFILE: $count_match" >&2
if [[ "$count_match" == "1" ]]; then
ok "exactamente 1 entrada (master only, no renderers)"
elif [[ "$count_match" -ge "1" ]]; then
fail "esperaba 1, encontrado $count_match (filtro --type= roto?)"
else
fail "no encuentro el perfil $PROFILE en /browsers"
fi
# ---------------------------------------------------------
step "7. POST /kill"
# ---------------------------------------------------------
body="$(curl -sf --max-time 5 -X POST "$API/kill?profile=$PROFILE")"
echo " body: $body" >&2
[[ "$body" == *'"ok":true'* ]] && ok "/kill respondio ok" || fail "/kill fallo"
# ---------------------------------------------------------
step "8. CDP ya no responde"
# ---------------------------------------------------------
sleep 2
if curl -sf --max-time 1 "http://127.0.0.1:$PORT/json/version" >/dev/null 2>&1; then
fail "CDP sigue vivo en $PORT despues de kill"
else
ok "CDP muerto"
fi
# ---------------------------------------------------------
step "9. /browsers ya no contiene instancia"
# ---------------------------------------------------------
sleep 1
body="$(curl -sf --max-time 5 "$API/browsers")"
count_match="$(echo "$body" | grep -c "\"profile\":\"$PROFILE\"" || true)"
[[ "$count_match" == "0" ]] && ok "instancia desaparecida" || fail "/browsers sigue listando perfil $PROFILE ($count_match veces)"
# ---------------------------------------------------------
echo
if [[ $failures -eq 0 ]]; then
echo "===== e2e_api.sh: ALL PASS =====" >&2
exit 0
else
echo "===== e2e_api.sh: $failures FAILURES =====" >&2
exit 1
fi