22 Commits

Author SHA1 Message Date
egutierrez 029dbf57bd feat(core): auto-commit con 10 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 13:20:36 +02:00
egutierrez 3f6b652f3f chore(agents): subir los 6 agentes fn de sonnet a opus
Los agentes del ciclo reactivo (constructor, executor, recopilador,
analizador, mejorador, orquestador) corrian con model: sonnet. Se suben
todos a model: opus para mejorar la calidad del codigo generado y del
razonamiento durante el ciclo CONSTRUIR -> EJECUTAR -> RECOPILAR ->
ANALIZAR -> MEJORAR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:17:46 +02:00
egutierrez 5b10b419a2 feat(browser): auto-commit con 44 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 12:49:54 +02:00
egutierrez e2c073b8b7 feat(browser): set_chrome_profile_appearance v1.1.0 — color tiñe el tema del navegador
Antes --color solo escribía los campos de color en Local State (info_cache), que
únicamente tiñen el círculo del avatar en el selector de perfiles. Ahora --color
aplica además el tema del navegador (toolbar, frame/bordes, barra de pestañas y
omnibox), que es lo que permite identificar un perfil de un vistazo.

El tema vive en el Preferences del perfil, no en Local State. La función ahora
escribe browser.theme.user_color2 (SkColor ARGB con signo), browser_color_variant
y is_grayscale2, y fuerza extensions.theme.system_theme=0. Escribe también las
claves legacy sin sufijo "2" por compatibilidad de versiones. Nuevo flag
--variant <0..4> (default 3 vibrant) para la intensidad del tinte. Backup y
validación del Preferences con el mismo patrón que Local State.

Claves verificadas empíricamente con captura de pantalla en Chromium 148: un
perfil lanzado con estas claves muestra la toolbar y el frame teñidos del color.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:12:37 +02:00
egutierrez 25054ff64e feat(browser): set_chrome_profile_appearance — avatar + color de perfiles Chrome
Nueva función Bash del dominio browser para personalizar la apariencia de un
perfil Chrome/Chromium y diferenciarlo de un vistazo. Edita
`profile.info_cache.<perfil>` en el Local State del user-data-dir:

- `--avatar <N>`: avatar built-in de Chrome (índice 0..55) vía
  `avatar_icon = chrome://theme/IDR_PROFILE_AVATAR_<N>`. Camino robusto.
- `--avatar <ruta.png>`: avatar custom best-effort (copia la imagen al perfil y
  marca `is_using_default_avatar=false`); ver gotchas del .md.
- `--color <#rrggbb>`: color del perfil. Convierte el hex a int32 con signo en
  formato ARGB (0xAARRGGBB) y lo aplica a `profile_highlight_color`,
  `profile_color_seed` y `default_avatar_fill_color`.

Sigue el patrón de create/delete_chrome_profile: backup del Local State antes de
escribir, validación del JSON resultante con restauración del backup si queda
inválido, guard de SingletonLock (chromium debe estar cerrado), idempotente y
con --dry-run. No crea perfiles (eso es create_chrome_profile); requiere que el
perfil ya exista. Probada con --avatar 26 --color #1f6feb y casos edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:57:12 +02:00
egutierrez 648ce63fc0 chore: auto-commit (1 archivos)
- .claude/settings.local.json

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 01:38:36 +02:00
Egutierrez 685224ccb2 fix(browser): guards chromium-por-udd dejan de auto-matchear el propio grep
Bug descubierto al ejecutar el reset real: los guards y los kills usaban
'pgrep -af [c]hromium | grep -F <udd>'. Como la ruta del user-data-dir contiene
la cadena 'chromium' (~/.config/chromium-cdp), el propio proceso grep/ugrep —cuyo
cmdline incluye <udd>— era capturado por pgrep, dando un falso positivo perpetuo:
el guard creía siempre que había un chromium abierto y delete/restore abortaban
con exit 2, y el lazo de cierre nunca convergía.

Fix en delete_chrome_profile, restore_chrome_bookmarks, create_chrome_profile y el
pipeline reset_chrome_profiles: enumerar por PID con 'pgrep -x chromium' (comm
exactamente 'chromium', nunca grep/pgrep/bash) y leer /proc/PID/cmdline para
comprobar el udd. Validado: reset destructivo real de los 4 perfiles completó OK,
cada perfil quedó con solo uBlock + web_proxy y los bookmarks restaurados.
2026-06-06 01:37:51 +02:00
Egutierrez ae841ceedb feat(browser): CRUD de perfiles Chromium + pipeline reset_chrome_profiles
Cinco funciones nuevas (dominio browser, grupo navegator) que cierran los gaps
de gestión de perfiles, más un pipeline que las orquesta:

- backup_chrome_bookmarks / restore_chrome_bookmarks: backup y restore de los
  archivos Bookmarks (copia byte a byte verbatim para preservar el checksum
  interno; en Chromium 148 los bookmarks no están bajo el super_mac de Secure
  Preferences). Guard por user-data-dir (no global).
- delete_chrome_profile: borra la carpeta del perfil + limpia su entrada en
  Local State (info_cache, profiles_order, last_active_profiles, last_used).
- create_chrome_profile: lanza chromium headless (vía systemd-run) para que la
  managed policy instale la whitelist de extensiones, y asigna el nombre legible
  en Local State. Mata todo el árbol de chromium del udd antes de editar Local
  State (los hijos zygote/gpu no repiten --user-data-dir pero referencian la ruta).
- list_chrome_profile_extensions (Go): lista extensiones de un perfil con
  ID/name/version/location/enabled/fromPolicy. 7 unit tests.
- reset_chrome_profiles (pipeline): backup -> cerrar chromium -> delete -> create
  -> restore -> verify. Destructivo (--yes), --dry-run seguro.

Validado: unit tests Go verdes, backup/restore byte-idéntico, delete limpia Local
State, create instala la forcelist global (uBlock + web_proxy) en perfiles nuevos.
2026-06-06 01:24:21 +02:00
egutierrez 736e019e19 feat(core): auto-commit con 17 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 17:34:22 +02:00
Egutierrez 1f93e9d502 docs: projects son sub-repos Gitea (dataforge/<name>)
Cada projects/<name>/ es ahora su propio repo Gitea con branch master,
versionando solo sus docs de nivel-project. apps/*/ y analysis/*/ siguen
como sub-repos hijos independientes (excluidos por el .gitignore del project).
/full-git-push|pull los manejan via discover_git_repos. Cierra el gap de
docs de nivel-project sin versionar. Aplicado a web_scraping, fn_monitoring,
message_bus.
2026-06-05 17:30:54 +02:00
Egutierrez b75bd7e154 feat(browser): apply_chromium_extension_policy soporta --keep id=update_url
Permite force-instalar extensiones self-hosted bajo managed policy indicando
un update_url propio (p.ej. file:// a un update.xml local que apunta a un .crx).
Necesario para cargar extensiones propias (como la de captura de web_proxy)
cuando hay una managed policy activa y --load-extension queda desactivado en
Chromium 137+. Forma simple '<id>' sigue usando el update_url por defecto.
2026-06-05 17:22:20 +02:00
Egutierrez e0fad0e82f feat(browser): clean_chrome_profile_extensions + fix policy backup en managed/
Rediseño de apply_chromium_extension_policy y nueva función de purga in-place,
tras resolver por qué las extensiones bloqueadas reaparecían en Chromium 148.

- apply_chromium_extension_policy: añade --block (ExtensionInstallBlocklist).
  Reemplaza el modo ExtensionSettings "*": blocked (que rompía las extensiones
  unpacked vía --load-extension, p.ej. la de captura de web_proxy con el error
  'Loading of unpacked extensions is disabled by the administrator') por una
  blocklist específica. FIX RAÍZ: los backups se guardan fuera de policies/managed/
  (en policy-backups/), porque Chromium lee TODOS los archivos del directorio
  managed/ sin filtrar extensión de nombre — un extensions.json.bak ahí se mergea
  con la policy y reinyecta las extensiones del backup (location=7).
- clean_chrome_profile_extensions (nueva): purga in-place de un perfil existente
  (borra carpetas de Extensions/ + refs en Preferences/Secure Preferences) dejando
  solo la whitelist. Complementa la policy: la policy evita reinstalación, esta
  desinstala lo ya presente. Requiere chromium cerrado.

Ambas: dominio browser, grupo navegator, guard de auto-ejecución, dry-run.
2026-06-05 17:13:49 +02:00
Egutierrez 830f2d34de feat(browser): funciones idempotentes para config de sistema de chromium
Cierra el gap de reproducibilidad entre PCs del proyecto web_scraping:
la organizacion de extensiones y el CDP global dejaban de ser pasos
manuales con sudo documentados en prosa.

- apply_chromium_extension_policy: escribe ExtensionInstallForcelist
  (whitelist via --keep) en /etc/chromium/policies/managed/extensions.json
  de forma idempotente, con backup automatico y validacion JSON. --dry-run
  previsualiza sin tocar el sistema.
- apply_chromium_cdp_flag: gestiona /etc/chromium.d/cdp (CDP global).
  Loopback por defecto, --network para bind 0.0.0.0 (con aviso), --remove
  para desactivar, --dry-run para previsualizar. Idempotente con backup.

Ambas: dominio browser, grupo navegator, impuras (escriben en /etc via
sudo), guard de auto-ejecucion (ejecutables con fn run y sourceables).

Docs del proyecto (CONVENTIONS.md reglas 8/9, CHROMIUM_SYSTEM.md
inventario + tabla accionable) ahora apuntan a 'fn run apply_chromium_*'
como metodo canonico en vez de editar los archivos de /etc a mano.
2026-06-05 16:33:35 +02:00
Egutierrez ccfa5bc78b feat(browser): funciones anti-deteccion + perfiles para web_scraping
Funciones nuevas del dominio browser (grupo navegator):
- cdp_move_mouse_human / cdp_click_human: movimiento de raton con curva
  de Bezier cubica, easing y micro-jitter para imitar comportamiento
  humano y reducir deteccion de automatizacion.
- cdp_wait_idle: espera network-idle contando requests en vuelo via
  eventos CDP Network.*; inmune a extensiones que mutan el DOM
  (Dark Reader, uBlock) y a animaciones JS.
- list_chrome_profiles: lista perfiles de un user-data-dir (extensiones,
  nombre legible, preferencias).
- prepare_chrome_profile (bash): clona un user-data-dir conservando solo
  una whitelist de extensiones (default uBlock Origin Lite).

Modificadas:
- chrome_launch: Linux-first (chromium/google-chrome/brave antes que
  chrome.exe), KeepExtensions y Setpgid para matar el arbol con cdp_close.
- cdp_close: kill por grupo de proceso.

Todas con tests verdes (go test ./functions/browser ok).
2026-06-05 16:25:11 +02:00
egutierrez 729921e16e feat(cybersecurity): auto-commit con 48 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 23:44:39 +02:00
egutierrez efc9911925 feat(kotlin-compose): design system fn.compose:ui + toolbelt android Linux-first
Design system Compose (kotlin/functions/ui, modulo Gradle `fn.compose:ui`):
- FnTokens + FnTheme con la paleta heredada al hex de cpp/DESIGN_SYSTEM.md
  (Mantine v9 dark + indigo), identica a la web @fn_library y a las apps C++.
- 26 componentes Compose (Layout/Display/Inputs/Feedback/Data/Charts) +
  FnTheme + FnTokens registrados en el registry (28 entradas kind=component
  lang=kt domain=ui), descubribles via fn_search. Habilitan init_kotlin_app.

Recuperacion: el commit cb6d9e6 habia anadido `kotlin/functions/ui/` al
.gitignore, por eso el design system nunca se versiono y se perdio del working
tree. Des-ignorado; el .gitignore interno del modulo ya excluye
build/.gradle/local.properties. La gallery (apps/gallery_kt) se recupero del
sub-repo Gitea y sus 27 componentes se reconstruyeron con su MainActivity como
contrato exacto.

Toolbelt Android Linux-first (antes asumia WSL2 + Windows):
- adb_wsl 1.1.0, android_emulator_start 1.1.0, android_emulator_list 1.1.0:
  resuelven adb/emulator nativos del SDK ($ANDROID_HOME), .exe solo fallback WSL2.
- android_emulator_start: fix `timeout adb_run wait-for-device` (timeout no puede
  ejecutar una funcion del shell; ahora invoca el binario $ADB directamente).
- install_android_sdk 1.0.1: fix licencias bajo pipefail (SIGPIPE de `yes`) +
  trap EXIT con variable unbound.
- docs/capabilities/android.md regenerado Linux-first + seccion design system.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:43:59 +02:00
egutierrez c65f1698ae fix(infra): write_mcp_jupyter_config usa wrapper jupyter_mcp_serve con el venv del analysis
El .mcp.json generado ahora apunta al wrapper jupyter_mcp_serve.sh con env overrides
(JUPYTER_MCP_VENV/ROOT/PORT/TOKEN) en vez del console-script jupyter-mcp-server directo.

Antes: el .mcp.json solo CONECTABA a un Jupyter ya existente y, si se abria Claude
desde la raiz del repo, el MCP usaba el venv canonico python/.venv (sin las deps del
analisis). Ahora el wrapper arranca (o reusa) un Jupyter con el venv del propio
analisis, asi que abrir Claude desde el directorio del analisis basta y cada analisis
ejecuta con sus dependencias sin contaminar python/.venv.

Bump v1.2.0. Declara dependencia jupyter_mcp_serve_bash_infra.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:48:01 +02:00
egutierrez 516db8efc0 feat(infra): auto-commit con 10 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 16:56:53 +02:00
egutierrez fa09ff9866 feat(infra): auto-commit con 4 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 16:44:23 +02:00
egutierrez 6aec0413bb feat(infra): auto-commit con 6 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 16:16:36 +02:00
egutierrez ea6a3ec8a5 chore: auto-commit (3 archivos)
- docs/adr/README.md
- docs/adr/0005-keep-parent-git-lean.md
- docs/diary/2026-06-03.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 12:50:09 +02:00
Egutierrez 3c1061fbd8 feat(core): dag_parse parsea continue_on.exit_code + retry_policy (v1.1.0)
DagContinueOn gana el campo ExitCodes []int (codigos de salida tolerados) y el
parser mapea continue_on.exit_code desde el YAML. retry_policy (limit,
interval_sec) ya existia en el modelo y ahora queda documentado como contrato
estable para los executors.

Funcion pura: solo normaliza el YAML al modelo DagDefinition; la interpretacion
(reintentar, tolerar codigos) vive en el executor que lo consuma (dag_engine).

Test: 'parsea continue_on.exit_code y retry_policy'. Tag de grupo: scheduler.
2026-06-03 12:44:26 +02:00
241 changed files with 17538 additions and 256 deletions
+2 -1
View File
@@ -21,7 +21,7 @@ Cualquier decision tecnica que choque con estos objetivos esta mal priorizada. E
**Sync entre PCs:** `fn sync` sincroniza datos no regenerables (proposals, apps, projects, analysis, vaults, pc_locations) contra `registry_api` en `https://registry.organic-machine.com`. Config: `~/.fn_pc` (identidad del PC), `FN_REGISTRY_API` (URL con basicAuth), `REGISTRY_API_TOKEN` (token).
**Sub-repos:** cada app y cada analysis es su propio repo Gitea en `dataforge/<basename>` con branch `master` (ver ADR 0002). `apps/*` y `analysis/*` estan en el `.gitignore` del repo padre — el codigo de cada app vive en `apps/<name>/.git/`. Los slash commands `/full-git-push` y `/full-git-pull` orquestan push/pull/clone de fn_registry + todos los sub-repos + `fn sync`. `/full-git-push` auto-inicializa apps/analyses sin `.git` via `ensure_repo_synced_bash_infra`. Los `vaults/` y `subrepos/` NO entran en este flujo. **Gotcha worktrees**: si creas una app nueva dentro de un git worktree del repo padre, haz `git init` dentro de `apps/<name>/` ANTES de limpiar el worktree, sino el codigo se pierde (apps/* gitignored). Ver `.claude/rules/apps_subrepo.md`.
**Sub-repos:** cada app, cada analysis y **cada project** es su propio repo Gitea en `dataforge/<basename>` con branch `master` (ver ADR 0002). `apps/*`, `analysis/*` y `projects/*` estan en el `.gitignore` del repo padre — el codigo de cada app vive en `apps/<name>/.git/`. Cada `projects/<name>/` es a su vez un sub-repo que versiona solo sus docs de nivel-project (`project.md`, `CONVENTIONS.md`, ...) con un `.gitignore` interno que excluye `apps/*/` y `analysis/*/` (sub-repos hijos). Ver `.claude/rules/projects.md`. Los slash commands `/full-git-push` y `/full-git-pull` orquestan push/pull/clone de fn_registry + todos los sub-repos + `fn sync`. `/full-git-push` auto-inicializa apps/analyses sin `.git` via `ensure_repo_synced_bash_infra`. Los `vaults/` y `subrepos/` NO entran en este flujo. **Gotcha worktrees**: si creas una app nueva dentro de un git worktree del repo padre, haz `git init` dentro de `apps/<name>/` ANTES de limpiar el worktree, sino el codigo se pierde (apps/* gitignored). **REGLA DURA**: el repo padre NUNCA trackea contenido de artefactos hijos (apps/analysis/projects) — solo `.gitkeep`. Nada de `git add -f` sobre esos paths: deja el padre permanentemente dirty (doble-tracking). Auditoria + fix en `.claude/rules/apps_subrepo.md`. Ver `.claude/rules/apps_subrepo.md`.
**Artefactos:** termino paraguas para apps, analysis, vaults, projects y playgrounds — todo lo que NO es codigo reutilizable. Usa "artefacto" cuando una afirmacion aplica a varios tipos a la vez para no repetir la lista. Ver `.claude/rules/artefactos.md` y `.claude/rules/playgrounds.md`.
@@ -193,6 +193,7 @@ Regla decisiva: antes de cada bloque de codigo, decide caso. Si dudas entre 2 y
| `client._http.request(...)` directo cuando hay wrapper en el registry | Salta validacion del wrapper y telemetria | Usar wrapper; si la firma no cubre el caso, proponer extension via `fn proposal add` |
| Scripts en `temp/` para composiciones que se repiten | Codigo se pierde y no se monitoriza | Pipeline en `python/functions/pipelines/` o pipeline Bash en `bash/functions/pipelines/` |
| Imports `from <pkg> import *` en heredoc | Imposible saber que funcion del registry se uso | Imports explicitos `from <domain> import <name1>, <name2>` |
| `claude -p` o `subprocess(["claude", "-p", ...])` para obtener una respuesta del modelo | Lento (cold start ~7-15s, carga MCP + CLAUDE.md), caro, sin control de tools | `ask_llm` (grupo `claude-direct`, API directa, arranque 0). Ver regla `llm_invocation.md` |
Excepciones autorizadas para `sqlite3` directo (no requieren MCP): `.schema`, `.tables`, `PRAGMA table_info`, `COUNT(*) GROUP BY`, JOINs custom entre tablas que el MCP no expone.
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-analizador
description: "Agente analizador (Fase 4) del ciclo reactivo. Lee `e2e_checks` declarados en app.md, ejecuta la suite via `e2e_run_checks_go_infra`, evalua assertions activas, calcula drift de metricas vs historico, persiste resultado en `e2e_runs` de operations.db y devuelve veredicto caveman pass/fail. NO modifica codigo ni propone fixes — eso es trabajo de fn-mejorador (Fase 5)."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-constructor
description: "Agente constructor (Fase 1) del ciclo reactivo. Construye funciones, tests y tipos en Go, Python, TypeScript y Bash para fn_registry."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-executor
description: "Agente ejecutor (Fase 2) del ciclo reactivo. Prepara apps, ejecuta pipelines/funciones Go y Python, y registra ejecuciones en operations.db."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-mejorador
description: "Agente mejorador (Fase 5) del ciclo reactivo. Lee resultados fallidos de fn-analizador desde `e2e_runs`/`assertion_results`, busca contexto en el registry, y crea proposals con evidencia trazable. NO modifica codigo: solo abre proposals para que un humano (o el bucle autonomo del issue 0069) decida."
model: sonnet
model: opus
tools: Read, Bash, Grep, Glob
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-orquestador
description: "Meta-orquestador (Fase 6) del ciclo reactivo. Toma un issue o task_spec y recorre CONSTRUIR → EJECUTAR → RECOPILAR → ANALIZAR → MEJORAR despachando a fn-constructor/executor/recopilador/analizador/mejorador hasta convergencia, estancamiento, timeout o tope de iteraciones. Trabaja SIEMPRE en rama sandbox `auto/<issue>`, NUNCA mergea a master, persiste progreso en `task_runs`. Issue 0069."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-recopilador
description: "Agente recopilador (Fase 3) del ciclo reactivo. Audita operations.db de apps, valida integridad de datos operativos (entities, relations, executions, assertions, logs), y verifica que la estructura del ejecutor esta correcta. Modo extra `design-e2e <app_id>`: propone bloque `e2e_checks` para que la fase 4 (fn-analizador) pueda validar la app sin iteracion humana."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+22 -6
View File
@@ -173,23 +173,39 @@ Si el build falla:
- "undefined reference to render" → falta quitar `static` o falta el `#ifndef FN_TEST_BUILD` en main.cpp.
- "multiple definition of main" → falta el `target_compile_definitions(... FN_TEST_BUILD)` en CMakeLists.
### 8. Ejecutar (headless en WSL)
### 8. Ejecutar (headless preferente — sin parpadeo)
WSL no tiene GLX 4.3 nativo — los tests corren bajo `xvfb` con software renderer Mesa. Wrapper canonico:
`fn::run_app_test` crea la ventana GLFW **oculta por defecto** (`GLFW_VISIBLE=FALSE`, ver `cpp/framework/app_base.cpp`). El contexto GL real se crea igual, así que el render que ejercita el Test Engine es fiel, pero la ventana nunca se mapea en pantalla: cero parpadeo, no roba foco. Por eso los tests de frontend C++ corren headless por defecto, sin tocar el código de cada app.
Dos formas de lanzar, según el entorno:
```bash
cd "$ROOT/cpp/build/linux_tests"
TEST_BIN="$(find . -name "${APP_ARG}_tests" -type f -executable | head -1)"
[ -z "$TEST_BIN" ] && { echo "no encuentro el binario de tests"; exit 1; }
timeout 90 xvfb-run -a -s "-screen 0 1280x800x24" \
env LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe \
"$TEST_BIN" 2>&1
if [ -n "$DISPLAY" ] && command -v glxinfo >/dev/null 2>&1 \
&& glxinfo 2>/dev/null | grep -q "OpenGL core profile version"; then
# Host con GL nativo (PC enmanuel, X11 + GPU): binario directo.
# La ventana ya nace oculta -> sin parpadeo, y usa la GPU real (rapido).
timeout 90 "$TEST_BIN" 2>&1
else
# CI / WSL sin GLX 4.3 nativo: display virtual en RAM + software Mesa.
timeout 90 xvfb-run -a -s "-screen 0 1280x800x24" \
env LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe \
"$TEST_BIN" 2>&1
fi
EXIT=$?
echo "EXIT: $EXIT"
```
Si en el host el usuario tiene GL nativo y `DISPLAY` funciona, el wrapper xvfb-run sigue siendo seguro (ejecuta dentro de su propio display).
Ambas vías son headless. `xvfb-run` sigue siendo seguro en host con display (corre en su propio display virtual), así que si el sniff de GL falla puedes usar siempre la rama xvfb.
**Para depurar un test a ojo** (ver la UI mientras el engine la maneja), desactiva el headless con `FN_HEADLESS=0`:
```bash
FN_HEADLESS=0 timeout 90 "$TEST_BIN" 2>&1
```
### 9. Reportar
+159
View File
@@ -0,0 +1,159 @@
---
description: "Modo launcher: das ordenes en lenguaje natural y Claude responde SOLO con la procedencia (registry/bash/heredoc) + el comando exacto, y lo ejecuta. Agiliza el lanzamiento de comandos y audita en vivo el Reg % (uso real de funciones del registry)."
---
# /modo_launcher — lanzamiento rápido registry-first
Activa un **modo de comportamiento** persistente. Mientras estás dentro, el usuario da órdenes en lenguaje natural y Claude responde con el **mínimo absoluto**: la procedencia del comando + el comando exacto + por qué, y lo ejecuta. Sin prosa, sin explicaciones largas, sin preámbulos.
El objetivo es doble:
1. **Agilizar** el lanzamiento de comandos (cero verborrea entre orden y ejecución).
2. **Auditar en vivo** que de verdad pasamos por funciones del registry antes que por bash inline — sube `Reg %` (objetivo 1 del Norte) y expone gaps reutilizables (objetivo 3).
## Activación
Al invocar `/modo_launcher` entras en **MODO LAUNCHER**. El modo permanece activo en todos los turnos siguientes hasta que el usuario escriba `salir` o `fin launcher`. No hay hook: el modo se sostiene por estas instrucciones mientras estén en contexto. Si el comportamiento se diluye tras muchos turnos, el usuario puede re-invocar `/modo_launcher` para reanclarlo.
Al entrar, responde con una sola línea de confirmación y queda a la espera:
```
MODO LAUNCHER activo. Da ordenes. 'salir' para terminar.
```
## Comportamiento por orden (regla dura)
Para CADA orden del usuario mientras el modo esté activo:
1. **Registry-first.** Mapea la orden a una capacidad y busca primero en el registry vía FTS (`mcp__registry__fn_search`) o reconoce un ID conocido. Las funciones del registry SIEMPRE tienen prioridad sobre bash inline.
2. **Clasifica la procedencia** según la taxonomía de abajo.
3. **Ejecuta directo.** Identificado el comando, ejecútalo sin pedir permiso — salvo que sea destructivo (ver guarda).
4. **Responde en el formato fijo** (abajo), con la salida cruda del comando. Nada más.
## Formato de respuesta (OBLIGATORIO en cada orden)
```
FUENTE: <etiqueta>
CMD: <comando exacto>
WHY: <razón: match FTS, ID conocido, o "sin función → bash">
──────────
<salida cruda del comando>
```
- `FUENTE` es una de las etiquetas de la taxonomía.
- `CMD` es el comando literal lanzado (forma `./fn run <id> [args]` para legibilidad aunque la ejecución real vaya por MCP).
- `WHY` es una línea: qué match de búsqueda o qué ID justifica esa elección. Si fue un gap, dilo.
- Tras la regla `──────────`, la salida cruda. Cero comentario después salvo que el usuario pregunte.
## Taxonomía de procedencia
| Etiqueta | Qué es | Cómo se ejecuta |
|---|---|---|
| `registry-run` | Ejecutar UNA función o pipeline del registry | `mcp__registry__fn_run <id> [args]` (preferido); fallback `./fn run <id> [args]` |
| `registry-mcp` | Inspeccionar el registro (buscar, ver, código, deps, dominios) | `mcp__registry__fn_search` / `fn_show` / `fn_code` / `fn_uses` / `fn_list_domains` |
| `heredoc` | Componer N funciones con lógica intermedia (loops, dispatch) | Heredoc `python/.venv/bin/python3 - <<'PY' ... PY` importando del registry |
| `bash` | Comando shell puro: no existe función que lo cubra | Bash directo |
| `gap` | No hay función Y el patrón parece reutilizable | Ejecuta el bash equivalente y marca el candidato (ver abajo) |
### Preferencia de ejecución para `registry-run`
- Usa `mcp__registry__fn_run` cuando esté disponible (queda registrado en `call_monitor`, alimenta el bucle reactivo).
- Si el MCP `fn_run` no está habilitado (requiere `--enable-run`), cae a `./fn run <id>` por terminal. La línea `CMD` muestra siempre la forma `./fn run <id>` por legibilidad.
## Gaps: orden sin función en el registry
Cuando una orden no tenga función que la cubra:
1. Ejecuta el bash equivalente (`FUENTE: bash`).
2. Si el patrón parece **reutilizable** (firma genérica, se repetiría en otras tareas, ≥5 líneas de lógica), añade tras la salida UNA línea:
```
CANDIDATO → <nombre_propuesto>_<lang>_<domain>: <1 frase de qué haría>
```
No lances `fn-constructor` automáticamente dentro del modo (rompería el ritmo de lanzamiento). Solo marca. El usuario decide al salir si promueve los candidatos.
## Guarda de comandos destructivos
Ejecuta directo SALVO que el comando sea irreversible o de alto impacto. En esos casos, NO ejecutes: muestra el bloque con `FUENTE`/`CMD`/`WHY` y añade `⚠ DESTRUCTIVO — confirma con 'ok'` en vez de la salida. Espera el `ok` explícito del usuario antes de lanzar.
Patrones que exigen confirmación:
- `rm -rf`, borrado de archivos versionados, `> archivo` sobre archivos trackeados.
- `git push --force`, `git reset --hard`, `git clean`, borrado de ramas.
- SQL `DROP`, `DELETE` sin `WHERE`, `TRUNCATE`, borrar cualquier `.db`.
- `deploy`, `systemctl stop/restart/disable` de services, `fn sync` (escribe en el servidor).
- `kill -9` masivo, `format`, `mkfs`, `dd`, cambios en `fstab`.
Para todo lo demás (lecturas, búsquedas, `fn run` de funciones puras o idempotentes, `git status/add/commit`, listados), ejecuta directo.
## Salida del modo
Cuando el usuario escriba `salir` o `fin launcher`, cierra el modo con un resumen caveman de una tabla:
```
=== fin MODO LAUNCHER ===
ordenes: N
registry: X (run A / mcp B)
bash: Y
gaps: Z → [lista de candidatos marcados]
Reg %: X/(X+Y) de las ordenes ejecutables golpearon el registry
```
Si hubo candidatos a función (`gap`), recuérdalos y pregunta si promover alguno vía `fn-constructor`.
## Reglas duras del modo
- **Registry-first siempre.** Nunca escribas bash inline para lógica que ya es función del registry. Busca antes de teclear.
- **Cero prosa fuera del bloque.** Nada de "Claro, voy a…". Solo el bloque `FUENTE/CMD/WHY/salida`.
- **Una orden = un bloque.** Si la orden necesita varios pasos, encadénalos en un solo `CMD` o numera los bloques, pero mantén el formato.
- **No inventes IDs.** Si no encuentras función por búsqueda, es `gap`, no un `registry-run` falso.
- **El modo no exime de las reglas del repo** (`registry_calls.md`, `purity.md`, etc.). Solo cambia el estilo de respuesta.
## Ejemplo de sesión
```
tú: /modo_launcher
yo: MODO LAUNCHER activo. Da ordenes. 'salir' para terminar.
tú: busca funciones de slice
yo: FUENTE: registry-mcp
CMD: mcp__registry__fn_search query="slice"
WHY: inspección directa del registro
──────────
filter_slice_go_core — Filtra slice por predicado
chunk_slice_go_core — Parte slice en lotes de N
...
tú: mata lo que escuche en el puerto 8484
yo: FUENTE: registry-run
CMD: ./fn run port_kill_bash_infra 8484
WHY: match FTS 'port kill' → port_kill_bash_infra
──────────
killed pid 33120 on :8484
tú: enséñame el uso de disco de home
yo: FUENTE: bash
CMD: du -sh /home/enmanuel/* | sort -rh | head
WHY: sin función → utilidad de sistema puntual
──────────
12G /home/enmanuel/fn_registry
...
CANDIDATO → disk_usage_top_bash_shell: top-N directorios por tamaño en una ruta
tú: salir
yo: === fin MODO LAUNCHER ===
ordenes: 3
registry: 2 (run 1 / mcp 1)
bash: 1
gaps: 1 → disk_usage_top_bash_shell
Reg %: 2/3 (67%)
1 candidato marcado. ¿Promuevo disk_usage_top_bash_shell vía fn-constructor?
```
## Relación con otras reglas
- `registry_calls.md` — el modo es una capa de estilo sobre los tres patrones canónicos (inspect / run / compose).
- `registry_first.md` — el modo materializa "buscar antes de escribir" en cada orden.
- `function_growth_and_self_docs.md` — los candidatos marcados alimentan la promoción de patrones inline a funciones.
- `kiss.md` — sin hook, sin estado en disco: el modo vive solo en estas instrucciones.
+2 -1
View File
@@ -21,7 +21,7 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
| 15 | [projects.md](projects.md) | Projects: agrupar apps, analysis y vaults bajo un tema |
| 16 | [kiss.md](kiss.md) | KISS en proyectos y apps: cuestionar herramientas externas, sin abstracciones especulativas |
| 17 | [apps_tbd.md](apps_tbd.md) | Trunk-based development obligatorio en apps generadas con `fn` (registry exento) |
| 17b | [apps_subrepo.md](apps_subrepo.md) | Apps son sub-repos Gitea (apps/* gitignored). `git init` dentro de cada app nueva ANTES de limpiar worktree, sino se pierde el codigo |
| 17b | [apps_subrepo.md](apps_subrepo.md) | Apps son sub-repos Gitea (apps/* gitignored). El padre NUNCA trackea contenido de artefactos hijos (solo `.gitkeep`); nada de `git add -f` sobre apps/analysis/projects o deja el padre dirty. `git init` dentro de cada app nueva ANTES de limpiar worktree, sino se pierde el codigo |
| 18 | [uses_functions.md](uses_functions.md) | Convencion de uses_functions para C++: el .md del consumidor declara las dependencias |
| 19 | [cpp_apps.md](cpp_apps.md) | Estandarizacion de apps C++: estructura, CMake, app.md, sub-repo, runtime — apunta a cpp/PATTERNS.md y cpp/DESIGN_SYSTEM.md como autoritativas |
| 20 | [artefactos.md](artefactos.md) | Termino paraguas para apps, analysis, vaults, projects y playgrounds (todo lo que no es codigo reutilizable) |
@@ -39,3 +39,4 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
| 32 | [../../dev/TAXONOMY.md](../../dev/TAXONOMY.md) | Allowlist canonica para dominios/tipos/scopes/estados/prioridades + flow patterns. Aplica a `dev/issues/` y `dev/flows/`. Issues 0100 + 0103 |
| 33 | [project_commands.md](project_commands.md) | Slash commands por project (`.claude/commands/<project>/`) expuestos via symlink. Desde fn_registry: `/<project>:foo`. Desde el project: `/foo`. Sin colision. |
| 34 | [dod_quality.md](dod_quality.md) | DoD Quality Triada: Mecanica + Cobertura (golden + edge + error path con evidencia ejecutable) + Vida util validada (>=7 dias uso real). Cierra anti-criterios contra checkbox vago. Aplica a `dev/flows/` y issues user-facing. |
| 35 | [llm_invocation.md](llm_invocation.md) | Invocacion de LLM: SIEMPRE `ask_llm` (grupo `claude-direct`, API directa, arranque 0), NUNCA `claude -p` (lento, cold start). One-shot/streaming/tool-loop + legacy `claude_stream_go_core` deprecado. |
+30
View File
@@ -45,6 +45,36 @@ Cuando el humano corre `/full-git-push` despues del merge, el script `ensure_rep
Todo lo demas (codigo de la app + app.md + appicon + service unit + tests propios de la app) vive en `apps/<name>/.git` independiente.
### REGLA DURA: el repo padre NUNCA trackea contenido de artefactos hijos
El repo padre `fn_registry` solo versiona codigo del registry (`functions/`, `types/`, `registry/`, `cmd/`, `docs/`, `.claude/`, `dev/`, `migrations/`, y el framework/functions/vendor de `cpp/`). NUNCA debe trackear el contenido de un artefacto hijo:
- apps: `apps/*`, `cpp/apps/*`, `projects/*/apps/*`
- analyses: `analysis/*`, `projects/*/analysis/*`
- projects: `projects/*`
Cada artefacto es un sub-repo Gitea independiente con su propio `.git`; su contenido completo (codigo, `app.md`, `analysis.md`, `appicon.*`, binarios, frontend, `local_files/`, tests propios) vive SOLO en ese sub-repo. `fn index` lee los `.md` de registro directamente del disco — no necesitan estar en el git del padre. Lo unico que el padre versiona dentro de esos arboles son los marcadores `.gitkeep` (mantienen `apps/` y `analysis/` presentes cuando estan vacios) y, en `projects/`, los `project.md` template si los hubiera.
**Como se rompe (sintoma = repo padre permanentemente dirty):** un `git add -f apps/<x>/...` (forzado, saltandose el `.gitignore`) o un commit que mete contenido del hijo al padre. Como el archivo ya queda en el indice, el `.gitignore` NO lo vuelve a ignorar y aparece para siempre en `git status` del padre como modificado cada vez que el sub-repo cambia (doble-tracking). Caso real (2026-06-03): `apps/dag_engine/` (31 archivos: Go + frontend + app.md) y `apps/shaders_lab/` (app.md + un binario `.exe` de 23 MB) quedaron forzados al indice del padre y lo dejaban dirty en cada cambio del sub-repo.
**Auditoria (cero salida = sano):**
```bash
git ls-files 'apps/*' 'analysis/*' 'projects/*/apps/*' 'projects/*/analysis/*' 'cpp/apps/*' \
| grep -vE '(^|/)\.gitkeep$'
```
**Fix si aparece contenido trackeado:**
```bash
# --cached SIEMPRE: saca del indice del padre sin borrar el working tree.
# El codigo sigue a salvo en el .git del sub-repo.
git rm -r --cached apps/<x>
git commit -m "chore: untrack contenido del artefacto <x> (es sub-repo Gitea)"
```
NUNCA `git rm` sin `--cached` (borraria el working tree del sub-repo). **Prevencion:** jamas usar `git add -f` sobre paths de artefactos; las reglas `apps/*/`, `analysis/*/`, `projects/*/` del `.gitignore` ya cubren el caso por defecto y solo un force las salta.
### Sintomas de la perdida
Si limpias el worktree y luego corres `ls apps/<name>/`, devuelve "No such file or directory" pese a que el issue aparece cerrado en `dev/issues/completed/`. **Patron** = scaffold sin sub-repo init = trabajo perdido.
+1 -1
View File
@@ -131,7 +131,7 @@ El `if(EXISTS ...)` hace el registro tolerante a apps no clonadas (cada app es s
### 6. Sub-repo Gitea (TBD obligatorio)
Cada app C++ es su propio repo en `dataforge/<name>` con branch `master`. Esto significa:
- El directorio `<app_dir>/` esta en el `.gitignore` de `fn_registry` (excepto `app.md`).
- TODO el directorio `<app_dir>/` (incluido `app.md`, `appicon.*`, binarios y `local_files/`) esta en el `.gitignore` de `fn_registry`: el repo padre NUNCA versiona contenido del artefacto. `fn index` lee `app.md` directo del disco, no del git. NO forzar con `git add -f` — deja el padre dirty. Ver la regla dura en `apps_subrepo.md`.
- El propio directorio tiene `.git/` apuntando al sub-repo.
- TBD obligatorio mientras se desarrolla la app: ver `apps_tbd.md`. Trabajar en `issue/<NNNN>-<slug>` o `quick/<slug>`, mergear a `master` con `--no-ff`.
- Sync entre PCs y push/pull se gestionan con `/full-git-push` y `/full-git-pull`.
+50
View File
@@ -0,0 +1,50 @@
## Invocación de LLM: SIEMPRE `ask_llm`, NUNCA `claude -p`
**REGLA DURA.** Para ejecutar un modelo LLM desde cualquier código del ecosistema (scripts, heredocs, apps, pipelines, agentes), usa el grupo `claude-direct` — empezando por `ask_llm_py_core`. **NUNCA** uses `claude -p` ni lances el binario `claude` como subproceso para obtener una respuesta del modelo.
### Por qué
| | `claude -p` | `ask_llm` / `claude-direct` |
|---|---|---|
| Mecanismo | Lanza Claude Code entero (proceso `claude`) | Habla directo a `api.anthropic.com/v1/messages` |
| Arranque | ~7-15s (carga MCP + `CLAUDE.md` ~100k tokens) | **0 — request HTTP directa** |
| Latencia/msg | ~9-15s | **~2.5s** |
| Coste | Alto (re-carga contexto cada vez) | Mínimo (solo tu prompt) |
| Tools | Las de Claude Code (no controlables) | **Las que tú defines** (`run_claude_tool_loop`) |
| Streaming | indirecto | nativo (`stream_anthropic_messages`) |
`claude -p` es lento, caro y arranca todo Claude Code para una completion. `ask_llm` es la API directa: arranque 0, rápido, con tus propias tools. Usa el token OAuth que Claude Code ya guarda en `~/.claude/.credentials.json`.
### Cómo (según el caso)
| Caso | Usa |
|---|---|
| Pregunta/chat one-shot | `fn run ask_llm "..."` o `from core.ask_llm import ask_llm` |
| Streaming de eventos crudos (text/tool_use deltas) | `stream_anthropic_messages_py_core` |
| Agente con TUS tools (tool-use loop) | `run_claude_tool_loop_py_core` (defines `tools` + `dispatch`) |
| Token OAuth | `load_claude_oauth_token_py_core` (automático dentro de las anteriores) |
| Distribuir fuera del registry | `apps/llm_cli/llm.py` (versión standalone autocontenida) |
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from core.ask_llm import ask_llm
respuesta = ask_llm("resume esto en 3 lineas: ...", model="claude-haiku-4-5-20251001", echo=False)
```
### Legacy
`claude_stream_go_core` (lanza `claude -p --output-format stream-json`) es el **camino antiguo**. No usarlo en código nuevo — preferir las funciones `claude-direct`. Queda solo para compatibilidad de consumidores existentes.
### Excepción acotada
Si una tarea necesita **genuinamente las capacidades de Claude Code** (sus tools nativas, los MCP del repo, plan mode, el contexto del proyecto) y no basta con el modelo + tus propias tools via `run_claude_tool_loop`, entonces NO es una "invocación LLM" simple: documenta por qué en el código. El **default sin excepción es `ask_llm`**.
### Telemetría / auditoría
Un `claude -p` o un `subprocess(["claude", "-p", ...])` en código nuevo es un antipatrón auditable: sustituir por `ask_llm` / `claude-direct`. Buscar usos: `grep -rn 'claude -p' --include='*.py' --include='*.sh' --include='*.go'`.
### Relación con otras reglas
- [[registry_calls]] — patrones canónicos de invocación de funciones; esta regla fija el patrón para la sub-tarea "invocar un LLM".
- [[registry_first]] — reusar antes que reescribir; `ask_llm` es la función reutilizable para LLM.
+17
View File
@@ -28,6 +28,23 @@ projects/{nombre}/
- `vault.yaml` lista los vaults con nombre, descripcion, path absoluto y tags
- Los vaults reales viven fuera del repo (ej: `~/vaults/{nombre}/`) con symlinks en el proyecto
- `fn index` escanea `projects/*/` y setea `project_id` automaticamente en apps, analyses y vaults
### Cada project es su propio repo Gitea (sub-repo)
Desde 2026-06-05 cada `projects/<nombre>/` es un **repo Gitea independiente** `dataforge/<nombre>` (branch `master`), igual que las apps y los analyses. El repo del project versiona **solo las docs de nivel-project** (`project.md`, `CONVENTIONS.md` y demás `.md`/`.claude/` propios del project). El contenido de los hijos NO se versiona aquí: cada `apps/<app>/` y cada `analysis/<a>/` es su propio sub-repo Gitea y queda excluido por el `.gitignore` del project:
```gitignore
apps/*/
analysis/*/
vaults/*
!vaults/.gitkeep
```
- **Crear el repo del project**: `ensure_repo_synced_bash_infra projects/<nombre> dataforge <nombre> master "init: project <nombre>"` (necesita `GITEA_URL` + `GITEA_TOKEN`; el token está en `pass gitea/dataforge-git-token`). Crear el `.gitignore` de arriba ANTES, para no trackear el contenido de los sub-repos hijos.
- **Push/pull**: `/full-git-push` y `/full-git-pull` ya lo manejan automáticamente — `discover_git_repos_bash_infra` descubre cualquier `.git` bajo `fn_registry`, incluidos los projects.
- **`repo_url`** en `project.md` apunta al repo del project; los `repo_url` de cada app viven en su `app.md`. Así el project "referencia" sus sub-repos sin git submodules (KISS).
- El repo padre `fn_registry` sigue ignorando `projects/*/` entero (regla `apps_subrepo.md`): nunca trackea contenido de projects.
- Estado actual: `dataforge/web_scraping`, `dataforge/fn_monitoring`, `dataforge/message_bus`.
- Apps y analyses sueltos (sin proyecto) siguen en `apps/` y `analysis/` en la raiz
### Raiz vs proyecto
+34 -6
View File
@@ -1,11 +1,27 @@
{
"permissions": {
"allow": [
"Bash(CGO_ENABLED=1 go test *)",
"Bash(sqlite3 *)"
]
},
"enabledMcpjsonServers": [
"registry",
"jupyter"
],
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_mcp.sh" },
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_fn_match.sh" }
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_mcp.sh"
},
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_fn_match.sh"
}
]
}
],
@@ -13,21 +29,33 @@
{
"matcher": "Bash|Edit|Write|MultiEdit|mcp__registry__.*",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_call_monitor.sh" }
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_call_monitor.sh"
}
]
},
{
"matcher": "Edit|Write|MultiEdit|mcp__registry__fn_create_function",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capability_tag_gate.sh" }
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capability_tag_gate.sh"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capabilities_inject.sh" },
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_first_reminder.sh" }
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capabilities_inject.sh"
},
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_first_reminder.sh"
}
]
}
]
+2 -3
View File
@@ -67,8 +67,8 @@ worktrees/
# Temp — workspace efimero para pruebas rapidas (APIs, scripts, analisis)
temp/
# C++ build artifacts
cpp/build/
# C++ build artifacts (build/, build-tests/, build-windows/, etc.)
cpp/build*/
/build/
# OS
@@ -81,7 +81,6 @@ Thumbs.db
broken_paths.txt
imgui.ini
prompts/
kotlin/functions/ui/
# Module versioning auto-generated headers (written by `fn index`, issue 0097)
**/version_generated.h
+7
View File
@@ -0,0 +1,7 @@
{
"0ea5e69b-9607-4f11-b740-005e835faef6": {
"version": "2.4.0",
"created_at": "2026-06-03T17:52:16.077873+00:00",
"document_version": "2.0.0"
}
}
Binary file not shown.
@@ -0,0 +1,71 @@
---
name: apply_chromium_cdp_flag
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "apply_chromium_cdp_flag [--port N] [--network] [--fragment-path <path>] [--remove] [--dry-run]"
description: "Gestiona de forma idempotente el fragmento /etc/chromium.d/cdp que activa Chrome DevTools Protocol global en todo chromium que el usuario lance en el equipo. Escribe, actualiza o borra el fragmento con backup automático."
tags: [navegator, chromium, cdp, devtools, browser, automation, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
params:
- name: "--port N"
desc: "Puerto TCP donde Chromium escuchará conexiones CDP. Default 9222."
- name: "--network"
desc: "Si se pasa, añade --remote-debugging-address=0.0.0.0 (accesible desde la red local). Por defecto solo loopback (127.0.0.1). Imprime advertencia de seguridad."
- name: "--fragment-path <path>"
desc: "Ruta del fragmento a escribir/borrar. Default /etc/chromium.d/cdp."
- name: "--remove"
desc: "Borra el fragmento (desactiva CDP global). Idempotente: si no existe, no-op."
- name: "--dry-run"
desc: "Imprime el fragmento que se escribiría sin tocar nada. No requiere sudo."
output: "Sale 0 en éxito (aplicado, ya-aplicado, o eliminado). Sale != 0 en error con mensaje a stderr. En caso de actualización imprime ruta del backup creado."
file_path: "bash/functions/browser/apply_chromium_cdp_flag.sh"
---
## Ejemplo
```bash
# Activar CDP global en loopback puerto 9222 (proyecto web_scraping, regla 8)
source bash/functions/browser/apply_chromium_cdp_flag.sh
apply_chromium_cdp_flag
# Previsualizar el fragmento sin escribir nada (no requiere sudo)
apply_chromium_cdp_flag --port 9222 --dry-run
# Puerto alternativo (para correr en paralelo al navegador del usuario)
apply_chromium_cdp_flag --port 9333
# Activar expuesto a la red local (RIESGO: cualquier host de la LAN puede controlar el navegador)
apply_chromium_cdp_flag --port 9222 --network
# Desactivar CDP global
apply_chromium_cdp_flag --remove
# Ruta personalizada (útil en pruebas o entornos chroot)
apply_chromium_cdp_flag --port 9222 --fragment-path /tmp/test_cdp_fragment --dry-run
```
## Cuando usarla
Al preparar un PC nuevo para controlar el chromium diario del usuario vía CDP (primer setup del proyecto `web_scraping`, regla 8). Al cambiar el puerto CDP del sistema. Al desactivar esa capacidad antes de prestar o formatear el equipo. Sustituye el paso manual "crear `/etc/chromium.d/cdp` con sudo" documentado en `CHROMIUM_SYSTEM.md`.
## Gotchas
- **Requiere sudo** para escribir bajo `/etc/`. En este equipo usar `pass show claude/sudo | sudo -S apply_chromium_cdp_flag` o ejecutar como root.
- **`--network` (0.0.0.0) es un riesgo de seguridad serio**: cualquier máquina en la red local puede conectarse al puerto CDP y controlar Chromium completamente (leer cookies, sesiones, inyectar JavaScript). Solo usar en entornos de red aislados o laboratorios.
- **El chromium ya abierto antes del cambio no hereda el flag** hasta que se reinicie. El fragmento solo se aplica en el próximo arranque de `/usr/bin/chromium`.
- **Dos procesos chromium no pueden compartir el mismo puerto**. Si el usuario ya tiene un chromium con CDP en 9222, la automatización dedicada debe arrancar con `chrome_launch_go_browser` en otro puerto (ej. 9333) o usar `--port 9333` en esta función.
- **Idempotente**: si el fragmento ya existe con contenido idéntico, la función sale 0 sin tocar nada ni crear backup.
- **Backup automático**: al sobreescribir, crea `<path>.bak.YYYYMMDD`. Si ya existe un backup del mismo día, no lo sobreescribe (el primero del día se preserva).
- **Validación post-escritura**: tras escribir, verifica con `grep` que la línea `export CHROMIUM_FLAGS` con el puerto correcto quedó en el archivo. Si falla, restaura el backup y sale con error.
- Ver `projects/web_scraping/CHROMIUM_SYSTEM.md` para el contexto completo del sistema CDP de este equipo.
@@ -0,0 +1,205 @@
#!/usr/bin/env bash
# apply_chromium_cdp_flag — gestiona el fragmento /etc/chromium.d/cdp que activa CDP global.
#
# Uso:
# apply_chromium_cdp_flag [--port N] [--network] [--fragment-path <path>] [--remove] [--dry-run]
apply_chromium_cdp_flag() {
local port=9222
local network=0
local fragment_path="/etc/chromium.d/cdp"
local remove=0
local dry_run=0
# Parseo de argumentos
while [[ $# -gt 0 ]]; do
case "$1" in
--port)
port="$2"
shift 2
;;
--network)
network=1
shift
;;
--fragment-path)
fragment_path="$2"
shift 2
;;
--remove)
remove=1
shift
;;
--dry-run)
dry_run=1
shift
;;
*)
echo "apply_chromium_cdp_flag: argumento desconocido: $1" >&2
return 1
;;
esac
done
# Validación del puerto
if ! [[ "$port" =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
echo "apply_chromium_cdp_flag: puerto inválido: $port (debe ser 1-65535)" >&2
return 1
fi
# Construcción del contenido del fragmento
local flags_line
if (( network )); then
echo "ADVERTENCIA DE SEGURIDAD: --network activa --remote-debugging-address=0.0.0.0." >&2
echo "El navegador quedará expuesto a toda la red local. Cualquier host en la red" >&2
echo "podrá controlar Chromium remotamente y leer todas las sesiones abiertas." >&2
flags_line='export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --remote-debugging-port='"${port}"' --remote-allow-origins=* --remote-debugging-address=0.0.0.0"'
else
flags_line='export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --remote-debugging-port='"${port}"' --remote-allow-origins=*"'
fi
local mode_label
if (( network )); then
mode_label="network (0.0.0.0)"
else
mode_label="loopback (127.0.0.1)"
fi
local fragment_content
fragment_content="# CDP global para automatizacion del navegador del usuario (proyecto web_scraping, regla 8).
# Bind ${mode_label} por defecto: el puerto solo
# es accesible desde 127.0.0.1, no desde la red.
${flags_line}"
# Modo --dry-run: solo mostrar y salir
if (( dry_run )); then
echo "--- dry-run: fragmento que se escribiría en ${fragment_path} ---"
echo "${fragment_content}"
echo "--- fin dry-run ---"
return 0
fi
# Modo --remove
if (( remove )); then
if [[ ! -e "$fragment_path" ]]; then
echo "apply_chromium_cdp_flag: ${fragment_path} no existe, nada que borrar."
return 0
fi
local backup_path="${fragment_path}.bak.$(date +%Y%m%d)"
if [[ ! -e "$backup_path" ]]; then
if [[ $EUID -eq 0 ]]; then
cp "$fragment_path" "$backup_path"
else
sudo cp "$fragment_path" "$backup_path" || {
echo "apply_chromium_cdp_flag: no se pudo crear backup en ${backup_path}" >&2
return 1
}
fi
fi
if [[ $EUID -eq 0 ]]; then
rm "$fragment_path"
else
sudo rm "$fragment_path" || {
echo "apply_chromium_cdp_flag: no se pudo borrar ${fragment_path}" >&2
return 1
}
fi
echo "apply_chromium_cdp_flag: fragmento eliminado (backup: ${backup_path})"
echo "Nota: el chromium ya abierto antes de este cambio no lo hereda hasta reiniciarlo."
return 0
fi
# Idempotencia: comparar con contenido actual
if [[ -f "$fragment_path" ]]; then
local current_content
current_content=$(cat "$fragment_path" 2>/dev/null)
if [[ "$current_content" == "$fragment_content" ]]; then
echo "apply_chromium_cdp_flag: ya aplicado, sin cambios (${fragment_path})."
return 0
fi
fi
# Crear directorio si falta
local fragment_dir
fragment_dir=$(dirname "$fragment_path")
if [[ ! -d "$fragment_dir" ]]; then
if [[ $EUID -eq 0 ]]; then
mkdir -p "$fragment_dir"
else
sudo mkdir -p "$fragment_dir" || {
echo "apply_chromium_cdp_flag: no se pudo crear ${fragment_dir}" >&2
return 1
}
fi
fi
# Backup si ya existe y difiere
if [[ -e "$fragment_path" ]]; then
local backup_path="${fragment_path}.bak.$(date +%Y%m%d)"
if [[ ! -e "$backup_path" ]]; then
if [[ $EUID -eq 0 ]]; then
cp "$fragment_path" "$backup_path"
else
sudo cp "$fragment_path" "$backup_path" || {
echo "apply_chromium_cdp_flag: no se pudo crear backup en ${backup_path}" >&2
return 1
}
fi
echo "apply_chromium_cdp_flag: backup creado en ${backup_path}"
fi
fi
# Escribir fragmento
local tmpfile
tmpfile=$(mktemp)
printf '%s\n' "$fragment_content" > "$tmpfile"
if [[ $EUID -eq 0 ]]; then
cp "$tmpfile" "$fragment_path"
chmod 644 "$fragment_path"
else
sudo cp "$tmpfile" "$fragment_path" || {
rm -f "$tmpfile"
echo "apply_chromium_cdp_flag: no se pudo escribir ${fragment_path}" >&2
return 1
}
sudo chmod 644 "$fragment_path" 2>/dev/null || true
fi
rm -f "$tmpfile"
# Validación post-escritura
local expected_line="--remote-debugging-port=${port}"
if ! grep -qF "$expected_line" "$fragment_path" 2>/dev/null; then
echo "apply_chromium_cdp_flag: validación fallida — la línea export no apareció en ${fragment_path}." >&2
# Restaurar backup si existe
local backup_path="${fragment_path}.bak.$(date +%Y%m%d)"
if [[ -f "$backup_path" ]]; then
if [[ $EUID -eq 0 ]]; then
cp "$backup_path" "$fragment_path"
else
sudo cp "$backup_path" "$fragment_path" 2>/dev/null || true
fi
echo "apply_chromium_cdp_flag: backup restaurado desde ${backup_path}" >&2
fi
return 1
fi
# Resumen final
echo "apply_chromium_cdp_flag: CDP global activado correctamente."
echo " Fragmento : ${fragment_path}"
echo " Puerto : ${port}"
echo " Modo : ${mode_label}"
echo ""
echo "Nota: el chromium ya abierto antes de este cambio no hereda el flag hasta reiniciarlo."
echo "Nota: dos procesos chromium no pueden compartir el mismo puerto; usa --port <otro> para"
echo " automatización dedicada que corra en paralelo al navegador del usuario."
}
# Auto-ejecución al correr el archivo directo (bash file.sh / fn run). Si se hace `source`,
# solo se define la función y no se ejecuta nada.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
apply_chromium_cdp_flag "$@"
fi
@@ -0,0 +1,90 @@
---
name: apply_chromium_extension_policy
kind: function
lang: bash
domain: browser
version: "1.1.0"
purity: impure
signature: "apply_chromium_extension_policy [--keep <ext_id[=update_url]>]... [--block <ext_id>]... [--policy-path <path>] [--update-url <url>] [--dry-run]"
description: "Escribe de forma idempotente la política managed de Chromium combinando ExtensionInstallForcelist (force-instala la whitelist --keep) y ExtensionInstallBlocklist (bloquea y desinstala la blocklist --block). No usa el comodín \"*\" blocked, por lo que NO afecta a las extensiones unpacked cargadas con --load-extension. Guarda backup fuera del directorio managed/ (que Chromium lee entero). Requiere sudo para escribir en /etc; en --dry-run no toca el sistema."
tags: [chromium, extensions, policy, browser, navegator, managed-policy, idempotent]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: "--keep <ext_id[=update_url]>"
desc: "ID de extensión a force-instalar (repetible). Va a ExtensionInstallForcelist. Forma simple '<id>' usa el update_url por defecto (Web Store). Forma '<id>=<update_url>' fuerza una extensión self-hosted: por ejemplo '<id>=file:///home/u/.web_proxy/update.xml' instala un .crx local empaquetado bajo managed policy (necesario porque --load-extension queda desactivado cuando hay managed policy). Ejemplo Web Store: ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)."
- name: "--block <ext_id>"
desc: "ID de extensión a bloquear y desinstalar en cualquier perfil (repetible). Va a ExtensionInstallBlocklist. Solo afecta a los IDs listados; el resto de extensiones no se toca."
- name: "--policy-path <path>"
desc: "Ruta del JSON de managed policy. Default: /etc/chromium/policies/managed/extensions.json."
- name: "--update-url <url>"
desc: "URL del servicio de actualización de extensiones. Default: https://clients2.google.com/service/update2/crx."
- name: "--dry-run"
desc: "Imprime el JSON que se escribiría sin tocar el sistema (no requiere sudo)."
output: "Escribe el JSON de política en policy-path y emite a stdout un resumen: extensiones forzadas, bloqueadas, ruta, backup creado y recordatorio de reinicio de Chromium. Sale 0 si la política se aplicó o ya estaba vigente. Sale != 0 en error. Requiere al menos un --keep o --block."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/browser/apply_chromium_extension_policy.sh"
---
## Ejemplo
```bash
# Dejar el perfil con solo uBlock Origin Lite: forzar uBlock + bloquear las 3 que estorban
# al scraping (Dark Reader, NoScript, OneTab). Proyecto web_scraping, regla 9.
source bash/functions/browser/apply_chromium_extension_policy.sh
apply_chromium_extension_policy \
--keep ddkjiahejlhfcafbddmgiahcphecmpfh \
--block eimadpbcbfnmbkopoojfekhnkhdbieeh \
--block doojmbjmlfjjnbmnoijecmcbfeoakpjm \
--block chphlpgkkbolifaimnlloiipkdnihall
# Previsualizar sin tocar el sistema (sin sudo)
apply_chromium_extension_policy --keep ddkjiahejlhfcafbddmgiahcphecmpfh --dry-run
# Ejecutar como root para el sudo no interactivo de este equipo
pass show claude/sudo | sudo -S bash bash/functions/browser/apply_chromium_extension_policy.sh \
--keep ddkjiahejlhfcafbddmgiahcphecmpfh --block eimadpbcbfnmbkopoojfekhnkhdbieeh
```
La policy por sí sola evita la reinstalación pero NO desinstala lo ya presente en un perfil concreto:
combínala con `clean_chrome_profile_extensions_bash_browser` (con Chromium cerrado) para purgar del
disco las extensiones ya instaladas.
## Cuando usarla
Al preparar un PC nuevo o cambiar qué extensiones de Chrome Web Store deben estar (o no estar) en
cualquier perfil de Chromium del equipo. Reemplaza el paso manual de editar el JSON de policy con
sudo. `--keep` fuerza y fija las imprescindibles; `--block` elimina las molestas sin tocar el resto.
## Gotchas
- **El backup NUNCA va dentro de `managed/`** (lo gestiona la función, pero es la lección clave): Chromium
lee **todos** los archivos del directorio `policies/managed/` sin filtrar por extensión de nombre. Un
`extensions.json.bak.YYYYMMDD` dentro de `managed/` se mergea con la policy efectiva y **reinyecta** las
extensiones del backup (se ven como `location=7` external_policy_download y vuelven aunque las borres).
Por eso la función guarda los backups en `policies/policy-backups/`, fuera de `managed/`. Si encuentras
backups antiguos dentro de `managed/`, muévelos fuera.
- **No usa el comodín `"*": blocked`**: ese modo desinstala todo lo no-whitelist pero también **bloquea las
extensiones unpacked** (`--load-extension`), rompiendo cosas como la extensión de captura de `web_proxy`
con el error "Loading of unpacked extensions is disabled by the administrator". Esta función bloquea solo
los IDs de `--block`.
- **`--load-extension` y managed policy son incompatibles en Chromium 137+**: con CUALQUIER managed policy
presente, Chromium desactiva `--load-extension` ("disabled by the administrator"). Para cargar una
extensión local junto a una managed policy hay que empaquetarla (.crx + update_url) o usar `--proxy-server`
directo en el caso de `web_proxy`.
- **Requiere sudo** para escribir en `/etc/chromium/policies/managed/`. En este equipo: `pass show claude/sudo | sudo -S <cmd>`.
- **Chrome cachea la política en memoria**: cerrar TODOS los Chromium (`pkill -9 chromium`) y relanzar, o `chrome://policy` → "Reload policies".
- **Idempotente**: si el archivo ya tiene el mismo contenido, no-op y sale 0.
- Referencia del sistema completo: `projects/web_scraping/CHROMIUM_SYSTEM.md`.
## Capability growth log
- v1.2.0 (2026-06-05) — `--keep` acepta `<id>=<update_url>` para force-instalar extensiones self-hosted (p.ej. un `.crx` local vía `file://` a un `update.xml`), que es la forma de cargar una extensión propia cuando hay managed policy y `--load-extension` está desactivado.
- v1.1.0 (2026-06-05) — añade `--block` (ExtensionInstallBlocklist); reemplaza el modo `ExtensionSettings "*": blocked` (rompía extensiones unpacked) por blocklist específica; mueve los backups fuera de `managed/` (Chromium lee todo el directorio y un `.bak` ahí reinyectaba extensiones).
- v1.0.0 (2026-06-05) — baseline: ExtensionInstallForcelist con whitelist `--keep`.
@@ -0,0 +1,257 @@
#!/usr/bin/env bash
# apply_chromium_extension_policy — Escribe de forma idempotente la política managed de Chromium
# que fuerza la instalación de una whitelist de extensiones y bloquea (desinstala) una blocklist
# concreta, sin tocar el resto. Usa ExtensionInstallForcelist + ExtensionInstallBlocklist.
apply_chromium_extension_policy() {
local policy_path="/etc/chromium/policies/managed/extensions.json"
local update_url="https://clients2.google.com/service/update2/crx"
local dry_run=0
local -a keep_ids=()
local -a block_ids=()
# --- Parseo de argumentos ---
while [[ $# -gt 0 ]]; do
case "$1" in
--keep)
if [[ -z "${2:-}" ]]; then
echo "apply_chromium_extension_policy: --keep requiere un ID de extensión" >&2
return 1
fi
keep_ids+=("$2")
shift 2
;;
--block)
if [[ -z "${2:-}" ]]; then
echo "apply_chromium_extension_policy: --block requiere un ID de extensión" >&2
return 1
fi
block_ids+=("$2")
shift 2
;;
--policy-path)
if [[ -z "${2:-}" ]]; then
echo "apply_chromium_extension_policy: --policy-path requiere un valor" >&2
return 1
fi
policy_path="$2"
shift 2
;;
--update-url)
if [[ -z "${2:-}" ]]; then
echo "apply_chromium_extension_policy: --update-url requiere un valor" >&2
return 1
fi
update_url="$2"
shift 2
;;
--dry-run)
dry_run=1
shift
;;
*)
echo "apply_chromium_extension_policy: argumento desconocido: $1" >&2
echo "Uso: apply_chromium_extension_policy [--keep <ext_id>]... [--block <ext_id>]... [--policy-path <path>] [--update-url <url>] [--dry-run]" >&2
return 1
;;
esac
done
# --- Validar que hay al menos una extensión a forzar o bloquear ---
if [[ ${#keep_ids[@]} -eq 0 && ${#block_ids[@]} -eq 0 ]]; then
echo "apply_chromium_extension_policy: se requiere al menos un --keep o un --block <ext_id>" >&2
return 1
fi
# --- Construir el JSON ---
# Dos claves complementarias, ninguna bloquea las extensiones unpacked (--load-extension),
# de modo que extensiones locales como la de captura de web_proxy siguen cargando:
# 1. ExtensionInstallForcelist: fuerza la instalación de la whitelist (--keep), que además
# no se puede desinstalar desde la UI.
# 2. ExtensionInstallBlocklist: bloquea Y desinstala las extensiones de la blocklist
# (--block) en cualquier perfil. Solo afecta a los IDs listados; el resto no se toca.
local forcelist_json="[]" blocklist_json="[]"
if [[ ${#keep_ids[@]} -gt 0 ]]; then
local entries="" first=1
for kid in "${keep_ids[@]}"; do
# Cada --keep puede ser "<id>" (usa el update_url por defecto, Web Store) o
# "<id>=<update_url>" para una extensión self-hosted (p.ej. file:// a un update.xml local).
local id="${kid%%=*}" url="$update_url"
[[ "$kid" == *=* ]] && url="${kid#*=}"
[[ $first -eq 0 ]] && entries+=","$'\n'
entries+=" \"${id};${url}\""
first=0
done
forcelist_json=$(printf '[\n%s\n ]' "$entries")
fi
if [[ ${#block_ids[@]} -gt 0 ]]; then
local entries="" first=1
for id in "${block_ids[@]}"; do
[[ $first -eq 0 ]] && entries+=","$'\n'
entries+=" \"${id}\""
first=0
done
blocklist_json=$(printf '[\n%s\n ]' "$entries")
fi
local new_json
new_json=$(cat <<JSONEOF
{
"ExtensionInstallForcelist": ${forcelist_json},
"ExtensionInstallBlocklist": ${blocklist_json}
}
JSONEOF
)
# --- Modo dry-run ---
if [[ $dry_run -eq 1 ]]; then
echo "[dry-run] JSON que se escribiría en: ${policy_path}"
echo "---"
echo "$new_json"
echo "---"
echo "[dry-run] No se ha modificado el sistema."
return 0
fi
# --- Idempotencia: comparar con contenido actual ---
if [[ -f "$policy_path" ]]; then
local current_content
current_content=$(cat "$policy_path" 2>/dev/null || true)
if [[ "$current_content" == "$new_json" ]]; then
echo "apply_chromium_extension_policy: política ya aplicada (sin cambios). Nada que hacer."
return 0
fi
fi
# --- Backup del archivo existente ---
# CRÍTICO: el backup NUNCA puede vivir dentro del directorio de la policy. Chromium lee TODOS
# los archivos del directorio managed/ (sin filtrar por extensión de nombre), así que un
# "extensions.json.bak.YYYYMMDD" dentro de managed/ se mergea con la policy efectiva y reinyecta
# las extensiones del backup. Por eso el backup se guarda en un directorio hermano (policy-backups)
# que chromium no lee.
local backup_path=""
if [[ -f "$policy_path" ]]; then
local date_suffix policy_dir backup_dir
date_suffix=$(date +%Y%m%d)
policy_dir="$(dirname "$policy_path")"
case "$(basename "$policy_dir")" in
managed|recommended) backup_dir="$(dirname "$policy_dir")/policy-backups" ;;
*) backup_dir="$policy_dir" ;;
esac
backup_path="${backup_dir}/$(basename "$policy_path").bak.${date_suffix}"
if [[ ! -d "$backup_dir" ]]; then
if [[ $EUID -ne 0 ]]; then sudo mkdir -p "$backup_dir" 2>/dev/null; else mkdir -p "$backup_dir"; fi
fi
if [[ ! -f "$backup_path" ]]; then
echo "apply_chromium_extension_policy: creando backup → ${backup_path}"
if [[ $EUID -ne 0 ]]; then
sudo cp "$policy_path" "$backup_path" || {
echo "apply_chromium_extension_policy: no se pudo crear el backup en ${backup_path}" >&2
return 1
}
else
cp "$policy_path" "$backup_path" || {
echo "apply_chromium_extension_policy: no se pudo crear el backup en ${backup_path}" >&2
return 1
}
fi
else
echo "apply_chromium_extension_policy: backup del día ya existe (${backup_path}), se omite."
fi
fi
# --- Crear directorio padre si no existe ---
local policy_dir
policy_dir=$(dirname "$policy_path")
if [[ ! -d "$policy_dir" ]]; then
echo "apply_chromium_extension_policy: creando directorio ${policy_dir}"
if [[ $EUID -ne 0 ]]; then
sudo mkdir -p "$policy_dir" || {
echo "apply_chromium_extension_policy: no se pudo crear el directorio ${policy_dir}" >&2
return 1
}
else
mkdir -p "$policy_dir" || {
echo "apply_chromium_extension_policy: no se pudo crear el directorio ${policy_dir}" >&2
return 1
}
fi
fi
# --- Escribir el JSON vía tmpfile + sudo cp ---
local tmpfile
tmpfile=$(mktemp /tmp/chromium_policy_XXXXXX.json)
echo "$new_json" > "$tmpfile"
if [[ $EUID -ne 0 ]]; then
sudo cp "$tmpfile" "$policy_path" || {
echo "apply_chromium_extension_policy: no se pudo escribir ${policy_path}" >&2
rm -f "$tmpfile"
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
echo "apply_chromium_extension_policy: restaurando backup tras error..."
sudo cp "$backup_path" "$policy_path" 2>/dev/null || true
fi
return 1
}
else
cp "$tmpfile" "$policy_path" || {
echo "apply_chromium_extension_policy: no se pudo escribir ${policy_path}" >&2
rm -f "$tmpfile"
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
echo "apply_chromium_extension_policy: restaurando backup tras error..."
cp "$backup_path" "$policy_path" 2>/dev/null || true
fi
return 1
}
fi
rm -f "$tmpfile"
# --- Validar el JSON escrito ---
local validation_ok=0
if command -v python3 &>/dev/null; then
python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$policy_path" 2>/dev/null && validation_ok=1
elif command -v jq &>/dev/null; then
jq . "$policy_path" &>/dev/null && validation_ok=1
else
validation_ok=1
fi
if [[ $validation_ok -eq 0 ]]; then
echo "apply_chromium_extension_policy: el JSON escrito no es válido — restaurando backup" >&2
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
if [[ $EUID -ne 0 ]]; then
sudo cp "$backup_path" "$policy_path" 2>/dev/null || true
else
cp "$backup_path" "$policy_path" 2>/dev/null || true
fi
fi
return 1
fi
# --- Resumen final ---
echo "apply_chromium_extension_policy: política aplicada correctamente."
echo " Ruta : ${policy_path}"
if [[ ${#keep_ids[@]} -gt 0 ]]; then
echo " Forzadas (${#keep_ids[@]}):"
for id in "${keep_ids[@]}"; do echo " - ${id}"; done
fi
if [[ ${#block_ids[@]} -gt 0 ]]; then
echo " Bloqueadas/desinstaladas (${#block_ids[@]}):"
for id in "${block_ids[@]}"; do echo " - ${id}"; done
fi
echo " Extensiones unpacked (--load-extension, p.ej. web_proxy): NO afectadas."
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
echo " Backup : ${backup_path}"
fi
echo ""
echo " AVISO: Chromium cachea la politica en memoria. Para que surta efecto:"
echo " pkill -9 chromium (cierra TODOS los procesos)"
echo " # Luego relanza Chromium. O desde un Chromium abierto:"
echo " # chrome://policy → 'Reload policies'"
}
# Auto-ejecución al correr el archivo directo (bash file.sh / fn run). Si se hace `source`,
# solo se define la función y no se ejecuta nada.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
apply_chromium_extension_policy "$@"
fi
@@ -0,0 +1,79 @@
---
name: backup_chrome_bookmarks
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "backup_chrome_bookmarks --user-data-dir <dir> [--profile <name>]... [--backup-dir <dir>] [--dry-run]"
description: "Copia byte a byte los archivos Bookmarks de perfiles Chrome/Chromium a un directorio de backup con timestamp ISO. Descubre automáticamente todos los perfiles con Bookmarks si no se especifica ninguno. Preserva el checksum interno del archivo sin parsear ni reserializar el JSON. No requiere que Chromium esté cerrado."
tags: [navegator, chromium, bookmarks, backup, browser, scraping]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/browser/backup_chrome_bookmarks.sh"
params:
- name: --user-data-dir
desc: "(obligatorio) Ruta raíz del user-data-dir de Chrome/Chromium. Ej: ~/.config/chromium-cdp"
- name: --profile
desc: "Nombre de carpeta de perfil a respaldar (repetible). Si no se pasa ninguno se descubren todos los perfiles que contengan un archivo Bookmarks, excluyendo System Profile."
- name: --backup-dir
desc: "Directorio raíz donde se crearán los backups. Default: ~/.local/share/web_scraping/bookmarks-backups"
- name: --dry-run
desc: "Muestra a stderr qué archivos se copiarían y sus tamaños sin escribir nada en disco. El JSON de salida se emite igualmente."
output: "JSON en stdout: {backup_dir: \"<dir>\", ts: \"<YYYYMMDDTHHmmss>\", profiles: [{profile: \"<name>\", src: \"<path>\", dst: \"<path>\", bytes: N}, ...]}. Perfiles sin Bookmarks se omiten silenciosamente. Exit 0 en éxito o dry-run. Errores a stderr con exit != 0."
---
## Ejemplo
```bash
# Backup de todos los perfiles del chromium-cdp (descubrimiento automático)
source $HOME/fn_registry/bash/functions/browser/backup_chrome_bookmarks.sh
backup_chrome_bookmarks --user-data-dir "$HOME/.config/chromium-cdp"
# Previsualizar sin tocar nada
backup_chrome_bookmarks \
--user-data-dir "$HOME/.config/chromium-cdp" \
--dry-run
# Backup de perfiles específicos
backup_chrome_bookmarks \
--user-data-dir "$HOME/.config/chromium-cdp" \
--profile Default \
--profile Personal \
--profile "Profile 1"
# Backup a directorio personalizado
backup_chrome_bookmarks \
--user-data-dir "$HOME/.config/chromium-cdp" \
--backup-dir "$HOME/vaults/backups/bookmarks"
# Salida esperada (ejemplo):
# {"backup_dir":"/home/enmanuel/.local/share/web_scraping/bookmarks-backups","ts":"20260605T143022","profiles":[{"profile":"Default","src":"/home/enmanuel/.config/chromium-cdp/Default/Bookmarks","dst":"/home/enmanuel/.local/share/web_scraping/bookmarks-backups/20260605T143022/Default/Bookmarks","bytes":4218}]}
```
También ejecutable directamente con `fn run`:
```bash
cd $HOME/fn_registry
./fn run backup_chrome_bookmarks_bash_browser -- \
--user-data-dir "$HOME/.config/chromium-cdp" --dry-run
```
## Cuando usarla
Úsala antes de cualquier sesión de scraping o automatización que modifique bookmarks de Chromium, para tener un snapshot recuperable. También útil como paso previo en pipelines que reorganizan o importan marcadores masivamente. Combínala con `rotate_backups_bash_infra` para aplicar política de retención sobre el directorio de backups.
## Gotchas
- **Copia verbatim para preservar checksum**: el archivo `Bookmarks` de Chromium incluye un campo `checksum` calculado sobre el contenido. Esta función usa `cp -p` sin tocar el contenido — si parseases y reserializases el JSON (con `jq`, `python3 json.dump`, etc.) el checksum quedaría inválido y Chromium podría resetear o ignorar los marcadores al arrancar.
- **No requiere Chromium cerrado**: a diferencia de `clean_chrome_profile_extensions`, esta función solo lee el archivo `Bookmarks`. Chromium no mantiene un lock exclusivo sobre él — la copia es segura con el navegador abierto. El archivo refleja el estado en disco en ese instante; cambios en vuelo en memoria no estarán en el backup hasta que Chromium los persista.
- **Perfiles sin Bookmarks se omiten silenciosamente**: si un perfil existe pero no tiene el archivo `Bookmarks` (perfil recién creado sin haber abierto el navegador), se salta sin error. Solo aparece en el JSON de salida si fue respaldado.
- **System Profile excluido siempre**: el perfil `System Profile` es un perfil interno de Chromium sin datos de usuario y se excluye del descubrimiento automático.
- **Sin jq ni python3**: la emisión del JSON de salida se construye con printf de bash puro, sin dependencias externas.
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# backup_chrome_bookmarks — copia byte a byte los archivos Bookmarks de perfiles
# Chrome/Chromium a un directorio de backup con timestamp. Preserva el checksum
# interno del archivo sin parsear ni reserializar el JSON.
set -euo pipefail
backup_chrome_bookmarks() {
# ── defaults ──────────────────────────────────────────────────────────────
local _user_data_dir=""
local _profiles=()
local _backup_dir="${HOME}/.local/share/web_scraping/bookmarks-backups"
local _dry_run=0
# ── parse args ─────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: backup_chrome_bookmarks --user-data-dir <dir> [--profile <name>]...
[--backup-dir <dir>] [--dry-run]
--user-data-dir (obligatorio) Raíz de perfiles de Chrome/Chromium.
Ej: ~/.config/chromium-cdp
--profile <name> Nombre de carpeta de perfil a respaldar (repetible).
Si no se pasa ninguno → respalda TODOS los perfiles con
un archivo Bookmarks (excluye System Profile).
--backup-dir <dir> Directorio raíz para backups.
Default: ~/.local/share/web_scraping/bookmarks-backups
--dry-run Muestra qué copiaría sin tocar nada.
Exit codes:
0 éxito (o dry-run completado)
1 error de argumento o validación
EOF
return 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--user-data-dir) _user_data_dir="$2"; shift 2 ;;
--profile) _profiles+=("$2"); shift 2 ;;
--backup-dir) _backup_dir="$2"; shift 2 ;;
--dry-run) _dry_run=1; shift ;;
-h|--help) _usage; return 0 ;;
*) echo "backup_chrome_bookmarks: argumento desconocido: $1" >&2; return 1 ;;
esac
done
# ── validaciones ──────────────────────────────────────────────────────────
if [[ -z "$_user_data_dir" ]]; then
echo "backup_chrome_bookmarks: --user-data-dir es obligatorio" >&2
return 1
fi
if [[ ! -d "$_user_data_dir" ]]; then
echo "backup_chrome_bookmarks: directorio no encontrado: ${_user_data_dir}" >&2
return 1
fi
# ── descubrir perfiles si no se pasó ninguno ───────────────────────────────
if [[ ${#_profiles[@]} -eq 0 ]]; then
local _candidate
while IFS= read -r -d '' _candidate; do
local _pname
_pname="$(basename "$_candidate")"
# Excluir System Profile (perfil interno de Chromium sin datos de usuario)
if [[ "$_pname" == "System Profile" ]]; then
continue
fi
if [[ -f "${_candidate}/Bookmarks" ]]; then
_profiles+=("$_pname")
fi
done < <(find "$_user_data_dir" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z)
fi
if [[ ${#_profiles[@]} -eq 0 ]]; then
echo "backup_chrome_bookmarks: no se encontraron perfiles con archivo Bookmarks en: ${_user_data_dir}" >&2
return 1
fi
# ── timestamp único para este backup ──────────────────────────────────────
local _ts
_ts="$(date +%Y%m%dT%H%M%S)"
# ── procesar perfiles ─────────────────────────────────────────────────────
# Construir el array de resultados JSON manualmente (sin jq ni python3)
local _results="["
local _first=1
local _profile
for _profile in "${_profiles[@]}"; do
local _src="${_user_data_dir}/${_profile}/Bookmarks"
# Si el perfil no tiene Bookmarks, se omite sin error
if [[ ! -f "$_src" ]]; then
continue
fi
local _dst="${_backup_dir}/${_ts}/${_profile}/Bookmarks"
local _bytes
_bytes="$(wc -c < "$_src")"
if [[ $_dry_run -eq 1 ]]; then
echo "backup_chrome_bookmarks: [dry-run] cp -p \"${_src}\" -> \"${_dst}\" (${_bytes} bytes)" >&2
else
local _dst_dir
_dst_dir="$(dirname "$_dst")"
mkdir -p "$_dst_dir"
cp -p "$_src" "$_dst"
fi
# Escapar comillas dobles en el path por si acaso
local _src_esc="${_src//\"/\\\"}"
local _dst_esc="${_dst//\"/\\\"}"
local _profile_esc="${_profile//\"/\\\"}"
local _entry
_entry="$(printf '{"profile":"%s","src":"%s","dst":"%s","bytes":%s}' \
"$_profile_esc" "$_src_esc" "$_dst_esc" "$_bytes")"
if [[ $_first -eq 1 ]]; then
_results+="$_entry"
_first=0
else
_results+=",${_entry}"
fi
done
_results+="]"
# ── emitir resultado JSON ──────────────────────────────────────────────────
local _backup_dir_esc="${_backup_dir//\"/\\\"}"
printf '{"backup_dir":"%s","ts":"%s","profiles":%s}\n' \
"$_backup_dir_esc" "$_ts" "$_results"
}
# ── auto-ejecución ────────────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
backup_chrome_bookmarks "$@"
fi
@@ -0,0 +1,84 @@
---
name: clean_chrome_profile_extensions
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "clean_chrome_profile_extensions [--user-data-dir <dir>] [--profile-directory <name>] [--keep <ext_id>]... [--dry-run]"
description: "Purga in-place las extensiones de un perfil Chrome/Chromium existente que no estén en la whitelist --keep: borra sus carpetas de disco y elimina sus referencias de Preferences y Secure Preferences para que Chromium no las reinstale. Complementaria a apply_chromium_extension_policy_bash_browser que evita reinstalación pero no desinstala lo ya instalado en Chromium 148."
tags: [navegator, chromium, extensions, profile, cleanup, browser, scraping]
uses_functions: [apply_chromium_extension_policy_bash_browser]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/browser/clean_chrome_profile_extensions.sh"
params:
- name: --user-data-dir
desc: "Ruta raíz del user-data-dir de Chrome/Chromium. Default: ~/.config/chromium"
- name: --profile-directory
desc: "Nombre del subperfil dentro de user-data-dir. Default: Default"
- name: --keep
desc: "ID de extensión Chrome a conservar (repetible, 32 chars minúsculas). Si no se pasa ninguno el default es ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)"
- name: --dry-run
desc: "Muestra qué IDs se conservarían y cuáles se borrarían sin tocar disco ni archivos de preferencias"
output: "JSON en stdout: {profile: \"<path>\", kept: [id...], removed: [id...]}. Exit 0 en éxito o dry-run. Errores a stderr con exit != 0."
---
## Ejemplo
```bash
# Cerrar Chromium primero (OBLIGATORIO en modo real)
pkill -TERM chromium
# Purgar perfil Default dejando solo uBlock Origin Lite
source $HOME/fn_registry/bash/functions/browser/clean_chrome_profile_extensions.sh
clean_chrome_profile_extensions --keep ddkjiahejlhfcafbddmgiahcphecmpfh
# Previsualizar antes de tocar nada
clean_chrome_profile_extensions --keep ddkjiahejlhfcafbddmgiahcphecmpfh --dry-run
# Perfil no-default con whitelist de dos extensiones
clean_chrome_profile_extensions \
--user-data-dir "$HOME/.config/chromium" \
--profile-directory "Profile 1" \
--keep ddkjiahejlhfcafbddmgiahcphecmpfh \
--keep cjpalhdlnbpafiamejdnhcphjbkeiagm
# Salida esperada (ejemplo):
# {"profile":"/home/enmanuel/.config/chromium/Default","kept":["ddkjiahejlhfcafbddmgiahcphecmpfh"],"removed":["dark-reader-id","another-ext-id"]}
```
También ejecutable directamente con `fn run`:
```bash
cd $HOME/fn_registry
./fn run clean_chrome_profile_extensions_bash_browser -- --dry-run
```
## Cuando usarla
Úsala después de reducir la whitelist de extensiones con `apply_chromium_extension_policy_bash_browser` (modo `blocked`), para quitar del disco las que ya estaban instaladas en el perfil: la policy evita que Chromium reinstale extensiones nuevas, pero en Chromium 148 no desinstala las que ya estaban force-instaladas. Esta función hace la purga determinista del estado existente. También útil antes de una sesión de scraping para dejar el perfil con solo las extensiones necesarias.
## Gotchas
- **Chromium DEBE estar cerrado** antes de ejecutar en modo real. Chromium reescribe `Preferences` desde memoria al cerrar y desharía toda la purga. La función lo comprueba con `pgrep -x chromium` y aborta con exit 2 si hay procesos vivos. En `--dry-run` no se hace este check.
- **Combínala con `apply_chromium_extension_policy_bash_browser` (blocked)** para que las extensiones no vuelvan a instalarse la próxima vez que arranques Chromium. Esta función purga el estado actual; la policy evita la reinstalación futura.
- **Backup automático de prefs**: antes de editar `Preferences` y `Secure Preferences` la función crea `<archivo>.bak.YYYYMMDD`. Si ya existe un backup del día no lo sobreescribe. En caso de problemas: `cp Preferences.bak.YYYYMMDD Preferences`.
- **Opera por perfil**: actúa sobre `--user-data-dir`/`--profile-directory`/Extensions. Si tienes varios perfiles (`Default`, `Profile 1`, etc.) debes invocarla una vez por cada uno.
- **python3 > jq > warn**: para editar el JSON de Preferences usa python3 si está disponible, jq como fallback, y emite un warning a stderr (sin abortar) si ninguno está. En ese caso las carpetas sí se borran pero las referencias en Preferences quedan — Chromium podría intentar reinstalar desde Web Store.
- **Secure Preferences HMAC**: la tabla `protection.macs.extensions.settings` también se limpia para evitar que Chromium detecte inconsistencia entre el HMAC y la entrada eliminada y resetee configuraciones. Si la HMAC falla de todas formas, Chromium lo trata como perfil potencialmente corrupto y puede resetear algunas prefs — comportamiento esperado de Chromium, no un bug de esta función.
## Exit codes
| Código | Significado |
|--------|------------|
| 0 | Éxito o dry-run completado |
| 1 | Argumento inválido o perfil no encontrado |
| 2 | Chromium está corriendo (solo en modo real) |
| 3 | Directorio Extensions no encontrado |
@@ -0,0 +1,245 @@
#!/usr/bin/env bash
# clean_chrome_profile_extensions — purga in-place extensiones fuera de la whitelist
# de un perfil Chrome/Chromium existente. Borra las carpetas de disco y limpia las
# referencias en Preferences y Secure Preferences para que Chromium no las reinstale.
set -euo pipefail
clean_chrome_profile_extensions() {
# ── defaults ──────────────────────────────────────────────────────────────
local _user_data_dir="${HOME}/.config/chromium"
local _profile_dir="Default"
local _keep=()
local _default_ext="ddkjiahejlhfcafbddmgiahcphecmpfh"
local _dry_run=0
# ── parse args ─────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: clean_chrome_profile_extensions [--user-data-dir <dir>] [--profile-directory <name>]
[--keep <ext_id>]... [--dry-run]
--user-data-dir Raíz del perfil. Default: ~/.config/chromium
--profile-directory Subperfil. Default: Default
--keep <ext_id> ID de extensión a conservar (repetible).
Default si no se pasa ninguno: ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)
--dry-run Lista qué se borraría sin tocar nada.
Exit codes:
0 éxito (o dry-run completado)
1 error de argumento o validación
2 chromium está corriendo (solo en modo real)
3 directorio de extensiones no encontrado
EOF
return 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--user-data-dir) _user_data_dir="$2"; shift 2 ;;
--profile-directory) _profile_dir="$2"; shift 2 ;;
--keep) _keep+=("$2"); shift 2 ;;
--dry-run) _dry_run=1; shift ;;
-h|--help) _usage; return 0 ;;
*) echo "clean_chrome_profile_extensions: argumento desconocido: $1" >&2; return 1 ;;
esac
done
# ── whitelist por defecto ──────────────────────────────────────────────────
if [[ ${#_keep[@]} -eq 0 ]]; then
_keep=("$_default_ext")
fi
# ── construir paths base ───────────────────────────────────────────────────
local _profile_path
_profile_path="${_user_data_dir}/${_profile_dir}"
local _ext_dir="${_profile_path}/Extensions"
# ── validaciones ──────────────────────────────────────────────────────────
if [[ ! -d "$_profile_path" ]]; then
echo "clean_chrome_profile_extensions: perfil no encontrado: ${_profile_path}" >&2
return 1
fi
if [[ ! -d "$_ext_dir" ]]; then
echo "clean_chrome_profile_extensions: directorio de extensiones no encontrado: ${_ext_dir}" >&2
return 3
fi
# ── guard: chromium NO debe estar corriendo (excepto en dry-run) ──────────
if [[ $_dry_run -eq 0 ]]; then
if pgrep -x chromium >/dev/null 2>&1; then
echo "clean_chrome_profile_extensions: chromium está corriendo — ciérralo antes de limpiar:" >&2
echo " pkill -TERM chromium" >&2
echo "(Chromium reescribe Preferences desde memoria al cerrar y desharía la purga)" >&2
return 2
fi
fi
# ── enumerar extensiones instaladas ───────────────────────────────────────
local _to_remove=()
local _to_keep=()
while IFS= read -r -d '' _ext_path; do
local _ext_id
_ext_id="$(basename "$_ext_path")"
# Siempre ignorar la carpeta Temp (usada durante installs en curso)
if [[ "$_ext_id" == "Temp" ]]; then
continue
fi
# Comprobar si está en la whitelist
local _in_keep=0
local _k
for _k in "${_keep[@]}"; do
if [[ "$_ext_id" == "$_k" ]]; then
_in_keep=1
break
fi
done
if [[ $_in_keep -eq 1 ]]; then
_to_keep+=("$_ext_id")
else
_to_remove+=("$_ext_id")
fi
done < <(find "$_ext_dir" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z)
# ── modo dry-run: solo informar ────────────────────────────────────────────
if [[ $_dry_run -eq 1 ]]; then
echo "=== clean_chrome_profile_extensions DRY-RUN ===" >&2
echo " Perfil : ${_profile_path}" >&2
echo " Conservar (${#_to_keep[@]}): ${_to_keep[*]+"${_to_keep[*]}"}" >&2
echo " Borrar (${#_to_remove[@]}): ${_to_remove[*]+"${_to_remove[*]}"}" >&2
_emit_json "$_profile_path" _to_keep _to_remove
return 0
fi
# ── borrar extensiones fuera de la whitelist ───────────────────────────────
if [[ ${#_to_remove[@]} -gt 0 ]]; then
local _id
for _id in "${_to_remove[@]}"; do
rm -rf "${_ext_dir}/${_id}"
done
# ── purgar referencias en Preferences y Secure Preferences ────────────
# Construir lista Python de IDs eliminados
local _py_ids_list=""
for _id in "${_to_remove[@]}"; do
_py_ids_list+="\"${_id}\","
done
_py_ids_list="[${_py_ids_list%,}]"
local _today
_today="$(date +%Y%m%d)"
local _prefs_file
for _prefs_file in "${_profile_path}/Preferences" "${_profile_path}/Secure Preferences"; do
if [[ ! -f "$_prefs_file" ]]; then
continue
fi
# Backup (no sobreescribir backup del mismo día)
local _backup="${_prefs_file}.bak.${_today}"
if [[ ! -f "$_backup" ]]; then
cp "$_prefs_file" "$_backup"
fi
# Editar con python3 si está disponible
if command -v python3 >/dev/null 2>&1; then
python3 - "$_prefs_file" "$_py_ids_list" <<'PY' || \
echo "clean_chrome_profile_extensions: advertencia — no se pudo purgar ${_prefs_file} con python3" >&2
import sys, json
prefs_path = sys.argv[1]
removed_ids = json.loads(sys.argv[2])
with open(prefs_path, "r", encoding="utf-8") as f:
data = json.load(f)
# 1. extensions.settings.<id>
ext_settings = data.get("extensions", {}).get("settings", {})
for ext_id in removed_ids:
ext_settings.pop(ext_id, None)
# 2. extensions.pinned_extensions (lista de IDs)
pinned = data.get("extensions", {}).get("pinned_extensions", None)
if isinstance(pinned, list):
data["extensions"]["pinned_extensions"] = [
pid for pid in pinned if pid not in removed_ids
]
# 3. protection.macs.extensions.settings.<id> (Secure Preferences HMAC table)
try:
mac_ext = data["protection"]["macs"]["extensions"]["settings"]
for ext_id in removed_ids:
mac_ext.pop(ext_id, None)
except (KeyError, TypeError):
pass
with open(prefs_path, "w", encoding="utf-8") as f:
json.dump(data, f, separators=(",", ":"))
PY
# Fallback con jq si python3 no está disponible
elif command -v jq >/dev/null 2>&1; then
local _tmp_prefs
_tmp_prefs="$(mktemp)"
local _jq_del=""
for _id in "${_to_remove[@]}"; do
_jq_del+=" | del(.extensions.settings[\"${_id}\"])"
_jq_del+=" | del(.protection.macs.extensions.settings[\"${_id}\"])"
done
# pinned_extensions como lista
_jq_del+=" | if .extensions.pinned_extensions then .extensions.pinned_extensions -= [$(printf '"%s",' "${_to_remove[@]}" | sed 's/,$//')] else . end"
jq "${_jq_del:1}" "$_prefs_file" > "$_tmp_prefs" && mv "$_tmp_prefs" "$_prefs_file" || {
echo "clean_chrome_profile_extensions: advertencia — jq falló procesando ${_prefs_file}" >&2
rm -f "$_tmp_prefs"
}
else
echo "clean_chrome_profile_extensions: advertencia — ni python3 ni jq disponibles; se borraron las carpetas pero no las referencias en $(basename "$_prefs_file")" >&2
fi
done
fi
# ── emitir resultado JSON ──────────────────────────────────────────────────
_emit_json "$_profile_path" _to_keep _to_remove
}
# ── helpers ────────────────────────────────────────────────────────────────────
# _json_array_from_nameref <nameref>
# Convierte un array bash (pasado por nombre de variable) en JSON array de strings.
_json_array_from_nameref() {
local -n _arr_ref="$1"
local _out="["
local _first=1
local _item
for _item in "${_arr_ref[@]+"${_arr_ref[@]}"}"; do
if [[ $_first -eq 1 ]]; then
_out+="\"${_item}\""
_first=0
else
_out+=",\"${_item}\""
fi
done
_out+="]"
echo "$_out"
}
# _emit_json <profile_path> <kept_nameref> <removed_nameref>
_emit_json() {
local _p="$1"
local _kept_json
_kept_json="$(_json_array_from_nameref "$2")"
local _removed_json
_removed_json="$(_json_array_from_nameref "$3")"
printf '{"profile":"%s","kept":%s,"removed":%s}\n' \
"$_p" "$_kept_json" "$_removed_json"
}
# ── auto-ejecución ────────────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
clean_chrome_profile_extensions "$@"
fi
@@ -0,0 +1,93 @@
---
name: create_chrome_profile
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "create_chrome_profile --user-data-dir <dir> --profile <dir-name> --name <legible> [--port N] [--chrome-path <path>] [--no-launch] [--timeout-sec N] [--dry-run]"
description: "Crea un perfil Chrome/Chromium nuevo en un user-data-dir: opcionalmente lanza chromium headless vía systemd-run para que la managed policy instale las extensiones forzadas (uBlock, web_proxy) y luego edita Local State para asignar el nombre legible al perfil. Con --no-launch crea solo la estructura de carpetas y la entrada en Local State sin arrancar Chrome."
tags: [navegator, chromium, profile, browser, cdp, headless, scraping]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/browser/create_chrome_profile.sh"
params:
- name: --user-data-dir
desc: "Raíz del user-data-dir de Chrome/Chromium. Puede no existir; la función lo crea. Obligatorio."
- name: --profile
desc: "Nombre de la carpeta del perfil dentro de user-data-dir, por ejemplo: Default, \"Profile 1\", Automation. Obligatorio."
- name: --name
desc: "Nombre legible visible en el selector de perfil de Chrome, por ejemplo: Work, Aurgi, Bot. Obligatorio."
- name: --port
desc: "Puerto CDP para el lanzamiento headless. Default: 9250. Usar un valor distinto al 9222 global para no colisionar."
- name: --chrome-path
desc: "Ruta absoluta al binario chromium/chrome. Si se omite, auto-detecta: chromium, chromium-browser, google-chrome, brave-browser."
- name: --no-launch
desc: "No lanza chromium. Solo crea la carpeta del perfil y edita Local State con el nombre legible. El perfil no tendrá extensiones instaladas. Útil para tests y CRUD offline."
- name: --timeout-sec
desc: "Segundos máximos esperando a que Preferences aparezca tras el lanzamiento headless. Default: 25."
- name: --dry-run
desc: "Describe las acciones que se ejecutarían sin lanzar ni escribir nada. Emite el JSON de resultado con dry_run:true."
output: "JSON en stdout: {\"profile\":\"<dir-name>\",\"name\":\"<legible>\",\"launched\":true|false,\"preferences_created\":true|false}. En dry-run añade \"dry_run\":true. Exit 0 en éxito."
---
## Ejemplo
```bash
source $HOME/fn_registry/bash/functions/browser/create_chrome_profile.sh
# Modo offline (no lanza Chrome, solo CRUD de Local State — seguro para tests)
create_chrome_profile \
--user-data-dir /tmp/test_udd \
--profile "Automation" \
--name "Aurgi Bot" \
--no-launch
# Salida: {"profile":"Automation","name":"Aurgi Bot","launched":false,"preferences_created":false}
# Modo normal: lanza headless para que la policy instale uBlock y web_proxy,
# luego asigna nombre en Local State
create_chrome_profile \
--user-data-dir "$HOME/.local/share/web_scraping/profiles" \
--profile "Profile 1" \
--name "Work" \
--port 9250
# Salida: {"profile":"Profile 1","name":"Work","launched":true,"preferences_created":true}
# Dry-run: describe acciones sin ejecutar nada
create_chrome_profile \
--user-data-dir "$HOME/.local/share/web_scraping/profiles" \
--profile "Default" \
--name "Scraping" \
--dry-run
```
## Cuando usarla
Úsala para aprovisionar perfiles nuevos en un user-data-dir de automatización antes de lanzar sesiones CDP con `script-navegador` o funciones del grupo `navegator`. En modo normal (sin `--no-launch`) la managed policy instala automáticamente uBlock y la extensión web_proxy en el perfil nuevo; en `--no-launch` sirve para tests unitarios o para crear la entrada de Local State sin depender de Chrome.
## Gotchas
- **Lanzar chromium desde Bash tool de Claude da exit-144**: la función usa `systemd-run --user --collect` para aislar el proceso en su propio cgroup, evitando que el harness del agente lo mate. Esto es obligatorio; lanzar con `&` / `setsid` daría exit-144 en el contexto del agente.
- **La managed policy instala las extensiones al arrancar el perfil**: NO pasar `--disable-extensions` — rompería la forcelist. Las extensiones force-listed (`ExtensionInstallForcelist` en `/etc/chromium/policies/managed/extensions.json`) se instalan en el perfil durante el primer arranque; en el headless inicial puede no completar la descarga si no hay red o si el timeout es corto.
- **Dos chromium NO pueden compartir el mismo user-data-dir**: si ya hay un chromium corriendo sobre `--user-data-dir`, la función detecta `SingletonLock` y sale con exit 2 antes de lanzar. Para perfiles de automatización paralela, usa un `--user-data-dir` dedicado por perfil.
- **Local State debe editarse con Chrome muerto**: la función para el unit de systemd y espera la desaparición de `SingletonLock` antes de editar `Local State`. Si se edita mientras Chrome está vivo, Chrome sobreescribe el archivo desde memoria al salir y los cambios de nombre se pierden.
- **`--remote-allow-origins=*` necesita comillas en zsh**: el glob `*` se expande si no va entre comillas. La función pasa el flag correctamente internamente, pero si lo pasas tú en otros scripts acuérdate de las comillas.
- **Perfil diario en `~/.config/chromium-cdp`**: en este equipo el fragmento `/etc/chromium.d/cdp` redirige el user-data-dir global a `~/.config/chromium-cdp`. Para automatización usar siempre un `--user-data-dir` dedicado fuera de `~/.config/`.
- **Timeout corto puede dar `preferences_created: false`**: el perfil headless tarda entre 2-8 segundos en crear `Preferences` según la carga del sistema. Si se aumenta `--timeout-sec` a 45-60 en máquinas lentas se evitan falsos timeouts.
## Exit codes
| Código | Significado |
|--------|------------|
| 0 | Éxito |
| 1 | Argumento obligatorio faltante o binario no encontrado |
| 2 | Lock: ya hay un chromium usando el mismo user-data-dir |
| 3 | Timeout esperando a que Preferences se cree |
| 4 | Error editando Local State (JSON inválido tras escritura) |
@@ -0,0 +1,309 @@
#!/usr/bin/env bash
# create_chrome_profile — crea un perfil Chrome/Chromium nuevo en un user-data-dir,
# opcionalmente lanzando chromium headless para que la managed policy instale las
# extensiones forzadas (uBlock, web_proxy). Edita Local State para asignar el nombre
# legible al perfil.
set -euo pipefail
create_chrome_profile() {
# ── defaults ──────────────────────────────────────────────────────────────
local _udd=""
local _profile_dir=""
local _name=""
local _port=9250
local _chrome_path=""
local _no_launch=0
local _timeout_sec=25
local _dry_run=0
# ── parse args ─────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: create_chrome_profile --user-data-dir <dir> --profile <dir-name> --name <legible>
[--port N] [--chrome-path <path>] [--no-launch] [--timeout-sec N] [--dry-run]
--user-data-dir Raíz del user-data-dir de Chrome/Chromium (obligatorio).
--profile Nombre de la carpeta del perfil dentro de user-data-dir, ej: Default,
"Profile 1", Automation (obligatorio).
--name Nombre legible que aparece en el selector de perfil, ej: Work, Aurgi
(obligatorio).
--port Puerto CDP para el lanzamiento headless. Default: 9250.
Usar un puerto distinto al 9222 global para no chocar.
--chrome-path Ruta explícita al binario chromium/chrome. Auto-detecta si se omite.
--no-launch No lanza chromium. Crea la carpeta y edita Local State offline.
El perfil no tendrá extensiones instaladas; útil para tests/CRUD.
--timeout-sec Segundos esperando a que Preferences aparezca tras el lanzamiento.
Default: 25.
--dry-run Describe las acciones sin lanzar ni escribir nada.
Exit codes:
0 éxito
1 error de argumento o validación
2 lock: ya hay un chromium usando este user-data-dir
3 timeout esperando a que Preferences se cree
4 error editando Local State (JSON inválido tras escritura)
EOF
return 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--user-data-dir) _udd="$2"; shift 2 ;;
--profile) _profile_dir="$2"; shift 2 ;;
--name) _name="$2"; shift 2 ;;
--port) _port="$2"; shift 2 ;;
--chrome-path) _chrome_path="$2"; shift 2 ;;
--no-launch) _no_launch=1; shift ;;
--timeout-sec) _timeout_sec="$2"; shift 2 ;;
--dry-run) _dry_run=1; shift ;;
-h|--help) _usage; return 0 ;;
*) echo "create_chrome_profile: argumento desconocido: $1" >&2; return 1 ;;
esac
done
# ── validaciones obligatorias ──────────────────────────────────────────────
if [[ -z "$_udd" ]]; then
echo "create_chrome_profile: --user-data-dir es obligatorio" >&2
return 1
fi
if [[ -z "$_profile_dir" ]]; then
echo "create_chrome_profile: --profile es obligatorio" >&2
return 1
fi
if [[ -z "$_name" ]]; then
echo "create_chrome_profile: --name es obligatorio" >&2
return 1
fi
local _profile_path="${_udd}/${_profile_dir}"
local _local_state="${_udd}/Local State"
local _prefs_file="${_profile_path}/Preferences"
# ── guard: lock por user-data-dir ─────────────────────────────────────────
# Dos procesos chromium no pueden compartir el mismo user-data-dir.
if [[ $_dry_run -eq 0 && $_no_launch -eq 0 ]]; then
local _singleton="${_udd}/SingletonLock"
if [[ -e "$_singleton" ]]; then
echo "create_chrome_profile: ya hay un chromium corriendo con --user-data-dir=${_udd}" >&2
echo " (encontrado: ${_singleton})" >&2
echo " Ciérralo o usa un user-data-dir distinto." >&2
return 2
fi
fi
# ── detección del binario chromium ────────────────────────────────────────
local _bin=""
if [[ -n "$_chrome_path" ]]; then
if [[ ! -x "$_chrome_path" ]]; then
echo "create_chrome_profile: binario no encontrado o no ejecutable: ${_chrome_path}" >&2
return 1
fi
_bin="$_chrome_path"
elif [[ $_no_launch -eq 0 ]]; then
for _candidate in chromium chromium-browser google-chrome brave-browser; do
if command -v "$_candidate" &>/dev/null; then
_bin="$_candidate"
break
fi
done
if [[ -z "$_bin" ]]; then
echo "create_chrome_profile: no se encontró binario chromium en PATH" >&2
echo " Probados: chromium, chromium-browser, google-chrome, brave-browser" >&2
echo " Usa --chrome-path o --no-launch." >&2
return 1
fi
fi
# ── modo dry-run ──────────────────────────────────────────────────────────
if [[ $_dry_run -eq 1 ]]; then
echo "=== create_chrome_profile DRY-RUN ===" >&2
echo " user-data-dir : ${_udd}" >&2
echo " profile : ${_profile_dir}" >&2
echo " name : ${_name}" >&2
if [[ $_no_launch -eq 1 ]]; then
echo " modo : --no-launch (sin chromium)" >&2
echo " acciones : mkdir -p ${_profile_path}" >&2
echo " editar ${_local_state} → info_cache + profiles_order" >&2
else
echo " binario : ${_bin}" >&2
echo " puerto CDP : ${_port}" >&2
echo " timeout : ${_timeout_sec}s" >&2
echo " acciones : systemd-run unit=create-prof-<rand> chromium headless" >&2
echo " poll Preferences hasta ${_timeout_sec}s" >&2
echo " systemctl --user stop unit" >&2
echo " editar ${_local_state} → info_cache + profiles_order" >&2
fi
printf '{"profile":"%s","name":"%s","launched":false,"preferences_created":false,"dry_run":true}\n' \
"$_profile_dir" "$_name"
return 0
fi
# ── crear directorio del perfil ───────────────────────────────────────────
mkdir -p "$_profile_path"
# ── también asegurar que user-data-dir existe ──────────────────────────────
mkdir -p "$_udd"
# ── modo --no-launch: solo estructura + Local State ────────────────────────
local _launched=false
local _prefs_created=false
if [[ $_no_launch -eq 1 ]]; then
_update_local_state "$_udd" "$_local_state" "$_profile_dir" "$_name"
if [[ -f "$_prefs_file" ]]; then
_prefs_created=true
fi
printf '{"profile":"%s","name":"%s","launched":false,"preferences_created":%s}\n' \
"$_profile_dir" "$_name" "$_prefs_created"
return 0
fi
# ── lanzar chromium headless vía systemd-run ──────────────────────────────
# systemd-run --user aísla el proceso del cgroup del agente (evita exit-144).
# NO se pasa --disable-extensions para que la managed policy instale las
# extensiones force-listed (uBlock, web_proxy).
local _rand
_rand="$(tr -dc 'a-z0-9' </dev/urandom | head -c 8 2>/dev/null || echo "$$")"
local _unit="create-prof-${_rand}"
systemd-run \
--user \
--collect \
--unit="$_unit" \
--setenv=DISPLAY=:0 \
--setenv=XAUTHORITY="${HOME}/.Xauthority" \
"$_bin" \
"--user-data-dir=${_udd}" \
"--profile-directory=${_profile_dir}" \
"--headless=new" \
"--no-first-run" \
"--remote-debugging-port=${_port}" \
"--remote-allow-origins=*" \
"about:blank" 2>/dev/null || true
_launched=true
# ── poll: esperar a que Preferences exista ────────────────────────────────
local _elapsed=0
while [[ $_elapsed -lt $_timeout_sec ]]; do
if [[ -f "$_prefs_file" ]]; then
_prefs_created=true
break
fi
sleep 1
(( _elapsed++ )) || true
done
# ── detener el unit Y matar TODO el árbol de chromium de este udd ───────────
# Necesario para poder editar Local State sin que Chrome lo sobreescriba. Ni el
# `systemctl stop` ni un `pkill -f --user-data-dir=` bastan: los procesos hijos
# (zygote/gpu/renderer) no repiten el flag --user-data-dir pero sí referencian la
# ruta del user-data-dir en otros argumentos. Los matamos por PID seleccionando
# los procesos chromium cuyo cmdline contiene la ruta del udd (seguro: no mata
# este propio script porque filtramos por '[c]hromium').
systemctl --user kill -s SIGKILL "$_unit" 2>/dev/null || true
systemctl --user stop "$_unit" 2>/dev/null || true
# Matar por PID los procesos cuyo comm es exactamente "chromium" (pgrep -x) y cuyo cmdline
# contiene la ruta del udd. Usamos pgrep -x para NO auto-matchear grep/pgrep: el path del udd
# contiene la cadena "chromium" (~/.config/chromium-cdp).
local _wait=0 _p _pids
while :; do
_pids=""
for _p in $(pgrep -x chromium 2>/dev/null); do
tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_udd" && _pids="$_pids $_p"
done
[[ -z "${_pids// }" ]] && break
# shellcheck disable=SC2086
kill -TERM $_pids 2>/dev/null || true
sleep 0.5
(( _wait++ )) || true
if [[ $_wait -ge 20 ]]; then
# shellcheck disable=SC2086
kill -9 $_pids 2>/dev/null || true
break
fi
done
rm -f "${_udd}/SingletonLock" 2>/dev/null || true
if [[ "$_prefs_created" == false ]]; then
echo "create_chrome_profile: timeout (${_timeout_sec}s) esperando a que se cree: ${_prefs_file}" >&2
echo " El directorio del perfil puede existir pero está vacío." >&2
printf '{"profile":"%s","name":"%s","launched":true,"preferences_created":false,"error":"timeout"}\n' \
"$_profile_dir" "$_name"
return 3
fi
# ── editar Local State para asignar nombre legible ────────────────────────
_update_local_state "$_udd" "$_local_state" "$_profile_dir" "$_name"
printf '{"profile":"%s","name":"%s","launched":true,"preferences_created":true}\n' \
"$_profile_dir" "$_name"
}
# ── helper: editar Local State con python3 ────────────────────────────────────
# Crea/actualiza info_cache.<profile_dir> con name + is_using_default_name=false
# y añade profile_dir a profiles_order si no está.
_update_local_state() {
local _udd="$1"
local _local_state="$2"
local _profile_dir="$3"
local _name="$4"
local _today
_today="$(date +%Y%m%d)"
# Si Local State no existe, crear una estructura mínima
if [[ ! -f "$_local_state" ]]; then
printf '{"profile":{"info_cache":{},"profiles_order":[]}}\n' > "$_local_state"
fi
# Backup antes de modificar (no sobreescribir el del mismo día)
local _backup="${_local_state}.bak.${_today}"
if [[ ! -f "$_backup" ]]; then
cp "$_local_state" "$_backup"
fi
# Editar con python3
if ! python3 - "$_local_state" "$_profile_dir" "$_name" <<'PY'; then
import sys, json
ls_path = sys.argv[1]
prof_dir = sys.argv[2]
prof_name = sys.argv[3]
with open(ls_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Asegurar estructura profile
profile_section = data.setdefault("profile", {})
info_cache = profile_section.setdefault("info_cache", {})
# Crear o actualizar la entrada del perfil en info_cache
entry = info_cache.setdefault(prof_dir, {})
entry["name"] = prof_name
entry["is_using_default_name"] = False
# Añadir a profiles_order si no está
order = profile_section.setdefault("profiles_order", [])
if prof_dir not in order:
order.append(prof_dir)
with open(ls_path, "w", encoding="utf-8") as f:
json.dump(data, f, separators=(",", ":"))
PY
echo "create_chrome_profile: error editando Local State con python3" >&2
return 4
fi
# Validar JSON tras escritura
if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$_local_state" 2>/dev/null; then
echo "create_chrome_profile: JSON inválido tras escribir Local State; restaurando backup" >&2
cp "$_backup" "$_local_state"
return 4
fi
}
# ── auto-ejecución ────────────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
create_chrome_profile "$@"
fi
@@ -0,0 +1,93 @@
---
name: delete_chrome_profile
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "delete_chrome_profile --user-data-dir <dir> --profile <name> [--profile <name>]... [--dry-run]"
description: "Borra por completo uno o varios perfiles Chrome/Chromium: elimina la carpeta del perfil del disco y limpia todas sus referencias en Local State (info_cache, profiles_order, last_active_profiles, last_used, variations_google_groups). Requiere que Chromium esté cerrado. Hace backup automático de Local State antes de editar y valida el JSON resultante restaurando el backup si es inválido."
tags: [navegator, chromium, profile, cleanup, browser, scraping]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/browser/delete_chrome_profile.sh"
params:
- name: --user-data-dir
desc: "Ruta raíz del user-data-dir de Chrome/Chromium (obligatorio). Ej: ~/.config/chromium"
- name: --profile
desc: "Nombre de la carpeta del perfil a borrar (repetible, mínimo uno obligatorio). Ej: 'Default', 'Profile 1'"
- name: --dry-run
desc: "Muestra qué carpetas borraría y qué claves de Local State quitaría sin tocar nada. No activa el guard de chromium cerrado."
output: "JSON en stdout. Modo real: {deleted:[{profile, dir_removed, local_state_cleaned}...], last_used:'<nuevo>', backup:'Local State.bak.YYYYMMDD'}. Modo dry-run: {dry_run:true, would_delete:[{profile, dir_exists, would_remove, local_state_would_clean}...]}. Errores a stderr con exit != 0."
---
## Ejemplo
```bash
# Cerrar Chromium primero (OBLIGATORIO en modo real)
pkill -TERM chromium
# Borrar un perfil
source $HOME/fn_registry/bash/functions/browser/delete_chrome_profile.sh
delete_chrome_profile \
--user-data-dir "$HOME/.config/chromium" \
--profile "Profile 1"
# Salida: {"deleted":[{"profile":"Profile 1","dir_removed":true,"local_state_cleaned":true}],"last_used":"Default","backup":"Local State.bak.20260606"}
# Borrar varios perfiles a la vez
delete_chrome_profile \
--user-data-dir "$HOME/.config/chromium" \
--profile "Profile 1" \
--profile "Profile 2"
# Previsualizar sin tocar nada (no requiere Chromium cerrado)
delete_chrome_profile \
--user-data-dir "$HOME/.config/chromium" \
--profile "Profile 1" \
--dry-run
# Salida: {"dry_run":true,"would_delete":[{"profile":"Profile 1","dir_exists":true,"would_remove":true,"local_state_would_clean":true}]}
# Con un user-data-dir sintético para pruebas
mkdir -p /tmp/test_udd/Default /tmp/test_udd/"Profile 1"
echo '{"profile":{"info_cache":{"Default":{},"Profile 1":{}},"profiles_order":["Default","Profile 1"],"last_active_profiles":["Profile 1"],"last_used":"Profile 1"},"variations_google_groups":{}}' \
> "/tmp/test_udd/Local State"
delete_chrome_profile --user-data-dir /tmp/test_udd --profile "Profile 1" --dry-run
```
También ejecutable directamente con `fn run`:
```bash
cd $HOME/fn_registry
./fn run delete_chrome_profile_bash_browser -- \
--user-data-dir "$HOME/.config/chromium" --profile "Profile 1" --dry-run
```
## Cuando usarla
Úsala cuando necesites limpiar completamente un perfil de Chromium: antes de crear un perfil de scraping fresco, para depurar problemas de perfiles corruptos, o para liberar espacio eliminando perfiles de sesión temporales. A diferencia de borrar solo la carpeta, esta función también retira las referencias de `Local State` para que Chromium no muestre el perfil fantasma ni intente acceder a él al arrancar.
## Gotchas
- **Chromium DEBE estar cerrado antes de ejecutar en modo real**. Chromium reescribe `Local State` desde memoria al cerrar y desharía todos los cambios. La función comprueba `pgrep -x chromium` y aborta con exit 2 si detecta procesos vivos. En `--dry-run` este check no se activa.
- **Operación destructiva e irreversible**: todos los datos del perfil (cookies, logins guardados, historial, caché, contraseñas) se pierden permanentemente al borrar la carpeta. No hay papelera.
- **Backup automático de Local State**: antes de editar, la función crea `<udd>/Local State.bak.YYYYMMDD`. Si ya existe un backup del día no lo sobreescribe. Restaurar manualmente: `cp "Local State.bak.YYYYMMDD" "Local State"`.
- **Validación JSON tras edición**: si el JSON de Local State queda inválido (raro pero posible con perfiles con nombres muy especiales), la función restaura el backup automáticamente y sale con exit != 0.
- **Nombres de perfil con espacios**: los nombres como `"Profile 1"` se pasan entre comillas al script Python. El parsing usa `json.loads` por lo que los espacios no dan problemas, pero deben pasarse correctamente en el shell: `--profile "Profile 1"`.
- **python3 > jq > warning**: usa python3 para editar Local State, jq como fallback. Si ninguno está disponible, las carpetas se borran pero Local State queda sin modificar (Chromium podría mostrar perfiles fantasma al arrancar).
- **last_used reasignado automáticamente**: si el perfil borrado era el `last_used`, la función asigna el primer perfil restante en `info_cache`. Si no queda ningún perfil, `last_used` queda como cadena vacía.
- **No afecta a `--profile Default` si es el único perfil**: lo borrará igualmente — Chromium puede quedar sin ningún perfil configurado y recreará Default al arrancar.
## Exit codes
| Código | Significado |
|--------|-------------|
| 0 | Éxito o dry-run completado |
| 1 | Argumento inválido, directorio o Local State no encontrado, JSON inválido tras edición |
| 2 | Chromium está corriendo (solo en modo real) |
@@ -0,0 +1,264 @@
#!/usr/bin/env bash
# delete_chrome_profile — borra por completo uno o varios perfiles Chrome/Chromium:
# elimina la carpeta del perfil y limpia todas las referencias en Local State
# (info_cache, profiles_order, last_active_profiles, last_used, variations_google_groups).
set -euo pipefail
delete_chrome_profile() {
# ── defaults ──────────────────────────────────────────────────────────────
local _user_data_dir=""
local _profiles=()
local _dry_run=0
# ── parse args ─────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: delete_chrome_profile --user-data-dir <dir> --profile <name> [--profile <name>]... [--dry-run]
--user-data-dir <dir> Ruta raíz del user-data-dir de Chrome/Chromium (obligatorio).
--profile <name> Nombre de la carpeta del perfil, ej. "Default" o "Profile 1"
(repetible, al menos uno obligatorio).
--dry-run Muestra qué borraría y qué claves de Local State quitaría
sin tocar nada.
Exit codes:
0 éxito (o dry-run completado)
1 error de argumento o validación
2 chromium está corriendo (solo en modo real)
EOF
return 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--user-data-dir) _user_data_dir="$2"; shift 2 ;;
--profile) _profiles+=("$2"); shift 2 ;;
--dry-run) _dry_run=1; shift ;;
-h|--help) _usage; return 0 ;;
*) echo "delete_chrome_profile: argumento desconocido: $1" >&2; return 1 ;;
esac
done
# ── validaciones de argumentos ────────────────────────────────────────────
if [[ -z "$_user_data_dir" ]]; then
echo "delete_chrome_profile: --user-data-dir es obligatorio" >&2
return 1
fi
if [[ ${#_profiles[@]} -eq 0 ]]; then
echo "delete_chrome_profile: se requiere al menos un --profile" >&2
return 1
fi
if [[ ! -d "$_user_data_dir" ]]; then
echo "delete_chrome_profile: user-data-dir no encontrado: ${_user_data_dir}" >&2
return 1
fi
local _local_state="${_user_data_dir}/Local State"
if [[ ! -f "$_local_state" ]]; then
echo "delete_chrome_profile: Local State no encontrado: ${_local_state}" >&2
return 1
fi
# ── guard: ningún chromium debe tener ESTE user-data-dir abierto (excepto en dry-run) ──
# Por-udd, no global. Comprobamos por PID con comm=chromium (pgrep -x) y leemos su cmdline,
# para NO auto-matchear el propio `grep`/`pgrep` del pipe: como el path del udd contiene la
# cadena "chromium" (p.ej. ~/.config/chromium-cdp), un `pgrep -af '[c]hromium' | grep <udd>`
# se detecta a sí mismo. pgrep -x chromium solo lista procesos cuyo nombre es exactamente
# "chromium" (el navegador), nunca grep/pgrep/bash.
if [[ $_dry_run -eq 0 ]]; then
local _p _busy=0
for _p in $(pgrep -x chromium 2>/dev/null); do
if tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_user_data_dir"; then
_busy=1; break
fi
done
if [[ $_busy -eq 1 ]]; then
echo "delete_chrome_profile: hay un chromium con este user-data-dir abierto — ciérralo antes de borrar perfiles:" >&2
echo " pkill -TERM chromium" >&2
echo "(Chromium reescribe Local State desde memoria al cerrar y desharía el borrado)" >&2
return 2
fi
fi
local _today
_today="$(date +%Y%m%d)"
# ── modo dry-run ──────────────────────────────────────────────────────────
if [[ $_dry_run -eq 1 ]]; then
echo "=== delete_chrome_profile DRY-RUN ===" >&2
local _p
for _p in "${_profiles[@]}"; do
local _pdir="${_user_data_dir}/${_p}"
if [[ -d "$_pdir" ]]; then
echo " [borraría] rm -rf ${_pdir}" >&2
else
echo " [no existe] ${_pdir}" >&2
fi
echo " [Local State] quitaría claves para perfil: '${_p}'" >&2
echo " profile.info_cache.${_p}" >&2
echo " profile.profiles_order (entrada '${_p}')" >&2
echo " profile.last_active_profiles (entrada '${_p}')" >&2
echo " profile.last_used (si == '${_p}', reasignar)" >&2
echo " variations_google_groups.${_p} (si existe)" >&2
done
# Construir JSON de dry-run inline
local _dry_items="" _dry_first=1
for _p in "${_profiles[@]}"; do
local _pdir="${_user_data_dir}/${_p}"
local _sep="" _exists="false"
[[ $_dry_first -eq 0 ]] && _sep=","
_dry_first=0
[[ -d "$_pdir" ]] && _exists="true"
_dry_items+="${_sep}{\"profile\":\"${_p}\",\"dir_exists\":${_exists},\"would_remove\":${_exists},\"local_state_would_clean\":true}"
done
printf '{"dry_run":true,"would_delete":[%s]}\n' "$_dry_items"
return 0
fi
# ── backup de Local State (no sobreescribir el del día) ───────────────────
local _backup="${_local_state}.bak.${_today}"
if [[ ! -f "$_backup" ]]; then
cp "$_local_state" "$_backup"
fi
# ── borrar carpetas de perfil ──────────────────────────────────────────────
local _deleted_results=() # "profile|dir_removed|ls_cleaned"
local _p
for _p in "${_profiles[@]}"; do
local _pdir="${_user_data_dir}/${_p}"
local _dir_removed=false
if [[ -d "$_pdir" ]]; then
rm -rf "$_pdir"
_dir_removed=true
fi
_deleted_results+=("${_p}|${_dir_removed}|false")
done
# ── construir lista Python de perfiles a eliminar ─────────────────────────
local _py_profiles_list=""
for _p in "${_profiles[@]}"; do
_py_profiles_list+="\"${_p}\","
done
_py_profiles_list="[${_py_profiles_list%,}]"
# ── editar Local State con python3 ────────────────────────────────────────
local _ls_cleaned=false
if command -v python3 >/dev/null 2>&1; then
python3 - "$_local_state" "$_py_profiles_list" <<'PY'
import sys, json
ls_path = sys.argv[1]
profiles_to_delete = json.loads(sys.argv[2])
with open(ls_path, "r", encoding="utf-8") as f:
data = json.load(f)
profile_section = data.get("profile", {})
# 1. profile.info_cache — eliminar cada perfil
info_cache = profile_section.get("info_cache", {})
for p in profiles_to_delete:
info_cache.pop(p, None)
# 2. profile.profiles_order — quitar entradas del perfil
if "profiles_order" in profile_section and isinstance(profile_section["profiles_order"], list):
profile_section["profiles_order"] = [
x for x in profile_section["profiles_order"] if x not in profiles_to_delete
]
# 3. profile.last_active_profiles — quitar entradas del perfil
if "last_active_profiles" in profile_section and isinstance(profile_section["last_active_profiles"], list):
profile_section["last_active_profiles"] = [
x for x in profile_section["last_active_profiles"] if x not in profiles_to_delete
]
# 4. profile.last_used — reasignar si apunta a un perfil borrado
last_used = profile_section.get("last_used", "")
if last_used in profiles_to_delete:
remaining = [k for k in info_cache.keys() if k not in profiles_to_delete]
profile_section["last_used"] = remaining[0] if remaining else ""
# 5. variations_google_groups — limpiar entradas del perfil (si existe)
vgg = data.get("variations_google_groups", {})
for p in profiles_to_delete:
vgg.pop(p, None)
with open(ls_path, "w", encoding="utf-8") as f:
json.dump(data, f, separators=(",", ":"))
PY
_ls_cleaned=true
# ── fallback con jq ───────────────────────────────────────────────────────
elif command -v jq >/dev/null 2>&1; then
local _tmp_ls
_tmp_ls="$(mktemp)"
local _jq_expr="."
for _p in "${_profiles[@]}"; do
_jq_expr+=" | del(.profile.info_cache[\"${_p}\"])"
_jq_expr+=" | del(.variations_google_groups[\"${_p}\"])"
_jq_expr+=" | if .profile.profiles_order then .profile.profiles_order -= [\"${_p}\"] else . end"
_jq_expr+=" | if .profile.last_active_profiles then .profile.last_active_profiles -= [\"${_p}\"] else . end"
done
if jq "${_jq_expr}" "$_local_state" > "$_tmp_ls" 2>/dev/null; then
mv "$_tmp_ls" "$_local_state"
_ls_cleaned=true
else
echo "delete_chrome_profile: advertencia — jq falló editando Local State" >&2
rm -f "$_tmp_ls"
fi
else
echo "delete_chrome_profile: advertencia — ni python3 ni jq disponibles; carpetas borradas pero Local State no modificado" >&2
fi
# ── validar que el JSON resultante sigue siendo parseable ─────────────────
if [[ "$_ls_cleaned" == "true" ]]; then
if command -v python3 >/dev/null 2>&1; then
if ! python3 -c "import sys, json; json.load(open(sys.argv[1]))" "$_local_state" 2>/dev/null; then
echo "delete_chrome_profile: JSON de Local State inválido tras edición — restaurando backup" >&2
cp "$_backup" "$_local_state"
return 1
fi
fi
fi
# ── actualizar _deleted_results con ls_cleaned ────────────────────────────
local _updated_results=()
for _entry in "${_deleted_results[@]}"; do
local _ep _edr _els
IFS='|' read -r _ep _edr _els <<< "$_entry"
_updated_results+=("${_ep}|${_edr}|${_ls_cleaned}")
done
# ── leer last_used resultante ──────────────────────────────────────────────
local _new_last_used=""
if command -v python3 >/dev/null 2>&1; then
_new_last_used="$(python3 -c "
import sys, json
data = json.load(open(sys.argv[1]))
print(data.get('profile', {}).get('last_used', ''))
" "$_local_state" 2>/dev/null || echo "")"
fi
# ── construir JSON de resultado inline ────────────────────────────────────
local _result_items="" _res_first=1
for _entry in "${_updated_results[@]+"${_updated_results[@]}"}"; do
local _pn _dr _lc
IFS='|' read -r _pn _dr _lc <<< "$_entry"
local _rsep=""
[[ $_res_first -eq 0 ]] && _rsep=","
_res_first=0
_result_items+="${_rsep}{\"profile\":\"${_pn}\",\"dir_removed\":${_dr},\"local_state_cleaned\":${_lc}}"
done
printf '{"deleted":[%s],"last_used":"%s","backup":"Local State.bak.%s"}\n' \
"$_result_items" "$_new_last_used" "$_today"
}
# ── auto-ejecución ────────────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]:-}" == "${0}" ]]; then
delete_chrome_profile "$@"
fi
@@ -0,0 +1,74 @@
---
name: prepare_chrome_profile
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "prepare_chrome_profile --src <user-data-dir> --dst <user-data-dir> [--keep <ext_id>]... [--force]"
description: "Clona un user-data-dir de Chrome/Chromium creando un perfil de scraping limpio: conserva solo las extensiones de una lista blanca (por defecto uBlock Origin Lite) y excluye caché, locks y sesiones antiguas."
tags: [chrome, browser, profile, scraping, extensions, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/browser/prepare_chrome_profile.sh"
params:
- name: --src
desc: "user-data-dir origen con un perfil Chrome/Chromium ya configurado (debe existir --src/Default)"
- name: --dst
desc: "Ruta de destino del nuevo perfil; no debe existir salvo que se pase --force"
- name: --keep
desc: "ID de extensión Chrome a conservar (repetible). Si no se pasa ninguno el default es ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)"
- name: --force
desc: "Borra --dst si existe antes de recrearlo. Sin este flag la función aborta si --dst ya existe"
output: "JSON en stdout: {dst, kept: [id...], removed: [id...]}. Exit 0 en éxito."
---
## Ejemplo
```bash
source $HOME/fn_registry/bash/functions/browser/prepare_chrome_profile.sh
prepare_chrome_profile \
--src "$HOME/.config/chromium" \
--dst "$HOME/.local/share/web_scraping/chrome-profile"
# Con extensión adicional conservada
prepare_chrome_profile \
--src "$HOME/.config/chromium" \
--dst "$HOME/.local/share/web_scraping/chrome-profile" \
--keep "ddkjiahejlhfcafbddmgiahcphecmpfh" \
--keep "cjpalhdlnbpafiamejdnhcphjbkeiagm" \
--force
# Salida esperada (ejemplo):
# {"dst":"/home/enmanuel/.local/share/web_scraping/chrome-profile","kept":["ddkjiahejlhfcafbddmgiahcphecmpfh"],"removed":["abcdefghijklmnopabcdefghijklmnop","dark-reader-id"]}
```
## Cuando usarla
Úsala antes de lanzar una sesión de scraping/automatización para partir de un perfil aislado: con uBlock Origin Lite activo (menos anuncios/trackers = DOM más limpio, respuestas más rápidas) pero sin extensiones que interfieren (Dark Reader muta colores del DOM, NoScript bloquea JS, OneTab modifica tabs). También sirve para aislar sesiones de diferentes proyectos de scraping sin contaminar el perfil personal.
## Gotchas
- **Chrome debe estar CERRADO sobre `--src`** antes de ejecutar. Los archivos SQLite (`Cookies`, `History`, `Login Data`, etc.) estarán bloqueados si Chrome está abierto, y `rsync` copiará versiones inconsistentes. Verificar con `pgrep -x chromium` o `pgrep -x chrome`.
- **HMAC de Secure Preferences**: el archivo `Local State` contiene la semilla HMAC que Chrome usa para verificar `Preferences` y `Secure Preferences`. Si no se copia (o se copia entre máquinas distintas con distinto binding), Chrome puede invalidar las extensiones al arrancar y resetear configuraciones. La función copia `Local State` automáticamente, pero la copia entre máquinas puede seguir produciendo resets de extensiones — esto es comportamiento esperado de Chrome, no un bug de esta función.
- **Purga de referencias en Preferences**: tras borrar las carpetas de extensiones fuera de la whitelist, la función también elimina con `python3` las entradas `extensions.settings.<id>` de `Default/Preferences` y `Default/Secure Preferences`, los IDs de `extensions.pinned_extensions` y las claves `protection.macs.extensions.settings.<id>`. Sin esta limpieza Chrome detecta las entradas en Preferences (con `from_webstore`/install_source) y **vuelve a descargar la extensión del Web Store al arrancar**, deshaciendo el filtrado (caso real: Dark Reader reaparece y oscurece páginas rompiendo screenshots). Si `python3` falla al procesar un Preferences concreto se emite un warning a stderr pero la función no aborta — el borrado de carpetas ya es el efecto principal.
- **`--force` borra `--dst` completamente**: si `--dst` es un perfil con datos que quieres conservar, no uses `--force` sin antes hacer backup.
- **Extensiones instaladas desde Web Store vs unpacked**: esta función opera sobre la carpeta `Extensions/` física. Las extensiones instaladas desde la Web Store tienen IDs de 32 caracteres en minúsculas. Las extensiones unpacked (`--load-extension`) no viven en `Extensions/` y no se ven afectadas.
## Exit codes
| Código | Significado |
|--------|------------|
| 0 | Éxito |
| 1 | Argumento inválido o `--src/Default` no existe |
| 2 | `--dst` ya existe y no se pasó `--force` |
| 3 | `--src` y `--dst` resuelven al mismo path real |
| 4 | Error durante `rsync` |
@@ -0,0 +1,223 @@
#!/usr/bin/env bash
# prepare_chrome_profile — clona un user-data-dir de Chrome/Chromium conservando solo
# las extensiones de una lista blanca. Sirve para perfiles de scraping limpios.
set -euo pipefail
# ── defaults ──────────────────────────────────────────────────────────────────
_SRC=""
_DST=""
_FORCE=0
# uBlock Origin Lite por defecto
_KEEP=()
_DEFAULT_EXT="ddkjiahejlhfcafbddmgiahcphecmpfh"
# ── parse args ────────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: prepare_chrome_profile --src <user-data-dir> --dst <user-data-dir> \
[--keep <ext_id>]... [--force]
--src user-data-dir origen (ej. $HOME/.config/chromium)
--dst user-data-dir destino a crear
--keep ID de extensión a conservar (repetible). Default: uBlock Origin Lite
--force si --dst existe, lo borra y recrea; sin flag aborta si existe
Exit codes:
0 éxito
1 error de argumento o validación
2 --dst ya existe y no se pasó --force
3 --src igual a --dst (mismo path real)
4 error de copia/rsync
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--src) _SRC="$2"; shift 2 ;;
--dst) _DST="$2"; shift 2 ;;
--keep) _KEEP+=("$2"); shift 2 ;;
--force) _FORCE=1; shift ;;
-h|--help) _usage ;;
*) echo "prepare_chrome_profile: argumento desconocido: $1" >&2; _usage ;;
esac
done
# ── validaciones básicas ──────────────────────────────────────────────────────
if [[ -z "$_SRC" || -z "$_DST" ]]; then
echo "prepare_chrome_profile: --src y --dst son obligatorios" >&2
exit 1
fi
if [[ ! -d "$_SRC/Default" ]]; then
echo "prepare_chrome_profile: $_SRC/Default no existe; ¿es un user-data-dir válido?" >&2
exit 1
fi
# Resolver paths reales para comparar (evitar borrar src cuando src==dst)
_SRC_REAL="$(realpath "$_SRC")"
_DST_REAL="$(realpath -m "$_DST")" # -m: no requiere que exista
if [[ "$_SRC_REAL" == "$_DST_REAL" ]]; then
echo "prepare_chrome_profile: --src y --dst resuelven al mismo path: $_SRC_REAL" >&2
exit 3
fi
# También rechazar si --dst es prefijo de --src (evitar borrar el origen)
if [[ "$_SRC_REAL" == "$_DST_REAL"/* ]]; then
echo "prepare_chrome_profile: --src está dentro de --dst; operación peligrosa, abortando" >&2
exit 3
fi
# ── lista blanca de extensiones ───────────────────────────────────────────────
if [[ ${#_KEEP[@]} -eq 0 ]]; then
_KEEP=("$_DEFAULT_EXT")
fi
# ── gestionar destino ─────────────────────────────────────────────────────────
if [[ -d "$_DST" ]]; then
if [[ $_FORCE -eq 1 ]]; then
rm -rf "$_DST"
else
echo "prepare_chrome_profile: $_DST ya existe; usa --force para sobreescribir" >&2
exit 2
fi
fi
mkdir -p "$_DST/Default"
# ── copiar Local State (HMAC seed para Secure Preferences) ────────────────────
if [[ -f "$_SRC/Local State" ]]; then
cp "$_SRC/Local State" "$_DST/Local State"
fi
# ── rsync del perfil Default excluyendo caché y locks ─────────────────────────
rsync -a \
--exclude='Cache/' \
--exclude='Code Cache/' \
--exclude='GPUCache/' \
--exclude='Dawn Cache/' \
--exclude='DawnGraphiteCache/' \
--exclude='DawnWebGPUCache/' \
--exclude='Service Worker/CacheStorage/' \
--exclude='Service Worker/ScriptCache/' \
--exclude='Singleton*' \
--exclude='*.lock' \
--exclude='lockfile' \
--exclude='Sessions/' \
--exclude='Session Storage/' \
--exclude='Current Session' \
--exclude='Current Tabs' \
--exclude='Last Session' \
--exclude='Last Tabs' \
"$_SRC/Default/" "$_DST/Default/" || {
echo "prepare_chrome_profile: rsync falló (exit $?)" >&2
exit 4
}
# ── eliminar extensiones fuera de la lista blanca ────────────────────────────
_EXT_DIR="$_DST/Default/Extensions"
_removed=()
_kept=()
if [[ -d "$_EXT_DIR" ]]; then
while IFS= read -r -d '' ext_path; do
ext_id="$(basename "$ext_path")"
# Conservar siempre la carpeta Temp (usada por Chrome durante installs)
if [[ "$ext_id" == "Temp" ]]; then
continue
fi
# Comprobar si está en la lista blanca
_in_keep=0
for keep_id in "${_KEEP[@]}"; do
if [[ "$ext_id" == "$keep_id" ]]; then
_in_keep=1
break
fi
done
if [[ $_in_keep -eq 1 ]]; then
_kept+=("$ext_id")
else
rm -rf "$ext_path"
_removed+=("$ext_id")
fi
done < <(find "$_EXT_DIR" -mindepth 1 -maxdepth 1 -type d -print0)
fi
# ── purgar referencias a extensiones eliminadas en Preferences ───────────────
# Chrome re-descarga del Web Store cualquier extensión que aparezca en
# extensions.settings aunque su carpeta haya sido borrada. Editamos el JSON
# con python3 para evitar ese comportamiento.
if [[ ${#_removed[@]} -gt 0 ]]; then
# Construir lista Python de IDs eliminados
_py_ids_list=""
for _id in "${_removed[@]}"; do
_py_ids_list+="\"${_id}\","
done
_py_ids_list="[${_py_ids_list%,}]"
for _prefs_file in "$_DST/Default/Preferences" "$_DST/Default/Secure Preferences"; do
if [[ -f "$_prefs_file" ]]; then
python3 - "$_prefs_file" "$_py_ids_list" <<'PY' || \
echo "prepare_chrome_profile: advertencia — no se pudieron purgar refs en $(basename "$_prefs_file")" >&2
import sys, json
prefs_path = sys.argv[1]
removed_ids = json.loads(sys.argv[2])
with open(prefs_path, "r", encoding="utf-8") as f:
data = json.load(f)
# 1. extensions.settings.<id>
ext_settings = data.get("extensions", {}).get("settings", {})
for ext_id in removed_ids:
ext_settings.pop(ext_id, None)
# 2. extensions.pinned_extensions (lista de IDs)
pinned = data.get("extensions", {}).get("pinned_extensions", None)
if isinstance(pinned, list):
data["extensions"]["pinned_extensions"] = [
pid for pid in pinned if pid not in removed_ids
]
# 3. protection.macs.extensions.settings.<id> (Secure Preferences)
try:
mac_ext = data["protection"]["macs"]["extensions"]["settings"]
for ext_id in removed_ids:
mac_ext.pop(ext_id, None)
except (KeyError, TypeError):
pass
with open(prefs_path, "w", encoding="utf-8") as f:
json.dump(data, f, separators=(",", ":"))
PY
fi
done
fi
# ── emitir resultado JSON ─────────────────────────────────────────────────────
_json_array() {
# Convierte array bash en JSON array de strings
local arr=("$@")
local out="["
local first=1
for item in "${arr[@]}"; do
if [[ $first -eq 1 ]]; then
out+="\"$item\""
first=0
else
out+=",\"$item\""
fi
done
out+="]"
echo "$out"
}
_kept_json="$(_json_array "${_kept[@]+"${_kept[@]}"}")"
_removed_json="$(_json_array "${_removed[@]+"${_removed[@]}"}")"
printf '{"dst":"%s","kept":%s,"removed":%s}\n' \
"$_DST_REAL" \
"$_kept_json" \
"$_removed_json"
@@ -0,0 +1,93 @@
---
name: restore_chrome_bookmarks
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "restore_chrome_bookmarks --backup-dir <ts-dir> [--user-data-dir <dir>] [--profile <name>]... [--dry-run]"
description: "Restaura archivos Bookmarks de Chrome/Chromium desde un directorio de backup generado por backup_chrome_bookmarks hacia los perfiles destino en user-data-dir. Copia byte a byte con cp -p para preservar el checksum MD5 interno del archivo. Nunca parsea ni reserializa el JSON. Requiere que Chromium esté cerrado antes de ejecutar."
tags: [navegator, chromium, bookmarks, restore, browser, scraping, profile]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/browser/restore_chrome_bookmarks.sh"
params:
- name: --backup-dir
desc: "Directorio de backup con timestamp generado por backup_chrome_bookmarks. Debe contener subdirectorios <profile>/Bookmarks. OBLIGATORIO."
- name: --user-data-dir
desc: "Ruta raíz del user-data-dir de Chrome/Chromium destino. Default: ~/.config/chromium"
- name: --profile
desc: "Nombre del perfil a restaurar (repetible, ej. Default, Profile 1). Si no se pasa ninguno se restauran TODOS los perfiles presentes en el backup-dir."
- name: --dry-run
desc: "Muestra qué archivos se copiarían y cuáles Bookmarks.bak se borrarían, sin tocar nada en disco."
output: "JSON en stdout: {\"restored\": [{\"profile\": \"Default\", \"dst\": \"<path>\", \"bytes\": N}, ...]}. Exit 0 en éxito o dry-run. Errores a stderr con exit != 0."
---
## Ejemplo
```bash
# PASO 1 — cerrar Chromium (OBLIGATORIO en modo real)
pkill -TERM chromium
# PASO 2 — restaurar todos los perfiles desde el backup más reciente
source $HOME/fn_registry/bash/functions/browser/restore_chrome_bookmarks.sh
restore_chrome_bookmarks \
--user-data-dir "$HOME/.config/chromium" \
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00"
# Restaurar solo un perfil concreto
restore_chrome_bookmarks \
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
--profile Default
# Restaurar dos perfiles específicos
restore_chrome_bookmarks \
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
--profile Default \
--profile "Profile 1"
# Previsualizar sin tocar nada (no necesita Chromium cerrado)
restore_chrome_bookmarks \
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
--dry-run
# Salida esperada:
# {"restored":[{"profile":"Default","dst":"/home/enmanuel/.config/chromium/Default/Bookmarks","bytes":12453}]}
```
También ejecutable directamente con `fn run`:
```bash
cd $HOME/fn_registry
./fn run restore_chrome_bookmarks_bash_browser -- \
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
--dry-run
```
## Cuando usarla
Úsala después de una sesión de scraping o automatización que haya alterado los bookmarks, o para recuperar bookmarks tras formatear/recrear un perfil de Chromium. Combínala con `backup_chrome_bookmarks` (que genera el `--backup-dir` con la estructura esperada) para tener un ciclo completo de backup/restore. También útil para propagar bookmarks de un perfil o PC a otro.
## Gotchas
- **Chromium DEBE estar cerrado** antes de ejecutar en modo real. Chromium mantiene los bookmarks en memoria y los reescribe al archivo `Bookmarks` al cerrar; si restauras con Chromium abierto, el proceso sobreescribirá tu restauración al cerrarse. La función lo comprueba con `pgrep -x chromium` y aborta con exit 2 si hay procesos vivos. En `--dry-run` este check se omite.
- **Copia verbatim — nunca reserializar el JSON**. El archivo `Bookmarks` contiene un campo `checksum` con el MD5 del propio contenido JSON (calculado por Chromium internamente). Si se parsea y reserializa el JSON (aunque sea equivalente), el checksum queda inválido y Chromium descarta silenciosamente el archivo y regenera uno vacío. Esta función usa `cp -p` para garantizar que los bytes son idénticos al original.
- **En Chromium 148 los bookmarks NO están bajo `super_mac` de Secure Preferences**. No es necesario tocar `Preferences` ni `Secure Preferences` al restaurar bookmarks (a diferencia de extensiones). La función solo opera sobre el archivo `Bookmarks`.
- **`Bookmarks.bak` residual se borra**. Chromium crea `Bookmarks.bak` como copia de seguridad interna. Si existe antes de la restauración, esta función lo borra para que Chromium no lo use como fallback en lugar del archivo recién restaurado.
- **El directorio destino del perfil se crea si no existe**. Si el perfil aún no tiene directorio en `user-data-dir`, se crea con `mkdir -p`. Chromium lo inicializará correctamente la primera vez que arranque con ese perfil.
- **Opera por perfil**. Si no pasas `--profile`, restaura todos los perfiles presentes en el backup. Pasa `--profile` explícito para restaurar selectivamente y evitar sobreescribir perfiles sin querer.
## Exit codes
| Código | Significado |
|--------|------------|
| 0 | Éxito o dry-run completado |
| 1 | Argumento inválido, backup-dir/user-data-dir no encontrado, o perfil no presente en backup |
| 2 | Chromium está corriendo (solo en modo real) |
@@ -0,0 +1,172 @@
#!/usr/bin/env bash
# restore_chrome_bookmarks — restaura archivos Bookmarks de un backup generado por
# backup_chrome_bookmarks hacia los perfiles destino en user-data-dir.
# Copia byte a byte con cp -p (nunca parsea ni reserializa el JSON).
set -euo pipefail
restore_chrome_bookmarks() {
# ── defaults ──────────────────────────────────────────────────────────────
local _user_data_dir="${HOME}/.config/chromium"
local _backup_dir=""
local _profiles=()
local _dry_run=0
# ── parse args ────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: restore_chrome_bookmarks --backup-dir <ts-dir>
[--user-data-dir <dir>] [--profile <name>]... [--dry-run]
--user-data-dir Raíz de perfiles destino. Default: ~/.config/chromium
--backup-dir Directorio de backup con timestamp generado por
backup_chrome_bookmarks. Debe contener subdirectorios
<profile>/Bookmarks. OBLIGATORIO.
--profile <name> Perfil a restaurar (repetible). Si no se pasa ninguno
se restauran TODOS los perfiles presentes en backup-dir.
--dry-run Muestra qué se copiaría sin tocar nada.
Exit codes:
0 éxito (o dry-run completado)
1 error de argumento o validación
2 chromium está corriendo (solo en modo real)
EOF
return 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--user-data-dir) _user_data_dir="$2"; shift 2 ;;
--backup-dir) _backup_dir="$2"; shift 2 ;;
--profile) _profiles+=("$2"); shift 2 ;;
--dry-run) _dry_run=1; shift ;;
-h|--help) _usage; return 0 ;;
*) echo "restore_chrome_bookmarks: argumento desconocido: $1" >&2; return 1 ;;
esac
done
# ── validaciones ──────────────────────────────────────────────────────────
if [[ -z "$_backup_dir" ]]; then
echo "restore_chrome_bookmarks: --backup-dir es obligatorio" >&2
return 1
fi
if [[ ! -d "$_backup_dir" ]]; then
echo "restore_chrome_bookmarks: backup-dir no encontrado: ${_backup_dir}" >&2
return 1
fi
if [[ ! -d "$_user_data_dir" ]]; then
echo "restore_chrome_bookmarks: user-data-dir no encontrado: ${_user_data_dir}" >&2
return 1
fi
# ── guard: ningún chromium debe tener ESTE user-data-dir abierto (excepto en dry-run) ──
# Por-udd, no global. Comprobamos por PID con comm=chromium (pgrep -x) y leemos su cmdline,
# para NO auto-matchear el propio `grep`/`pgrep`: el path del udd contiene "chromium"
# (~/.config/chromium-cdp), así que un `pgrep -af '[c]hromium' | grep <udd>` se detecta a sí mismo.
if [[ $_dry_run -eq 0 ]]; then
local _p _busy=0
for _p in $(pgrep -x chromium 2>/dev/null); do
if tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_user_data_dir"; then
_busy=1; break
fi
done
if [[ $_busy -eq 1 ]]; then
echo "restore_chrome_bookmarks: hay un chromium con este user-data-dir abierto — ciérralo antes de restaurar:" >&2
echo " pkill -TERM chromium" >&2
echo "(Chromium reescribe Bookmarks desde memoria al cerrar y desharía la restauración)" >&2
return 2
fi
fi
# ── determinar perfiles a restaurar ───────────────────────────────────────
local _target_profiles=()
if [[ ${#_profiles[@]} -gt 0 ]]; then
# Perfiles explícitos: verificar que existen en el backup
local _p
for _p in "${_profiles[@]}"; do
if [[ ! -f "${_backup_dir}/${_p}/Bookmarks" ]]; then
echo "restore_chrome_bookmarks: backup no contiene perfil '${_p}': ${_backup_dir}/${_p}/Bookmarks" >&2
return 1
fi
_target_profiles+=("$_p")
done
else
# Autodescubrir todos los perfiles en el backup
local _profile_path
while IFS= read -r -d '' _profile_path; do
local _pname
_pname="$(basename "$(dirname "$_profile_path")")"
_target_profiles+=("$_pname")
done < <(find "$_backup_dir" -mindepth 2 -maxdepth 2 -name "Bookmarks" -print0 | sort -z)
if [[ ${#_target_profiles[@]} -eq 0 ]]; then
echo "restore_chrome_bookmarks: no se encontraron archivos Bookmarks en: ${_backup_dir}" >&2
return 1
fi
fi
# ── restaurar cada perfil ─────────────────────────────────────────────────
local _restored_json=""
local _first=1
local _prof
for _prof in "${_target_profiles[@]}"; do
local _src="${_backup_dir}/${_prof}/Bookmarks"
local _dst_dir="${_user_data_dir}/${_prof}"
local _dst="${_dst_dir}/Bookmarks"
local _dst_bak="${_dst_dir}/Bookmarks.bak"
# Tamaño del archivo fuente para el JSON de salida
local _bytes=0
if [[ -f "$_src" ]]; then
_bytes="$(wc -c < "$_src")"
# Eliminar espacios que wc puede añadir en algunas plataformas
_bytes="${_bytes// /}"
fi
if [[ $_dry_run -eq 1 ]]; then
echo "=== restore_chrome_bookmarks DRY-RUN ===" >&2
echo " Perfil : ${_prof}" >&2
echo " src : ${_src}" >&2
echo " dst : ${_dst}" >&2
echo " bytes : ${_bytes}" >&2
if [[ -f "$_dst_bak" ]]; then
echo " .bak : borraría ${_dst_bak}" >&2
fi
else
# Crear directorio destino si no existe
mkdir -p "$_dst_dir"
# Copiar byte a byte preservando timestamps (NUNCA reserializar)
cp -p "$_src" "$_dst"
# Borrar Bookmarks.bak residual si existe
if [[ -f "$_dst_bak" ]]; then
rm -f "$_dst_bak"
fi
fi
# Construir fragmento JSON para este perfil
local _entry
_entry="$(printf '{"profile":"%s","dst":"%s","bytes":%s}' \
"$_prof" "$_dst" "$_bytes")"
if [[ $_first -eq 1 ]]; then
_restored_json="${_entry}"
_first=0
else
_restored_json+=",$_entry"
fi
done
# ── emitir resultado JSON ─────────────────────────────────────────────────
printf '{"restored":[%s]}\n' "$_restored_json"
}
# ── auto-ejecución ────────────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
restore_chrome_bookmarks "$@"
fi
@@ -0,0 +1,100 @@
---
name: set_chrome_profile_appearance
kind: function
lang: bash
domain: browser
version: "1.1.0"
purity: impure
signature: "set_chrome_profile_appearance --user-data-dir <dir> --profile <dir-name> [--avatar <N|ruta.png>] [--color <#rrggbb>] [--variant <0..4>] [--dry-run]"
description: "Personaliza la apariencia visual de un perfil Chrome/Chromium existente: asigna un avatar built-in (índice 0..55) o una imagen PNG/JPG custom, y/o un color de acento (hex #rrggbb). Con --color aplica el tinte tanto al círculo del avatar en Local State (profile_highlight_color, profile_color_seed, default_avatar_fill_color) como al tema completo del navegador en el Preferences del perfil (browser.theme.user_color2, browser_color_variant, extensions.theme.system_theme), tiñendo toolbar, frame, barra de pestañas y omnibox. Requiere que Chromium esté cerrado sobre el user-data-dir. Hace backup de Local State y Preferences antes de escribir y valida el JSON resultante."
tags: [navegator, chromium, profile, browser, cdp, scraping, appearance, avatar, color]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/browser/set_chrome_profile_appearance.sh"
params:
- name: --user-data-dir
desc: "Raíz del user-data-dir de Chrome/Chromium donde vive el perfil. El directorio y Local State deben existir. Obligatorio."
- name: --profile
desc: "Nombre de la carpeta del perfil dentro de user-data-dir, por ejemplo: Default, Automation, \"Profile 1\". El perfil debe existir previamente en info_cache de Local State. Obligatorio."
- name: --avatar
desc: "Índice entero 0..55 del avatar built-in de Chrome (56 avatares: animales, objetos, personas) o ruta absoluta/relativa a un archivo PNG/JPG para avatar custom. Con índice: sets avatar_icon=IDR_PROFILE_AVATAR_<N> e is_using_default_avatar=true. Con imagen: copia el archivo al perfil como 'Google Profile Picture.png' y sets is_using_default_avatar=false. Opcional; al menos uno de --avatar o --color debe darse."
- name: --color
desc: "Color de acento del perfil en hex #rrggbb, con o sin el '#' inicial. Se convierte a int32 con signo en formato ARGB 0xFFRRGGBB. Aplica el color en dos lugares: (1) Local State info_cache (profile_highlight_color, profile_color_seed, default_avatar_fill_color) para el círculo del avatar; (2) Preferences del perfil (browser.theme.user_color2 + browser_color_variant + extensions.theme.system_theme=0) para teñir toolbar, frame, barra de pestañas y omnibox. Opcional; al menos uno de --avatar o --color debe darse."
- name: --variant
desc: "Intensidad del tema de color aplicado al navegador (browser_color_variant). Entero 0..4: 0=system, 1=tonal_spot, 2=neutral, 3=vibrant (default), 4=expressive. Valores más altos dan tintes más saturados e identificables. Solo tiene efecto cuando se usa --color. Opcional."
- name: --dry-run
desc: "Describe las acciones que se ejecutarían (campos a modificar en Local State y Preferences, conversión de color, ruta del Preferences) sin escribir nada ni verificar si Chromium está corriendo. Emite JSON de resultado con dry_run:true."
output: "JSON en stdout con los campos resultantes del perfil: {\"profile\":\"<dir>\",\"avatar_icon\":\"...\",\"is_using_default_avatar\":true|false,\"profile_highlight_color\":<int>,\"profile_color_seed\":<int>,\"default_avatar_fill_color\":<int>,\"theme_applied\":true|false,\"variant\":<int>,\"preferences_path\":\"...\",\"browser_theme_user_color2\":<int>,\"browser_theme_color_variant\":<int>,\"extensions_theme_system_theme\":<int>,\"backup\":\"Local State.bak.YYYYMMDD\"}. En dry-run: {\"profile\":\"...\",\"avatar_applied\":true|false,\"color_applied\":true|false,\"theme_applied\":true|false,\"variant\":<int>,\"dry_run\":true}. Mensajes de diagnóstico a stderr. Exit 0 en éxito."
---
## Ejemplo
```bash
source $HOME/fn_registry/bash/functions/browser/set_chrome_profile_appearance.sh
# Asignar avatar #30 y tinte verde a toolbar/frame/omnibox del perfil Automation
# (verde #16a34a tiñe toda la chrome del navegador, no solo el círculo del avatar)
set_chrome_profile_appearance \
--user-data-dir ~/.config/chromium-cdp \
--profile Automation \
--avatar 30 \
--color "#16a34a"
# Salida JSON incluye: theme_applied:true, variant:3, browser_theme_user_color2:-15293622
# Color con intensidad personalizada (expressive = máxima saturación)
set_chrome_profile_appearance \
--user-data-dir ~/.config/chromium-cdp \
--profile Scraping \
--color "#1f6feb" \
--variant 4
# Solo cambiar avatar (no toca Preferences del perfil)
set_chrome_profile_appearance \
--user-data-dir ~/.config/chromium-cdp \
--profile "Profile 1" \
--avatar 5
# Dry-run: ver qué se aplicaría en Local State y Preferences sin escribir
set_chrome_profile_appearance \
--user-data-dir ~/.config/chromium-cdp \
--profile Automation \
--avatar 30 \
--color "#16a34a" \
--dry-run
```
## Cuando usarla
Úsala para diferenciar visualmente los perfiles de un user-data-dir de automatización — un color y avatar distintos por perfil hacen inmediata la identificación en el selector de Chrome Y en la chrome del navegador (toolbar/frame visible mientras navega). Ejecútala justo después de `create_chrome_profile` (con `--no-launch`) o como paso independiente de personalización batch antes de lanzar sesiones CDP. Si solo quieres teñir el círculo del avatar (sin el tema), basta esta función; si quieres el tinte completo del navegador (lo más identificable), pasa `--color`.
## Gotchas
- **Chromium debe estar cerrado**: Chrome reescribe `Local State` y `Preferences` completos desde memoria al cerrar; si se ejecuta mientras hay un proceso chromium vivo sobre el mismo user-data-dir, Chrome sobreescribirá los cambios al salir. La función detecta esto con `pgrep -x chromium` filtrando por cmdline y sale con exit 2 antes de modificar nada. Usa `pkill -TERM chromium` para cerrar y espera unos segundos.
- **El tema se escribe en Preferences del perfil, distinto de Local State**: los cambios de color al avatar van en `<user-data-dir>/Local State` (global a todos los perfiles); los cambios de tema del navegador van en `<user-data-dir>/<profile_dir>/Preferences` (específico de cada perfil). La función hace backup de ambos archivos por separado antes de tocarlos.
- **El perfil debe existir en info_cache**: esta función personaliza perfiles existentes; no los crea. Usa `create_chrome_profile` primero (con `--no-launch` basta para que aparezca en Local State) y luego `set_chrome_profile_appearance`.
- **color es int32 con signo en ARGB**: Chrome almacena el color como entero con signo de 32 bits en formato `0xAARRGGBB`. Un color como `#16a34a` (verde) da ARGB `0xFF16A34A` → signed int32 `-15293622`. La función hace la conversión internamente; tú pasas siempre hex `#rrggbb`.
- **En modo oscuro del sistema el tinte sale más apagado**: en temas oscuros del sistema el color se mezcla con el fondo oscuro y queda menos saturado. Para compensar, usa `--variant 3` (vibrant, default) o `--variant 4` (expressive); valores bajos como 1 o 2 pueden resultar casi imperceptibles en modo oscuro.
- **`extensions.theme.system_theme` se fuerza a 0**: si el perfil usaba el tema GTK del sistema (`system_theme=1`), el GTK puede ignorar el `user_color`. Esta función lo fuerza a 0 (tema propio de Chrome) para que el `user_color2` tenga efecto. Si quieres devolver el perfil al tema del sistema, tendrás que resetear `system_theme` manualmente.
- **Avatar custom (imagen) es best-effort**: el campo `gaia_picture_file_name` y `is_using_default_avatar=false` se aplican correctamente en Local State y la imagen se copia al directorio del perfil. Sin embargo, Chrome puede ignorar la foto de perfil en perfiles sin sesión Google activa (Chromium sin cuenta). El camino robusto y garantizado es usar el índice built-in (`--avatar 0..55`): 56 avatares (animales, objetos, personas) son más que suficientes para diferenciar perfiles de automatización.
- **Backup diario**: se crea `Local State.bak.YYYYMMDD` y `Preferences.bak.YYYYMMDD` antes de cualquier escritura. Si ya existen los backups del día no se sobreescriben. Si el JSON resultante es inválido, se restaura automáticamente el backup correspondiente.
## Exit codes
| Código | Significado |
|--------|------------|
| 0 | Éxito |
| 1 | Argumento obligatorio faltante, rango inválido o archivo de imagen no encontrado |
| 2 | Lock: hay un chromium usando el mismo user-data-dir |
| 3 | El perfil no existe en info_cache de Local State |
| 4 | Error editando Local State o Preferences (JSON inválido tras escritura, restaurado backup) |
## Capability growth log
v1.1.0 (2026-06-06) — --color ahora aplica también el tema del navegador (toolbar/frame/omnibox) escribiendo browser.theme.user_color2 + browser_color_variant en el Preferences del perfil, no solo el color del avatar en Local State. Nuevo flag --variant (0..4, default 3 vibrant). Verificado con captura en Chromium 148.
@@ -0,0 +1,426 @@
#!/usr/bin/env bash
# set_chrome_profile_appearance — personaliza la apariencia visual de un perfil
# Chrome/Chromium existente: asigna un avatar built-in (índice 0..55) o una imagen
# PNG/JPG custom, y/o un color de acento (hex #rrggbb). Edita Local State Y el
# Preferences del perfil (browser.theme.* para teñir toolbar/frame/omnibox).
set -euo pipefail
set_chrome_profile_appearance() {
# ── defaults ──────────────────────────────────────────────────────────────
local _udd=""
local _profile_dir=""
local _avatar=""
local _color=""
local _variant=3
local _dry_run=0
# ── parse args ─────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: set_chrome_profile_appearance --user-data-dir <dir> --profile <dir-name>
[--avatar <N|ruta.png>] [--color <#rrggbb>] [--variant <0..4>] [--dry-run]
--user-data-dir Raíz del user-data-dir de Chrome/Chromium (obligatorio).
--profile Nombre de la carpeta del perfil, ej: Default, Automation,
"Profile 1" (obligatorio). El perfil debe existir.
--avatar Índice entero 0..55 del avatar built-in de Chrome, o ruta a
un archivo PNG/JPG para avatar custom (opcional).
--color Color de acento del perfil en formato hex #rrggbb, con o sin
el '#' inicial (opcional). Aplica el color tanto al círculo
del avatar (Local State) como al tema del navegador
(toolbar/frame/omnibox via Preferences del perfil).
--variant Intensidad del tema de color: 0=system, 1=tonal_spot,
2=neutral, 3=vibrant (default), 4=expressive. Solo tiene
efecto cuando se usa --color.
--dry-run Describe las acciones sin modificar nada.
Al menos uno de --avatar o --color debe indicarse.
Exit codes:
0 éxito
1 error de argumento o validación
2 lock: hay un chromium corriendo con este user-data-dir
3 el perfil no existe en info_cache de Local State
4 error editando Local State o Preferences (JSON inválido tras escritura)
EOF
return 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--user-data-dir) _udd="$2"; shift 2 ;;
--profile) _profile_dir="$2"; shift 2 ;;
--avatar) _avatar="$2"; shift 2 ;;
--color) _color="$2"; shift 2 ;;
--variant) _variant="$2"; shift 2 ;;
--dry-run) _dry_run=1; shift ;;
-h|--help) _usage; return 0 ;;
*) echo "set_chrome_profile_appearance: argumento desconocido: $1" >&2; return 1 ;;
esac
done
# ── validaciones obligatorias ──────────────────────────────────────────────
if [[ -z "$_udd" ]]; then
echo "set_chrome_profile_appearance: --user-data-dir es obligatorio" >&2
return 1
fi
if [[ -z "$_profile_dir" ]]; then
echo "set_chrome_profile_appearance: --profile es obligatorio" >&2
return 1
fi
if [[ -z "$_avatar" && -z "$_color" ]]; then
echo "set_chrome_profile_appearance: al menos --avatar o --color debe indicarse" >&2
return 1
fi
# Validar --variant
if ! [[ "$_variant" =~ ^[0-4]$ ]]; then
echo "set_chrome_profile_appearance: --variant debe ser un entero 0..4, recibido: ${_variant}" >&2
return 1
fi
# Expandir ~ en el user-data-dir
_udd="${_udd/#\~/$HOME}"
local _local_state="${_udd}/Local State"
# Verificar que user-data-dir y Local State existen
if [[ ! -d "$_udd" ]]; then
echo "set_chrome_profile_appearance: user-data-dir no encontrado: ${_udd}" >&2
return 1
fi
if [[ ! -f "$_local_state" ]]; then
echo "set_chrome_profile_appearance: Local State no encontrado: ${_local_state}" >&2
return 1
fi
# ── validar --avatar ──────────────────────────────────────────────────────
local _avatar_index=-1
local _avatar_image_path=""
if [[ -n "$_avatar" ]]; then
if [[ "$_avatar" =~ ^[0-9]+$ ]]; then
# Índice built-in
_avatar_index=$(( _avatar ))
if [[ $_avatar_index -lt 0 || $_avatar_index -gt 55 ]]; then
echo "set_chrome_profile_appearance: índice de avatar fuera de rango (0..55): ${_avatar}" >&2
return 1
fi
else
# Ruta a imagen custom
local _img_path="${_avatar/#\~/$HOME}"
if [[ ! -f "$_img_path" ]]; then
echo "set_chrome_profile_appearance: archivo de imagen no encontrado: ${_img_path}" >&2
return 1
fi
_avatar_image_path="$_img_path"
fi
fi
# ── validar --color ───────────────────────────────────────────────────────
local _color_hex=""
if [[ -n "$_color" ]]; then
_color_hex="${_color/#\#/}" # quitar # inicial si lo hay
if ! [[ "$_color_hex" =~ ^[0-9a-fA-F]{6}$ ]]; then
echo "set_chrome_profile_appearance: color hex inválido (espera rrggbb): ${_color}" >&2
return 1
fi
fi
# ── guard: ningún chromium debe tener ESTE user-data-dir abierto ──────────
# pgrep -x chromium lista solo procesos cuyo comm es exactamente "chromium",
# nunca grep/pgrep/bash. Así evitamos auto-matchear el propio script cuando
# el path del udd contiene "chromium" (p.ej. ~/.config/chromium-cdp).
if [[ $_dry_run -eq 0 ]]; then
local _p _busy=0
for _p in $(pgrep -x chromium 2>/dev/null); do
if tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_udd"; then
_busy=1; break
fi
done
if [[ $_busy -eq 1 ]]; then
echo "set_chrome_profile_appearance: hay un chromium corriendo con este user-data-dir — ciérralo primero:" >&2
echo " pkill -TERM chromium" >&2
echo " (Chrome reescribe Local State y Preferences al cerrar y pierde los cambios)" >&2
return 2
fi
fi
# ── verificar que el perfil existe en info_cache ──────────────────────────
if [[ $_dry_run -eq 0 ]]; then
local _profile_exists
_profile_exists="$(python3 -c "
import json, sys
data = json.load(open(sys.argv[1]))
ic = data.get('profile', {}).get('info_cache', {})
print('yes' if sys.argv[2] in ic else 'no')
" "$_local_state" "$_profile_dir" 2>/dev/null || echo "no")"
if [[ "$_profile_exists" != "yes" ]]; then
echo "set_chrome_profile_appearance: perfil '${_profile_dir}' no existe en info_cache de Local State" >&2
echo " Perfiles disponibles:" >&2
python3 -c "
import json, sys
data = json.load(open(sys.argv[1]))
ic = data.get('profile', {}).get('info_cache', {})
for k in ic: print(' ', k)
" "$_local_state" >&2 2>/dev/null || true
return 3
fi
fi
# ── modo dry-run ──────────────────────────────────────────────────────────
if [[ $_dry_run -eq 1 ]]; then
echo "=== set_chrome_profile_appearance DRY-RUN ===" >&2
echo " user-data-dir : ${_udd}" >&2
echo " profile : ${_profile_dir}" >&2
if [[ $_avatar_index -ge 0 ]]; then
echo " avatar : built-in #${_avatar_index} → avatar_icon=chrome://theme/IDR_PROFILE_AVATAR_${_avatar_index}" >&2
echo " is_using_default_avatar=true" >&2
elif [[ -n "$_avatar_image_path" ]]; then
local _dest_img="${_udd}/${_profile_dir}/Google Profile Picture.png"
echo " avatar : imagen custom ${_avatar_image_path}" >&2
echo " copiaría a ${_dest_img}" >&2
echo " is_using_default_avatar=false" >&2
echo " gaia_picture_file_name=Google Profile Picture.png" >&2
fi
if [[ -n "$_color_hex" ]]; then
local _signed_preview
_signed_preview="$(python3 -c "
rgb = int('${_color_hex}', 16)
argb = 0xFF000000 | rgb
signed = argb - 0x100000000 if argb >= 0x80000000 else argb
print(signed)
" 2>/dev/null || echo '?')"
echo " color : #${_color_hex} → signed int32 ${_signed_preview}" >&2
echo " Local State: profile_highlight_color, profile_color_seed, default_avatar_fill_color" >&2
echo " Preferences: browser.theme.user_color2=${_signed_preview}, browser_color_variant=${_variant}, is_grayscale2=false" >&2
echo " Preferences: extensions.theme.system_theme=0" >&2
local _prefs_path="${_udd}/${_profile_dir}/Preferences"
echo " Preferences : ${_prefs_path}" >&2
fi
echo " Local State : ${_local_state}" >&2
printf '{"profile":"%s","avatar_applied":%s,"color_applied":%s,"theme_applied":%s,"variant":%d,"dry_run":true}\n' \
"$_profile_dir" \
"$([[ -n "$_avatar" ]] && echo 'true' || echo 'false')" \
"$([[ -n "$_color_hex" ]] && echo 'true' || echo 'false')" \
"$([[ -n "$_color_hex" ]] && echo 'true' || echo 'false')" \
"$_variant"
return 0
fi
# ── backup de Local State (no sobreescribir el del mismo día) ────────────
local _today
_today="$(date +%Y%m%d)"
local _backup="${_local_state}.bak.${_today}"
if [[ ! -f "$_backup" ]]; then
cp "$_local_state" "$_backup"
fi
# ── copiar imagen custom si es necesario ──────────────────────────────────
local _copy_image_done=false
if [[ -n "$_avatar_image_path" ]]; then
local _profile_path="${_udd}/${_profile_dir}"
mkdir -p "$_profile_path"
cp "$_avatar_image_path" "${_profile_path}/Google Profile Picture.png"
_copy_image_done=true
fi
# ── editar Local State con python3 ────────────────────────────────────────
if ! python3 - \
"$_local_state" \
"$_profile_dir" \
"${_avatar_index}" \
"${_avatar_image_path}" \
"${_color_hex}" <<'PY'; then
import sys, json
ls_path = sys.argv[1]
prof_dir = sys.argv[2]
avatar_index = int(sys.argv[3]) # -1 = no cambiar avatar
avatar_img = sys.argv[4] # "" = no usar imagen
color_hex = sys.argv[5] # "" = no cambiar color
with open(ls_path, "r", encoding="utf-8") as f:
data = json.load(f)
profile_section = data.setdefault("profile", {})
info_cache = profile_section.setdefault("info_cache", {})
# El perfil debe existir (ya validado en bash, pero doble check)
if prof_dir not in info_cache:
print(f"error: perfil '{prof_dir}' no existe en info_cache", file=sys.stderr)
sys.exit(1)
entry = info_cache[prof_dir]
# ── Avatar ────────────────────────────────────────────────────────────────────
if avatar_index >= 0:
# Avatar built-in: IDR_PROFILE_AVATAR_<N>
entry["avatar_icon"] = f"chrome://theme/IDR_PROFILE_AVATAR_{avatar_index}"
entry["is_using_default_avatar"] = True
elif avatar_img:
# Avatar custom imagen: Chrome necesita gaia_picture_file_name
entry["avatar_icon"] = "chrome://theme/IDR_PROFILE_AVATAR_0"
entry["is_using_default_avatar"] = False
entry["gaia_picture_file_name"] = "Google Profile Picture.png"
# ── Color ─────────────────────────────────────────────────────────────────────
if color_hex:
rgb = int(color_hex, 16) # 0xRRGGBB
argb = 0xFF000000 | rgb # alpha=FF opaco → 0xFFRRGGBB
# Convertir a int32 con signo (Python usa enteros arbitrarios)
signed = argb - 0x100000000 if argb >= 0x80000000 else argb
entry["profile_highlight_color"] = signed
entry["profile_color_seed"] = signed
entry["default_avatar_fill_color"] = signed
with open(ls_path, "w", encoding="utf-8") as f:
json.dump(data, f, separators=(",", ":"))
PY
echo "set_chrome_profile_appearance: error editando Local State con python3" >&2
return 4
fi
# ── validar JSON de Local State tras escritura ────────────────────────────
if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$_local_state" 2>/dev/null; then
echo "set_chrome_profile_appearance: JSON inválido tras escribir Local State; restaurando backup" >&2
cp "$_backup" "$_local_state"
return 4
fi
# ── editar Preferences del perfil (browser.theme.*) si hay color ─────────
local _prefs_path="${_udd}/${_profile_dir}/Preferences"
local _prefs_backup=""
local _theme_applied=false
if [[ -n "$_color_hex" ]]; then
_theme_applied=true
# Backup de Preferences antes de escribir (mismo patrón que Local State)
if [[ -f "$_prefs_path" ]]; then
_prefs_backup="${_prefs_path}.bak.${_today}"
if [[ ! -f "$_prefs_backup" ]]; then
cp "$_prefs_path" "$_prefs_backup"
fi
fi
# Editar/crear Preferences con python3
if ! python3 - \
"$_prefs_path" \
"${_color_hex}" \
"${_variant}" <<'PY'; then
import sys, json, os
prefs_path = sys.argv[1]
color_hex = sys.argv[2]
variant = int(sys.argv[3])
# Calcular el signed int32 ARGB
rgb = int(color_hex, 16)
argb = 0xFF000000 | rgb
signed = argb - 0x100000000 if argb >= 0x80000000 else argb
# Cargar Preferences existente o arrancar desde vacío
if os.path.isfile(prefs_path):
with open(prefs_path, "r", encoding="utf-8") as f:
data = json.load(f)
else:
data = {}
# ── browser.theme.* ──────────────────────────────────────────────────────────
browser = data.setdefault("browser", {})
theme = browser.setdefault("theme", {})
# Claves modernas (sufijo "2") — verificadas en Chromium 148
theme["user_color2"] = signed
theme["browser_color_variant"] = variant
theme["is_grayscale2"] = False
# Claves legacy (sin sufijo "2") — compatibilidad con versiones anteriores
theme["user_color"] = signed
theme["color_variant"] = variant
theme["is_grayscale"] = False
# ── extensions.theme.system_theme = 0 ────────────────────────────────────────
# 0=color propio, 1=GTK, 2=Qt. Forzar 0 para que el user_color tenga efecto.
extensions = data.setdefault("extensions", {})
ext_theme = extensions.setdefault("theme", {})
ext_theme["system_theme"] = 0
# Escribir directorio si no existe (perfil recién creado sin arrancar)
os.makedirs(os.path.dirname(prefs_path), exist_ok=True)
with open(prefs_path, "w", encoding="utf-8") as f:
json.dump(data, f, separators=(",", ":"))
PY
echo "set_chrome_profile_appearance: error editando Preferences con python3" >&2
# Restaurar Preferences si teníamos backup
if [[ -n "$_prefs_backup" && -f "$_prefs_backup" ]]; then
cp "$_prefs_backup" "$_prefs_path"
elif [[ -f "$_prefs_path" ]]; then
rm -f "$_prefs_path"
fi
return 4
fi
# Validar JSON de Preferences tras escritura
if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$_prefs_path" 2>/dev/null; then
echo "set_chrome_profile_appearance: JSON inválido tras escribir Preferences; restaurando backup" >&2
if [[ -n "$_prefs_backup" && -f "$_prefs_backup" ]]; then
cp "$_prefs_backup" "$_prefs_path"
fi
return 4
fi
fi
# ── leer valores resultantes para el JSON de salida ───────────────────────
local _result_json
_result_json="$(python3 - "$_local_state" "$_profile_dir" "$_prefs_path" "$_theme_applied" "$_variant" <<'PY'
import json, sys, os
ls_path = sys.argv[1]
prof_dir = sys.argv[2]
prefs_path = sys.argv[3]
theme_applied = sys.argv[4] == "true"
variant = int(sys.argv[5])
data = json.load(open(ls_path))
entry = data.get("profile", {}).get("info_cache", {}).get(prof_dir, {})
out = {
"profile": prof_dir,
"avatar_icon": entry.get("avatar_icon", ""),
"is_using_default_avatar": entry.get("is_using_default_avatar", True),
"profile_highlight_color": entry.get("profile_highlight_color", 0),
"profile_color_seed": entry.get("profile_color_seed", 0),
"default_avatar_fill_color": entry.get("default_avatar_fill_color", 0),
"theme_applied": theme_applied,
"variant": variant,
"preferences_path": prefs_path if theme_applied else "",
"backup": "Local State.bak." + __import__("datetime").date.today().strftime("%Y%m%d"),
}
# Añadir valores de theme si se aplicó
if theme_applied and os.path.isfile(prefs_path):
try:
prefs = json.load(open(prefs_path))
bt = prefs.get("browser", {}).get("theme", {})
out["browser_theme_user_color2"] = bt.get("user_color2", 0)
out["browser_theme_color_variant"] = bt.get("browser_color_variant", 0)
out["extensions_theme_system_theme"] = prefs.get("extensions", {}).get("theme", {}).get("system_theme", -1)
except Exception:
pass
print(json.dumps(out, separators=(",",":")))
PY
)"
echo "$_result_json"
}
# ── auto-ejecución ────────────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
set_chrome_profile_appearance "$@"
fi
+42 -44
View File
@@ -3,17 +3,17 @@ name: adb_wsl
kind: function
lang: bash
domain: infra
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "source adb_wsl.sh [ADB=<path>] [ANDROID_SDK_WIN=<sdk_root>]"
description: "Wrapper sourceable para usar adb.exe Windows desde WSL2. Resuelve binario, convierte paths, espera boot del emulador."
tags: ["android", "adb", "wsl", "windows"]
signature: "source adb_wsl.sh [ADB=<path>] [ANDROID_HOME=<sdk_root>]"
description: "Wrapper sourceable para resolver e invocar adb. Linux-first: usa el adb nativo del Android SDK ($ANDROID_HOME) o del PATH; fallback a adb.exe solo si detecta WSL2. Expone adb_run, adb_devices, adb_pick_serial, adb_s, adb_wait_boot."
tags: ["android", "adb", "linux", "emulator", "wsl"]
params:
- name: ADB
desc: "Env var opcional. Path absoluto a adb.exe. Si no se fija, se construye desde ANDROID_SDK_WIN o el default /mnt/c/Users/lucas/AppData/Local/Android/Sdk."
- name: ANDROID_SDK_WIN
desc: "Env var opcional. Raiz del Android SDK montado en WSL. Default: /mnt/c/Users/lucas/AppData/Local/Android/Sdk."
output: "Source-able shell helpers: adb_run, adb_devices, adb_wsl_to_win, adb_wait_boot. Define ADB env var apuntando a Windows adb.exe via ANDROID_SDK_WIN."
desc: "Env var opcional. Path absoluto al binario adb (override explicito). Si no se fija, se resuelve Linux-first: $ANDROID_HOME/platform-tools/adb, luego adb del PATH, luego adb.exe si WSL2."
- name: ANDROID_HOME
desc: "Env var opcional. Raiz del Android SDK nativo. Si esta presente, se usa $ANDROID_HOME/platform-tools/adb. Tambien se acepta ANDROID_SDK_ROOT."
output: "Source-able shell helpers: adb_run, adb_devices, adb_pick_serial, adb_s, adb_wait_boot, adb_wsl_to_win. Resuelve y fija la env var ADB al binario adb disponible."
uses_functions: []
uses_types: []
returns: []
@@ -26,24 +26,33 @@ test_file_path: ""
file_path: "bash/functions/infra/adb_wsl.sh"
---
## Uso
## Cuando usarla
Sourcéala como capa base de cualquier script que hable con un device o emulador Android via adb. Es la dependencia comun de todo el toolbelt android del registry (`android_screenshot`, `android_input_*`, `android_logcat`, `android_app_*`, `android_push/pull`). En Linux nativo resuelve el adb del SDK automaticamente; no hace falta configurar nada si `ANDROID_HOME` esta exportado (o `adb` esta en el PATH).
## Ejemplo
```bash
# Sourcear (usa SDK default)
# Linux nativo: con el SDK instalado y ANDROID_HOME exportado, resuelve solo.
source ~/android-sdk/env.sh
source bash/functions/infra/adb_wsl.sh
adb_devices
# List of devices attached
# emulator-5554 device
# Sourcear con SDK custom
ANDROID_SDK_WIN=/mnt/d/Android/Sdk source bash/functions/infra/adb_wsl.sh
# Fijar binario adb explicito (override)
ADB=/opt/android/platform-tools/adb source bash/functions/infra/adb_wsl.sh
# Sourcear con binario fijo
ADB=/mnt/c/my/tools/adb.exe source bash/functions/infra/adb_wsl.sh
# Smoke test
bash bash/functions/infra/adb_wsl.sh --self-test
# Android Debug Bridge version 1.0.41
```
## Funciones expuestas
### `adb_run "<args...>"`
Ejecuta `$ADB` con los argumentos dados. Retorna el exit code de `adb.exe`.
Ejecuta `$ADB` con los argumentos dados. Retorna el exit code de adb.
```bash
adb_run shell ls /sdcard/
@@ -54,45 +63,34 @@ adb_run install app.apk
Alias de `adb_run devices`. Lista dispositivos/emuladores conectados.
```bash
adb_devices
# List of devices attached
# emulator-5554 device
```
### `adb_pick_serial [--serial <S>] [...]`
### `adb_wsl_to_win <path_wsl>`
Convierte un path WSL a formato Windows con `wslpath -w`. Si `wslpath` no está disponible retorna el path sin convertir.
Resuelve el serial a usar (multi-device). Lee `--serial X` de los args y setea los globals `ADB_PICK_SERIAL` y `ADB_PICK_REST`. Si no se pasa, autoselecciona el primer device/emulador conectado.
```bash
win_path=$(adb_wsl_to_win /home/lucas/proyecto/app.apk)
# C:\Users\lucas\AppData\Local\... (o la ruta Windows equivalente)
adb_run install "$win_path"
adb_pick_serial "$@" || { echo "no device" >&2; exit 3; }
serial="$ADB_PICK_SERIAL"; set -- "${ADB_PICK_REST[@]}"
```
### `adb_s <serial> <args...>`
Atajo de `adb_run -s <serial> <args...>` para multi-device.
### `adb_wait_boot [timeout_s]`
Espera a que el emulador/dispositivo complete el boot (`sys.boot_completed = 1`). Útil tras lanzar un AVD en CI.
Espera a que el emulador/dispositivo complete el boot (`sys.boot_completed = 1`). Polling cada 3s. Retorna `0` si bootó, `1` si timeout (default 120s).
```bash
adb_wait_boot # timeout 120s
adb_wait_boot 60 # timeout 60s
```
### `adb_wsl_to_win <path_wsl>`
Retorna `0` si el boot se completó, `1` si expiró el timeout.
Legacy WSL: convierte path WSL→Windows con `wslpath -w`. En Linux nativo (sin `wslpath`) devuelve el path tal cual.
## Smoke test
## Gotchas
```bash
bash bash/functions/infra/adb_wsl.sh --self-test
# OK
```
- **Linux-first.** El default ya NO es Windows. Resolucion: `$ADB``$ANDROID_HOME/platform-tools/adb``adb` del PATH → (solo si `/proc/version` indica WSL2) `adb.exe`. En un PC Linux con el SDK instalado funciona sin configurar nada.
- **Necesita el SDK o adb en PATH.** Si no encuentra adb aborta con mensaje a stderr. Instala con `fn run install_android_sdk_bash_infra` y exporta `ANDROID_HOME` (o `source ~/android-sdk/env.sh`).
- **`ADB` se resuelve una sola vez al sourcing.** Cambiar el SDK despues requiere re-sourcear.
- **Sourcéala con bash, no zsh.** Los consumidores usan `${BASH_SOURCE[0]}` para localizar este archivo; ejecutarlos con `bash <file>` (no `zsh`/`source` desde zsh) resuelve el path correctamente.
## Notas
## Capability growth log
- El script es **source-able**: define funciones en el shell actual, no crea subshell.
- `ADB` se resuelve una sola vez al sourcing. Si el binario no existe en disco, la carga falla con mensaje en stderr y `return 1` / `exit 1`.
- `adb_wait_boot` hace polling cada 3 segundos. Ajustar `interval` si el emulador es especialmente lento.
- En WSL2 `wslpath` siempre está disponible; el fallback existe para entornos Linux puros que accidentalmente sourceen el archivo.
- Si el emulador requiere `-s <serial>`, pasar el flag directamente a `adb_run`: `adb_run -s emulator-5554 shell ...`.
---
- v1.1.0 (2026-06-03) — Linux-first: la resolucion de adb ahora prioriza el adb nativo del SDK (`$ANDROID_HOME/platform-tools/adb`) y del PATH; el adb.exe de Windows queda como fallback legacy solo bajo WSL2. Se elimina el default hardcodeado `/mnt/c/Users/lucas/...`. Todo el toolbelt android (~20 funciones) pasa a funcionar en Linux nativo sin preexportar `ADB`.
+25 -10
View File
@@ -1,20 +1,35 @@
#!/usr/bin/env bash
# adb_wsl — Wrapper sourceable para usar adb.exe Windows desde WSL2.
# adb_wsl — Wrapper sourceable para resolver e invocar adb.
# Linux-first: usa el adb nativo del Android SDK o del PATH. Conserva un
# fallback a adb.exe SOLO cuando se detecta WSL2 (legacy). El nombre del
# archivo se mantiene por compatibilidad con sus consumidores del registry.
# Uso: source bash/functions/infra/adb_wsl.sh
# Smoke test: bash bash/functions/infra/adb_wsl.sh --self-test
# ---------------------------------------------------------------------------
# Resolver ADB
# Resolver ADB (Linux-first; fallback WSL legacy)
# ---------------------------------------------------------------------------
# El caller puede fijar ADB antes de sourcing para apuntar a otro binario.
# Prioridad de resolucion:
# 1. $ADB preexportada por el caller (override explicito).
# 2. adb nativo del Android SDK ($ANDROID_HOME / $ANDROID_SDK_ROOT).
# 3. adb del PATH.
# 4. (legacy) adb.exe de Windows, solo si corremos dentro de WSL2.
if [[ -z "${ADB:-}" ]]; then
_sdk_root="${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}"
ADB="${_sdk_root}/platform-tools/adb.exe"
unset _sdk_root
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -n "$_sdk" && -x "$_sdk/platform-tools/adb" ]]; then
ADB="$_sdk/platform-tools/adb"
elif command -v adb &>/dev/null; then
ADB="$(command -v adb)"
elif grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then
_sdk_win="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}"
ADB="${_sdk_win}/platform-tools/adb.exe"
unset _sdk_win
fi
unset _sdk
fi
if [[ ! -f "$ADB" ]]; then
echo "adb_wsl: ADB no encontrado en '$ADB'. Fija ADB= o ANDROID_SDK_WIN= antes de sourcear." >&2
if [[ -z "${ADB:-}" ]] || ! command -v "$ADB" &>/dev/null; then
echo "adb_wsl: adb no encontrado. Instala el SDK (fn run install_android_sdk_bash_infra), exporta ANDROID_HOME, o fija ADB= antes de sourcear." >&2
# Solo abortamos si el script se ejecuta directamente; si se sourcea,
# permitimos continuar para que el caller maneje el error.
return 1 2>/dev/null || exit 1
@@ -22,8 +37,8 @@ fi
# ---------------------------------------------------------------------------
# adb_run "<args...>"
# Ejecuta el ADB Windows con los argumentos dados.
# Retorna el exit code de adb.exe.
# Ejecuta adb (el binario resuelto en $ADB) con los argumentos dados.
# Retorna el exit code de adb.
# ---------------------------------------------------------------------------
adb_run() {
"$ADB" "$@"
+20 -14
View File
@@ -3,11 +3,11 @@ name: android_emulator_list
kind: function
lang: bash
domain: infra
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "android_emulator_list([--json])"
description: "Lista los AVDs disponibles invocando emulator.exe Windows desde WSL2."
tags: [android, emulator, wsl]
description: "Lista los AVDs disponibles. Linux-first: usa el emulator nativo del Android SDK ($ANDROID_HOME); fallback a emulator.exe solo bajo WSL2."
tags: [android, emulator, linux, avd, wsl]
uses_functions: []
uses_types: []
returns: []
@@ -17,35 +17,41 @@ imports: []
params:
- name: "--json"
desc: "Optional flag, outputs JSON array instead of newline-separated names"
output: "Lista de AVDs disponibles en el SDK Windows. Una por linea, o JSON array con --json."
output: "Lista de AVDs disponibles en el SDK. Una por linea, o JSON array con --json."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/android_emulator_list.sh"
notes: "Lee env var EMULATOR o ANDROID_SDK_WIN. Default Windows path: /mnt/c/Users/lucas/AppData/Local/Android/Sdk/emulator/emulator.exe. Exit 0 si lista (incluso vacia). Exit 1 solo si el binario no existe o no es ejecutable."
notes: "Resuelve el binario emulator Linux-first ($ANDROID_HOME/emulator/emulator -> emulator del PATH -> emulator.exe si WSL2). Override con EMULATOR=. Exit 0 si lista (incluso vacia). Exit 1 solo si el binario no existe."
---
## Ejemplo
```bash
source ~/android-sdk/env.sh # exporta ANDROID_HOME
# Listar AVDs (una por linea)
android_emulator_list
# Pixel_API34
# Listar AVDs en formato JSON
android_emulator_list --json
# ["Pixel_7_API_34","Pixel_4_API_30"]
# ["Pixel_API34"]
# Sobreescribir ruta del emulador
EMULATOR="/custom/path/emulator.exe" android_emulator_list
# Sobreescribir SDK base
ANDROID_SDK_WIN="/mnt/d/Android/Sdk" android_emulator_list
EMULATOR="/opt/android/emulator/emulator" android_emulator_list
```
## Notas
## Cuando usarla
El script es ejecutable directamente (`chmod +x`) o invocable con `bash android_emulator_list.sh`.
Antes de arrancar un emulador, para validar que el AVD existe (lo hace `deploy_capacitor_to_emulator` y `run_kotlin_app_tests` internamente). Útil también para listar qué AVDs hay creados en la máquina.
`emulator.exe -list-avds` imprime warnings a stderr que se descartan con `2>/dev/null`. La captura con `mapfile` filtra ademas lineas vacias para producir una lista limpia.
## Gotchas
La variable `EMULATOR` tiene prioridad sobre `ANDROID_SDK_WIN`. Si ninguna esta definida se usa el path Windows por defecto de Lucas.
- **Linux-first.** El default ya no es Windows. Resuelve `$ANDROID_HOME/emulator/emulator`, luego `emulator` del PATH, y solo bajo WSL2 cae a `emulator.exe`.
- `emulator -list-avds` imprime warnings a stderr que se descartan con `2>/dev/null`. La captura con `mapfile` filtra líneas vacías.
- Override del binario con `EMULATOR=`; override del SDK con `ANDROID_HOME=`.
## Capability growth log
- v1.1.0 (2026-06-03) — Linux-first: resuelve el emulator nativo del SDK (`$ANDROID_HOME`) y del PATH antes que `emulator.exe`; se elimina el default hardcodeado `/mnt/c/Users/lucas/...`.
+16 -5
View File
@@ -1,12 +1,23 @@
#!/usr/bin/env bash
# android_emulator_list — Lista los AVDs disponibles invocando emulator.exe Windows desde WSL2.
# android_emulator_list — Lista los AVDs disponibles. Linux-first: usa el
# emulator nativo del Android SDK; fallback a emulator.exe solo bajo WSL2.
set -euo pipefail
# Resolve emulator binary
EMULATOR="${EMULATOR:-${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}/emulator/emulator.exe}"
# Resolve emulator binary (Linux-first; WSL fallback)
if [[ -z "${EMULATOR:-}" ]]; then
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -n "$_sdk" && -x "$_sdk/emulator/emulator" ]]; then
EMULATOR="$_sdk/emulator/emulator"
elif command -v emulator &>/dev/null; then
EMULATOR="$(command -v emulator)"
elif grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then
EMULATOR="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}/emulator/emulator.exe"
fi
unset _sdk
fi
if [[ ! -x "$EMULATOR" ]]; then
echo "error: emulator binary not found or not executable: $EMULATOR" >&2
if [[ -z "${EMULATOR:-}" ]] || ! command -v "$EMULATOR" &>/dev/null; then
echo "error: emulator no encontrado. Instala el SDK (fn run install_android_sdk_bash_infra) + el paquete 'emulator', exporta ANDROID_HOME, o fija EMULATOR=." >&2
exit 1
fi
+23 -13
View File
@@ -3,14 +3,14 @@ name: android_emulator_start
kind: function
lang: bash
domain: infra
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "android_emulator_start(avd_name: string, timeout_s: int) -> string"
description: "Arranca un AVD en background y espera a que termine de bootear. Idempotente: si ya hay emulador corriendo no lanza otro."
tags: [android, emulator, wsl]
description: "Arranca un AVD Android en background y espera a que termine de bootear. Linux-first: resuelve el emulator/adb nativos del SDK; fallback a binarios .exe solo bajo WSL2. Idempotente: si ya hay un emulador corriendo, imprime 'already running' y su serial sin lanzar otro."
tags: [android, emulator, linux, avd, wsl]
params:
- name: avd_name
desc: "Nombre del AVD a arrancar (visible con android_emulator_list o `emulator.exe -list-avds`)"
desc: "Nombre del AVD a arrancar (visible con android_emulator_list o `emulator -list-avds`)"
- name: timeout_s
desc: "Timeout total en segundos para esperar el boot completo. Opcional, default 180"
output: "Serial del device emulado (ej. emulator-5554) en stdout. Exit 0 = boot completo, exit 1 = timeout o emulador murio."
@@ -29,21 +29,31 @@ file_path: "bash/functions/infra/android_emulator_start.sh"
## Ejemplo
```bash
source ~/android-sdk/env.sh # exporta ANDROID_HOME -> resuelve emulator/adb nativos
source bash/functions/infra/android_emulator_start.sh
# Arrancar AVD con timeout por defecto (180s)
serial=$(android_emulator_start "Pixel_6_API_34")
serial=$(android_emulator_start "Pixel_API34")
echo "Emulador listo: $serial" # emulator-5554
# Con timeout personalizado
serial=$(android_emulator_start "Pixel_6_API_34" 300)
serial=$(android_emulator_start "Pixel_API34" 300)
```
## Notas
Para ver la ventana del emulador en un escritorio Linux, exporta `DISPLAY` (y `XAUTHORITY`) antes de invocar.
- Sourcea `adb_wsl.sh` del mismo directorio si existe (provee `ADB`, `adb_run`, `adb_wait_boot`). Si no, usa implementacion inline.
- Resuelve `EMULATOR` y `ADB` desde `ANDROID_SDK_WIN` (default `/mnt/c/Users/lucas/AppData/Local/Android/Sdk`) o desde las variables de entorno `EMULATOR=` / `ADB=` si ya están fijadas.
- Idempotente: si `adb devices` ya muestra un `emulator-*`, imprime "already running" + el serial y sale con exit 0 sin lanzar un segundo proceso.
- Log del emulador en `/tmp/emulator_<avd>.log`. PID en `/tmp/emulator_<avd>.pid`.
- El timeout total se reparte: primera mitad para `adb wait-for-device`, segunda mitad para esperar `sys.boot_completed=1`.
- Diseñado para WSL2 con Android SDK instalado en Windows. En Linux nativo basta cambiar las rutas de los binarios via `EMULATOR=` y `ADB=`.
## Cuando usarla
Cuando un script necesita un emulador booteado antes de instalar un APK o correr tests instrumentados (`gradle_instrumented_test`, `run_kotlin_app_tests`). Es idempotente, así que se puede llamar al principio de cualquier pipeline sin comprobar antes si ya hay uno arriba.
## Gotchas
- **Linux-first.** Resuelve `EMULATOR`/`ADB` desde `$ANDROID_HOME/{emulator/emulator, platform-tools/adb}` o del PATH; `emulator.exe`/`adb.exe` solo como fallback bajo WSL2. Override manual con `EMULATOR=`/`ADB=`.
- **Necesita `DISPLAY` para ventana.** Sin un servidor X accesible el emulador puede fallar al abrir ventana. Para headless/CI añade `-no-window` (editar la función o lanzar el emulador aparte).
- **Aceleración KVM.** Requiere acceso a `/dev/kvm` (grupo `kvm` o ACL). Sin ella el boot es lentísimo o falla.
- Log del emulador en `/tmp/emulator_<avd>.log`, PID en `/tmp/emulator_<avd>.pid`.
- El timeout total se reparte: primera mitad para `adb wait-for-device`, segunda para `sys.boot_completed=1`.
## Capability growth log
- v1.1.0 (2026-06-03) — Linux-first: resuelve emulator/adb nativos del SDK (`$ANDROID_HOME`) antes que los `.exe` de Windows (ahora solo fallback WSL2); se elimina el default hardcodeado `/mnt/c/Users/lucas/...`. fix: `timeout <n> adb_run wait-for-device` fallaba siempre porque `timeout` no puede ejecutar la función shell `adb_run`; ahora invoca el binario `"$ADB"` directamente.
+29 -14
View File
@@ -11,11 +11,17 @@ if [[ -f "$_ADB_WSL_SH" ]]; then
# shellcheck source=adb_wsl.sh
source "$_ADB_WSL_SH"
else
# Fallback inline: resolver ADB
# Fallback inline: resolver ADB (Linux-first; WSL fallback)
if [[ -z "${ADB:-}" ]]; then
_sdk_root="${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}"
ADB="${_sdk_root}/platform-tools/adb.exe"
unset _sdk_root
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -n "$_sdk" && -x "$_sdk/platform-tools/adb" ]]; then
ADB="$_sdk/platform-tools/adb"
elif command -v adb &>/dev/null; then
ADB="$(command -v adb)"
else
ADB="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}/platform-tools/adb.exe"
fi
unset _sdk
fi
adb_run() { "$ADB" "$@"; }
adb_wait_boot() {
@@ -33,12 +39,18 @@ else
fi
# ---------------------------------------------------------------------------
# Resolver EMULATOR
# Resolver EMULATOR (Linux-first; WSL fallback)
# ---------------------------------------------------------------------------
if [[ -z "${EMULATOR:-}" ]]; then
_sdk_root="${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}"
EMULATOR="${_sdk_root}/emulator/emulator.exe"
unset _sdk_root
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -n "$_sdk" && -x "$_sdk/emulator/emulator" ]]; then
EMULATOR="$_sdk/emulator/emulator"
elif command -v emulator &>/dev/null; then
EMULATOR="$(command -v emulator)"
elif grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then
EMULATOR="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}/emulator/emulator.exe"
fi
unset _sdk
fi
# ---------------------------------------------------------------------------
@@ -49,12 +61,12 @@ android_emulator_start() {
local timeout_s="${2:-180}"
# Validaciones de entorno
if [[ ! -f "$EMULATOR" ]]; then
echo "android_emulator_start: emulator.exe no encontrado en '$EMULATOR'. Fija EMULATOR= o ANDROID_SDK_WIN=." >&2
if [[ -z "${EMULATOR:-}" ]] || ! command -v "$EMULATOR" &>/dev/null; then
echo "android_emulator_start: emulator no encontrado. Instala el SDK + paquete 'emulator', exporta ANDROID_HOME, o fija EMULATOR=." >&2
return 1
fi
if [[ ! -f "$ADB" ]]; then
echo "android_emulator_start: adb.exe no encontrado en '$ADB'. Fija ADB= o ANDROID_SDK_WIN=." >&2
if [[ -z "${ADB:-}" ]] || ! command -v "$ADB" &>/dev/null; then
echo "android_emulator_start: adb no encontrado. Instala platform-tools, exporta ANDROID_HOME, o fija ADB=." >&2
return 1
fi
@@ -74,9 +86,12 @@ android_emulator_start() {
local emu_pid=$!
echo "$emu_pid" > "$pid_file"
# Esperar a que el dispositivo aparezca en adb
# Esperar a que el dispositivo aparezca en adb.
# Usamos el binario "$ADB" directamente (no la funcion adb_run): `timeout`
# ejecuta un comando externo y no puede ver funciones del shell, asi que
# `timeout ... adb_run` fallaba siempre con "command not found".
local wait_timeout=$(( timeout_s / 2 ))
if ! timeout "$wait_timeout" adb_run wait-for-device 2>/dev/null; then
if ! timeout "$wait_timeout" "$ADB" wait-for-device 2>/dev/null; then
echo "android_emulator_start: timeout esperando que el dispositivo aparezca en adb (${wait_timeout}s)." >&2
return 1
fi
@@ -0,0 +1,70 @@
---
name: audit_doctor_snapshot
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "audit_doctor_snapshot(doctor_subcommand: string, snapshot_base_dir: string) -> void"
description: "Ejecuta un subcomando de fn doctor --json, guarda un snapshot JSON fechado en <base>/<sub>/<stamp>.json, lo compara con la corrida anterior (latest.json) y emite a stdout un resumen legible: count actual, count previo, IDs nuevos y resueltos. Pieza de observabilidad Nivel 1 para DAGs de auditoría periódica."
tags: [audit, registry, infra, doctor, snapshot, diff, dag]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: doctor_subcommand
desc: "Subcomando de fn doctor a ejecutar (unused, capabilities, artefacts, copied-code, uses-functions, cpp-apps, services, sync, etc.)."
- name: snapshot_base_dir
desc: "Directorio base donde se crea la carpeta <base>/<subcommand>/ con los snapshots fechados y latest.json."
output: "Resumen a stdout: '[audit:<sub>] count=N prev=M +X new -Y resolved'. Si hay IDs nuevos/resueltos, líneas adicionales NEW:/RESOLVED: con hasta 8 IDs. Snapshots JSON en disco."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/audit_doctor_snapshot.sh"
---
## Ejemplo
```bash
# Primera corrida — establece baseline
FN_REGISTRY_ROOT=/home/enmanuel/fn_registry \
FN_BIN=/home/enmanuel/fn_registry/fn \
bash bash/functions/infra/audit_doctor_snapshot.sh \
unused \
/home/enmanuel/fn_registry/apps/dag_engine/local_files/audits/daily
# => [audit:unused] count=12 prev=- baseline (sin corrida previa)
# Segunda corrida — compara contra latest.json
FN_REGISTRY_ROOT=/home/enmanuel/fn_registry \
FN_BIN=/home/enmanuel/fn_registry/fn \
bash bash/functions/infra/audit_doctor_snapshot.sh \
unused \
/home/enmanuel/fn_registry/apps/dag_engine/local_files/audits/daily
# => [audit:unused] count=12 prev=12 +0 new -0 resolved
# Con otro subcomando (directorio independiente automático)
audit_doctor_snapshot artefacts /tmp/audits/weekly
```
## Cuando usarla
Úsala en un DAG/cron que ejecuta `fn doctor` periódicamente y quieres **persistir el resultado y ver qué cambió desde la última corrida**: funciones huérfanas que aparecieron, artefactos rotos nuevos, capabilities sin doc, etc. Es la pieza "snapshot + diff" del Nivel 1 de observabilidad de auditorías — el DAG llama esta función en vez de descartar el output de `fn doctor`.
## Gotchas
- **Depende de `FN_BIN` o `FN_REGISTRY_ROOT`** en el entorno. Si ninguno está seteado, asume `$HOME/fn_registry/fn`. En DAGs, asegúrate de exportar `FN_REGISTRY_ROOT` antes de invocar.
- **`latest.json` se sobreescribe cada corrida** — es el snapshot de referencia para el diff siguiente. No es un historial acumulado; el historial está en los archivos fechados `<stamp>.json`.
- **Si cambias de subcomando, el subdirectorio es distinto** (`<base>/unused/` vs `<base>/artefacts/`), así que no hay contaminación entre subcomandos aunque compartan el mismo `base_dir`.
- **Si `fn doctor <sub>` falla (rc != 0)**, la función propaga ese exit code. Esto es intencional: doctor roto = problema real que el DAG debe reportar. Los hallazgos normales (funciones huérfanas, artefactos con drift) tienen rc=0 en `fn doctor`.
- **jq es dependencia requerida**. Está disponible en el ecosistema del registry pero si el entorno no lo tiene, los conteos y diffs de IDs caen a `?`/textual respectivamente.
- **Retención automática**: snapshots fechados con más de 30 días se borran con `find -mtime +30`. `latest.json` nunca se borra.
- **Estructura del JSON de `fn doctor`**: el diff de IDs busca campos `.ID` o `.id` en los elementos. Si el subcomando produce una estructura distinta (objeto anidado sin esos campos), el diff cae a comparación textual, que sigue siendo útil.
## Notas
Diseñada para ser invocada desde steps del dag_engine (`daily-registry-audit`, `weekly-deep-scan`) como reemplazo del descarte silencioso del output de `fn doctor --json`. La salida stdout es legible por humanos y parseable por el orquestador del DAG para decidir si crear proposals.
Binario `fn` resuelto en orden: `$FN_BIN``${FN_REGISTRY_ROOT}/fn``$HOME/fn_registry/fn`.
@@ -0,0 +1,169 @@
#!/usr/bin/env bash
# audit_doctor_snapshot — ejecuta un subcomando de fn doctor, guarda snapshot JSON
# fechado, compara con la corrida anterior y emite resumen legible de cambios.
#
# Uso: audit_doctor_snapshot <doctor_subcommand> <snapshot_base_dir>
#
# Ejemplo:
# audit_doctor_snapshot unused /home/enmanuel/fn_registry/apps/dag_engine/local_files/audits/daily
set -uo pipefail
audit_doctor_snapshot() {
local sub="${1:-}"
local base="${2:-}"
# --- validacion de argumentos ---
if [[ -z "$sub" || -z "$base" ]]; then
echo "usage: audit_doctor_snapshot <subcommand> <base_dir>" >&2
return 2
fi
# --- resolver binario fn ---
local fn_bin="${FN_BIN:-${FN_REGISTRY_ROOT:-$HOME/fn_registry}/fn}"
if [[ ! -x "$fn_bin" ]]; then
echo "audit_doctor_snapshot: binario fn no encontrado o no ejecutable: $fn_bin" >&2
return 2
fi
# --- preparar directorio ---
local dir="$base/$sub"
mkdir -p "$dir"
# --- ejecutar fn doctor ---
local stderr_tmp
stderr_tmp="$(mktemp /tmp/audit_doctor_snapshot_stderr.XXXXXX)"
local json rc
json="$("$fn_bin" doctor "$sub" --json 2>"$stderr_tmp")" || rc=$?
rc="${rc:-0}"
if [[ "$rc" -ne 0 ]]; then
cat "$stderr_tmp" >&2
echo "audit_doctor_snapshot: 'fn doctor $sub' fallo (rc=$rc)" >&2
rm -f "$stderr_tmp"
return "$rc"
fi
rm -f "$stderr_tmp"
# --- normalizar con jq (diff estable) ---
local stamp
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
local curr="$dir/${stamp}.json"
local nojson=0
if ! echo "$json" | jq -S . > "$curr" 2>/dev/null; then
# salida no es JSON valido -> guardar crudo
printf '%s' "$json" > "$curr"
nojson=1
fi
# --- snapshot anterior ---
local prev="$dir/latest.json"
# --- contar hallazgos actuales ---
local count="?"
if [[ "$nojson" -eq 0 ]]; then
if jq -e 'type == "array"' "$curr" >/dev/null 2>&1; then
count="$(jq 'length' "$curr")"
elif jq -e 'type == "object"' "$curr" >/dev/null 2>&1; then
count="$(jq 'keys | length' "$curr")"
fi
fi
# --- contar hallazgos previos ---
local prevcount="-"
if [[ -f "$prev" ]]; then
if jq -e 'type == "array"' "$prev" >/dev/null 2>&1; then
prevcount="$(jq 'length' "$prev")"
elif jq -e 'type == "object"' "$prev" >/dev/null 2>&1; then
prevcount="$(jq 'keys | length' "$prev")"
fi
fi
# --- diff de identidad ---
local new_count=0
local resolved_count=0
local new_ids=()
local resolved_ids=()
local diff_label=""
if [[ ! -f "$prev" ]]; then
diff_label="baseline (sin corrida previa)"
elif [[ "$nojson" -eq 1 ]]; then
if ! diff -q "$prev" "$curr" >/dev/null 2>&1; then
diff_label="changed (textual)"
else
diff_label="+0 new -0 resolved"
fi
else
# extraer IDs estables: .ID o .id
local curr_ids prev_ids
curr_ids="$(jq -r 'if type=="array" then .[].ID // .[].id // empty else to_entries[].value.ID // to_entries[].value.id // empty end' "$curr" 2>/dev/null | sort -u)"
prev_ids="$(jq -r 'if type=="array" then .[].ID // .[].id // empty else to_entries[].value.ID // to_entries[].value.id // empty end' "$prev" 2>/dev/null | sort -u)"
if [[ -n "$curr_ids" || -n "$prev_ids" ]]; then
# NEW: en curr pero no en prev
local new_raw resolved_raw
new_raw="$(comm -23 <(echo "$curr_ids") <(echo "$prev_ids") 2>/dev/null || true)"
resolved_raw="$(comm -13 <(echo "$curr_ids") <(echo "$prev_ids") 2>/dev/null || true)"
if [[ -n "$new_raw" ]]; then
mapfile -t new_ids <<< "$new_raw"
fi
if [[ -n "$resolved_raw" ]]; then
mapfile -t resolved_ids <<< "$resolved_raw"
fi
new_count="${#new_ids[@]}"
resolved_count="${#resolved_ids[@]}"
diff_label="+${new_count} new -${resolved_count} resolved"
else
# sin campo .ID/.id — fallback textual
if ! diff -q "$prev" "$curr" >/dev/null 2>&1; then
diff_label="changed (textual)"
else
diff_label="+0 new -0 resolved"
fi
fi
fi
# --- resumen a stdout ---
echo "[audit:$sub] count=$count prev=$prevcount $diff_label"
# listar nuevos (max 8)
if [[ "${#new_ids[@]}" -gt 0 ]]; then
local listed=("${new_ids[@]:0:8}")
local extra=$(( ${#new_ids[@]} - 8 ))
local line
line="$(IFS=', '; echo "${listed[*]}")"
if [[ "$extra" -gt 0 ]]; then
line="${line} (+${extra} más)"
fi
echo " NEW: $line"
fi
# listar resueltos (max 8)
if [[ "${#resolved_ids[@]}" -gt 0 ]]; then
local listed_r=("${resolved_ids[@]:0:8}")
local extra_r=$(( ${#resolved_ids[@]} - 8 ))
local line_r
line_r="$(IFS=', '; echo "${listed_r[*]}")"
if [[ "$extra_r" -gt 0 ]]; then
line_r="${line_r} (+${extra_r} más)"
fi
echo " RESOLVED: $line_r"
fi
# --- actualizar puntero latest ---
cp "$curr" "$prev"
# --- retención: borrar snapshots fechados > 30 días ---
find "$dir" -maxdepth 1 -name '*.json' ! -name 'latest.json' -mtime +30 -delete 2>/dev/null || true
return 0
}
# Permitir ejecución directa
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
audit_doctor_snapshot "$@"
fi
+1 -1
View File
@@ -32,7 +32,7 @@ discover_git_repos() {
-not -path "*/node_modules/*" \
-not -path "*/.venv/*" \
-not -path "*/cpp/vendor/*" \
-not -path "*/cpp/build/*" \
-not -path "*/cpp/build*/*" \
-not -path "*/sources/*" \
-not -path "*/temp/*" \
-not -path "*/subrepos/*" \
+16 -1
View File
@@ -3,7 +3,7 @@ name: install_android_sdk
kind: function
lang: bash
domain: infra
version: "1.0.0"
version: "1.0.1"
purity: impure
signature: "install_android_sdk() -> void"
description: "Descarga e instala Android SDK command-line tools y JDK 17 localmente (sin root/sudo) en $ANDROID_SDK_DIR (default: $HOME/android-sdk). Idempotente: detecta instalacion existente y sale sin hacer nada. Genera env.sh con JAVA_HOME, ANDROID_HOME y PATH listos para hacer source."
@@ -50,6 +50,17 @@ ANDROID_SDK_DIR=/opt/android source install_android_sdk.sh
source ~/android-sdk/env.sh
```
## Cuando usarla
Cuando necesites un Android SDK funcional en una maquina Linux sin permisos de root: CI, contenedores, o un PC de desarrollo donde quieras un SDK aislado en `$HOME`. Instala la base minima para compilar (cmdline-tools + JDK 17 + platform-tools + API 34 + build-tools). Hazle `source` para tener `sdkmanager`/`avdmanager`/`adb` en el PATH antes de invocar `gradle_run`, `gradle_assemble_debug` o `capacitor_build_apk`.
## Gotchas
- **No instala `emulator` ni system images.** Solo la base de compilacion. Para correr un AVD: tras hacer `source env.sh`, instala `emulator` y una imagen (`sdkmanager "emulator" "system-images;android-34;google_apis;x86_64"`) y crea el AVD con `avdmanager create avd`.
- **Aceleracion KVM:** el emulador necesita acceso a `/dev/kvm`. Verifica con `[ -w /dev/kvm ]`; si no, anade tu usuario al grupo `kvm` (`sudo usermod -aG kvm $USER` + re-login) o concede ACL.
- **URL de cmdline-tools clavada** a la build 11076708 (2024). Si Google la retira, actualizar `tools_url` en el `.sh`.
- **Idempotente:** re-ejecutar no reinstala; detecta `sdkmanager` existente y sale en 0.
## Notas
Requiere `curl` y `unzip` (disponibles en la mayoria de distros Linux). No requiere root ni sudo.
@@ -61,3 +72,7 @@ La reorganizacion del zip es necesaria porque Google distribuye cmdline-tools co
El archivo `env.sh` generado en `$ANDROID_SDK_DIR/env.sh` contiene las variables de entorno necesarias (`JAVA_HOME`, `ANDROID_HOME`, `ANDROID_SDK_ROOT`, `PATH`) y puede hacerse source desde `.bashrc`, `.zshrc` o desde scripts de CI.
Paquetes instalados: `platform-tools` (adb, fastboot), `platforms;android-34` (API 34), `build-tools;34.0.0`.
## Capability growth log
- v1.0.1 (2026-06-03) — fix: `yes | sdkmanager --licenses` daba falso negativo bajo `pipefail` (SIGPIPE de `yes`, exit 141) abortando una instalacion exitosa; ahora se desactiva `pipefail` solo en ese pipe. fix: el trap `EXIT` referenciaba `$tmp_dir` (variable `local`) fuera del scope de la funcion → "unbound variable" con `set -u`; ahora es global con expansion defensiva.
+13 -3
View File
@@ -5,11 +5,14 @@ set -euo pipefail
install_android_sdk() {
local sdk_dir="${ANDROID_SDK_DIR:-$HOME/android-sdk}"
local tmp_dir
# tmp_dir es global a proposito: el trap EXIT se dispara al terminar el
# script (fuera del scope de la funcion), donde una variable `local` ya no
# existiria y `set -u` la marcaria como unbound. La expansion defensiva
# ${tmp_dir:-} evita el fallo aunque el trap corra antes de la asignacion.
tmp_dir="$(mktemp -d)"
# Limpia temporales al salir
trap 'rm -rf "$tmp_dir"' EXIT
trap 'rm -rf "${tmp_dir:-}"' EXIT
# 1. Verifica si ya está instalado
if [[ -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
@@ -103,11 +106,18 @@ install_android_sdk() {
export PATH="$JAVA_HOME/bin:$sdk_dir/cmdline-tools/latest/bin:$sdk_dir/platform-tools:$PATH"
# 4. Acepta licencias e instala paquetes necesarios
# `yes` recibe SIGPIPE (exit 141) cuando sdkmanager termina de leer y cierra
# el pipe; bajo `set -o pipefail` eso convierte un exito real en falso
# negativo. Desactivamos pipefail solo aqui para que el exit del pipeline
# refleje el de sdkmanager (ultimo comando), no el SIGPIPE de `yes`.
echo "Aceptando licencias de Android SDK..."
if ! yes | "$sdkmanager" --licenses; then
set +o pipefail
if ! yes | "$sdkmanager" --licenses >/dev/null 2>&1; then
set -o pipefail
echo "ERROR: fallo al aceptar licencias de Android SDK" >&2
return 1
fi
set -o pipefail
echo "Instalando platform-tools, platforms;android-34, build-tools;34.0.0..."
if ! "$sdkmanager" "platform-tools" "platforms;android-34" "build-tools;34.0.0"; then
@@ -3,12 +3,12 @@ name: write_mcp_jupyter_config
kind: function
lang: bash
domain: infra
version: "1.1.0"
version: "1.2.0"
purity: impure
signature: "write_mcp_jupyter_config([project_dir: string], [port: int]) -> string"
description: "Genera o actualiza .mcp.json con la config de jupyter-mcp-server apuntando al console-script del venv local (transport stdio + flags --jupyter-url/--jupyter-token). Merge con jq reemplazando la entrada jupyter entera."
description: "Genera o actualiza .mcp.json para un analisis Jupyter. La entrada jupyter usa el wrapper jupyter_mcp_serve.sh con env overrides (venv, root y puerto del analisis), de modo que el MCP arranca su propio Jupyter con el venv del analisis. Merge con jq reemplazando la entrada jupyter entera."
tags: [mcp, jupyter, config, setup, infra, notebook]
uses_functions: []
uses_functions: [jupyter_mcp_serve_bash_infra]
uses_types: []
returns: []
returns_optional: false
@@ -16,9 +16,9 @@ error_type: "error_go_core"
imports: []
params:
- name: project_dir
desc: "directorio del proyecto Jupyter (default: directorio actual)"
desc: "directorio del proyecto/analisis Jupyter (default: directorio actual)"
- name: port
desc: "puerto Jupyter (default: detectado automáticamente)"
desc: "puerto Jupyter del analisis (default: 8888)"
output: "ruta del archivo .mcp.json generado o actualizado"
tested: false
tests: []
@@ -33,25 +33,33 @@ source write_mcp_jupyter_config.sh
path=$(write_mcp_jupyter_config $HOME/fn_registry/analysis/finanzas 8890)
echo "Config MCP en: $path"
# Genera .mcp.json con:
# "command": ".../.venv/bin/jupyter-mcp-server"
# "args": ["--transport","stdio","--jupyter-url","http://localhost:8890","--jupyter-token",""]
# "command": "bash"
# "args": [".../bash/functions/infra/jupyter_mcp_serve.sh"]
# "env": {
# "JUPYTER_MCP_VENV": ".../analysis/finanzas/.venv",
# "JUPYTER_MCP_ROOT": ".../analysis/finanzas",
# "JUPYTER_MCP_PORT": "8890",
# "JUPYTER_MCP_TOKEN": ""
# }
```
## Cuando usarla
- Al crear un analysis Jupyter nuevo (la usa el pipeline `init_jupyter_analysis`).
- Tras mover/recrear un venv y necesitar regenerar el `.mcp.json` del analysis.
- Para reparar un `.mcp.json` con el comando viejo roto (`python -m jupyter_mcp_server.server`).
- Para reparar un `.mcp.json` con el comando viejo (console-script directo que no arranca Jupyter, o `python -m jupyter_mcp_server.server`).
## Gotchas
- **NUNCA `python -m jupyter_mcp_server.server`** — `server.py` no tiene bloque `__main__`; el proceso importa y sale 0 y el MCP nunca arranca. El entrypoint real es la CLI (`jupyter_mcp_server.CLI:server`), expuesta como console-script `jupyter-mcp-server`. Sin subcomando arranca en stdio por defecto.
- **No usa env vars** `SERVER_URL`/`TOKEN`. La CLI lee flags `--jupyter-url` / `--jupyter-token` (cubren document + runtime). Configs viejas con bloque `env` quedan inertes.
- **Tolera Jupyter apagado al boot**: el MCP responde `initialize` tras un connect-timeout (~10s) y sirve igual. Arrancar Jupyter despues en `:port` y los tools se enganchan. No hace falta reiniciar Claude por tener Jupyter caido al inicio.
- **Requiere `jupyter-mcp-server` instalado en el venv**: `uv pip install jupyter-mcp-server`. La funcion aborta si el console-script no existe.
- **Path atado al venv del analysis**: si borras el analysis, ese `.mcp.json` apunta a un binario inexistente. Para un MCP jupyter global e independiente, el `.mcp.json` raiz de `fn_registry` usa el binario del venv canonico `python/.venv/bin/jupyter-mcp-server` (sobrevive el borrado de cualquier analysis).
- **Merge con jq usa `+` (shallow)** en el mapa de servidores para reemplazar la entrada `jupyter` entera; `*` (deep) dejaba keys huerfanas de configs viejas.
- **Usa el wrapper, no el console-script directo**: el `.mcp.json` apunta a `jupyter_mcp_serve.sh` (ver `jupyter_mcp_serve_bash_infra`), que arranca (o reusa) el Jupyter del analisis con su venv antes de exec del MCP. Con el console-script directo (`jupyter-mcp-server --jupyter-url ...`) el MCP solo se CONECTA: si el server no esta levantado no hay kernel y las operaciones sobre notebooks fallan. Con el wrapper basta abrir Claude desde el analisis — no hace falta lanzar `run-jupyter-lab.sh` aparte.
- **El venv del kernel es el del analisis** (`JUPYTER_MCP_VENV`), no `python/.venv` del repo. Asi cada analisis ejecuta con sus propias dependencias sin contaminar el venv canonico. Este fix nacio de un caso real (analisis `nats`): trabajar desde la raiz de `fn_registry` cargaba el MCP global (8899, venv `python/.venv`) que no tenia `nats-py`.
- **Reuso por puerto**: si ya hay un Jupyter escuchando en `JUPYTER_MCP_PORT` (p.ej. lanzado por `run-jupyter-lab.sh`, que es colaborativo), el wrapper lo reusa en vez de arrancar otro. Si no hay ninguno, el wrapper levanta uno propio (sin `--collaborative`, suficiente para el MCP). Para colaboracion humana en tiempo real, lanzar `run-jupyter-lab.sh` antes.
- **NUNCA `python -m jupyter_mcp_server.server`** — `server.py` no tiene bloque `__main__`; importa y sale 0, el MCP nunca arranca. El entrypoint real es el console-script `jupyter-mcp-server`, que el wrapper localiza dentro del venv del analisis.
- **Requiere `jupyter-mcp-server` instalado en el venv del analisis**: `uv pip install jupyter-mcp-server`. La funcion aborta si el console-script no existe.
- **Localiza el wrapper subiendo directorios** desde `project_dir` (hasta 8 niveles) buscando `bash/functions/infra/jupyter_mcp_serve.sh`; si no lo encuentra, usa `FN_REGISTRY_ROOT`. Aborta si no aparece por ninguna via.
- **Merge con jq usa `+` (shallow)** en el mapa de servidores para reemplazar la entrada `jupyter` entera; `*` (deep) dejaba keys huerfanas de configs viejas (p.ej. el bloque `args` del console-script directo).
## Capability growth log
- v1.2.0 (2026-06-03) — el `.mcp.json` generado usa el wrapper `jupyter_mcp_serve.sh` con env overrides (`JUPYTER_MCP_VENV/ROOT/PORT/TOKEN`) en vez del console-script directo. Garantiza que el MCP arranca su propio Jupyter con el venv del analisis (antes solo conectaba y usaba el venv equivocado si se abria Claude desde la raiz del repo). Declara dependencia `jupyter_mcp_serve_bash_infra`.
- v1.1.0 (2026-05-28) — fix comando roto: console-script `jupyter-mcp-server` + flags stdio en vez de `python -m ...server` + env vars. Merge `+` para reemplazar entrada entera. Tag `notebook`.
@@ -1,21 +1,32 @@
# write_mcp_jupyter_config
# -------------------------
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server.
# Usa el console-script `jupyter-mcp-server` del venv local con transport stdio
# y los flags --jupyter-url / --jupyter-token (NO env vars, NO `-m ...server`).
# Hace merge si ya existe .mcp.json (requiere jq).
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server para un
# analisis/proyecto. La entrada `jupyter` usa el wrapper `jupyter_mcp_serve.sh`
# (no el console-script directo), de modo que el MCP SIEMPRE tiene servidor: el
# wrapper arranca (o reusa) un Jupyter Lab en el puerto indicado usando el venv
# del propio analisis y lo engancha al MCP por stdio.
#
# Por que el wrapper y no el console-script directo: el console-script
# `jupyter-mcp-server --jupyter-url http://localhost:PORT` solo se CONECTA, no
# arranca Jupyter. Si el server no esta levantado, el MCP responde `initialize`
# pero no hay kernel y toda operacion sobre notebooks falla. El wrapper levanta el
# server con el venv correcto (JUPYTER_MCP_VENV) antes de exec del MCP, asi que
# abrir Claude desde el analisis basta — no hace falta lanzar run-jupyter-lab.sh
# aparte. Si ya hay un Jupyter en ese puerto (p.ej. run-jupyter-lab.sh), lo reusa.
#
# Env overrides que se inyectan al wrapper (ver jupyter_mcp_serve.sh):
# JUPYTER_MCP_VENV venv del analisis (su .venv, con jupyter + jupyter-mcp-server)
# JUPYTER_MCP_ROOT root de notebooks = directorio del analisis
# JUPYTER_MCP_PORT puerto del Jupyter gestionado
# JUPYTER_MCP_TOKEN token (vacio: solo escucha en 127.0.0.1)
#
# GOTCHA (2026-05-28): `python -m jupyter_mcp_server.server` NO arranca nada —
# server.py no tiene bloque __main__, asi que el proceso importa y sale 0 y el
# MCP nunca levanta. El entrypoint real es la CLI (`jupyter_mcp_server.CLI:server`,
# expuesta como console-script `jupyter-mcp-server`), que sin subcomando arranca
# en stdio por defecto. La config tampoco lee SERVER_URL/TOKEN: usa los flags
# --jupyter-url / --jupyter-token. El MCP tolera que Jupyter este apagado al
# arrancar (responde `initialize` tras un connect-timeout ~10s y sirve igual).
# server.py no tiene bloque __main__. El entrypoint real es el console-script
# `jupyter-mcp-server` (que el wrapper localiza dentro del venv del analisis).
#
# USO (sourced):
# source write_mcp_jupyter_config.sh
# write_mcp_jupyter_config /path/to/project 8888
# write_mcp_jupyter_config /path/to/analysis 8890
write_mcp_jupyter_config() {
local project_dir="${1:-.}"
@@ -31,23 +42,47 @@ write_mcp_jupyter_config() {
return 1
fi
# Verificar que el console-script esta instalado
# Verificar que el console-script esta instalado en el venv del analisis
if [ ! -x "$mcp_bin" ]; then
echo "write_mcp_jupyter_config: jupyter-mcp-server no instalado en el venv (${mcp_bin}). Instala con: uv pip install jupyter-mcp-server" >&2
return 1
fi
# Localizar el wrapper jupyter_mcp_serve.sh subiendo desde el directorio del
# analisis hasta la raiz del repo. Fallback a FN_REGISTRY_ROOT.
local wrapper="" d="$abs_project"
local i
for i in 1 2 3 4 5 6 7 8; do
if [ -f "$d/bash/functions/infra/jupyter_mcp_serve.sh" ]; then
wrapper="$d/bash/functions/infra/jupyter_mcp_serve.sh"
break
fi
d="$(dirname "$d")"
[ "$d" = "/" ] && break
done
if [ -z "$wrapper" ] && [ -n "${FN_REGISTRY_ROOT:-}" ] && [ -f "${FN_REGISTRY_ROOT}/bash/functions/infra/jupyter_mcp_serve.sh" ]; then
wrapper="${FN_REGISTRY_ROOT}/bash/functions/infra/jupyter_mcp_serve.sh"
fi
if [ -z "$wrapper" ]; then
echo "write_mcp_jupyter_config: no encuentro bash/functions/infra/jupyter_mcp_serve.sh subiendo desde ${abs_project} ni en FN_REGISTRY_ROOT" >&2
return 1
fi
local new_config
new_config=$(cat << EOF
{
"mcpServers": {
"jupyter": {
"command": "${mcp_bin}",
"command": "bash",
"args": [
"--transport", "stdio",
"--jupyter-url", "http://localhost:${port}",
"--jupyter-token", ""
]
"${wrapper}"
],
"env": {
"JUPYTER_MCP_VENV": "${abs_project}/.venv",
"JUPYTER_MCP_ROOT": "${abs_project}",
"JUPYTER_MCP_PORT": "${port}",
"JUPYTER_MCP_TOKEN": ""
}
}
}
}
@@ -57,7 +92,7 @@ EOF
if [ -f "$mcp_file" ] && command -v jq &>/dev/null; then
# Merge conservando otros servidores MCP. Usa `+` (shallow) en el mapa de
# servidores para REEMPLAZAR la entrada `jupyter` entera — `*` (deep) dejaba
# keys huerfanas de configs viejas (ej. bloque `env` obsoleto).
# keys huerfanas de configs viejas (ej. flags `args` obsoletos).
jq -s '.[0] * {mcpServers: ((.[0].mcpServers // {}) + (.[1].mcpServers // {}))}' \
"$mcp_file" <(echo "$new_config") > "${mcp_file}.tmp"
mv "${mcp_file}.tmp" "$mcp_file"
@@ -0,0 +1,67 @@
---
name: reset_chrome_profiles
kind: pipeline
lang: bash
domain: pipelines
version: "1.0.0"
purity: impure
signature: "reset_chrome_profiles --user-data-dir <dir> [--profile \"<dir>=<legible>\"]... [--backup-dir <dir>] [--base-port 9250] [--keep <ext_id>]... [--dry-run] [--yes]"
description: "Pipeline de reset destructivo de perfiles de Chromium: hace backup de los bookmarks de todos los perfiles, cierra el chromium que use ese user-data-dir, borra los perfiles (carpeta + Local State), los recrea (la managed policy reinstala la whitelist de extensiones uBlock + web_proxy), restaura los bookmarks y verifica que cada perfil quedó solo con la whitelist. DESTRUCTIVO: se pierden cookies, logins, historial y contraseñas; solo los bookmarks se preservan. Requiere --yes en modo real."
tags: [launcher, navegator, chromium, pipeline, profile, reset]
uses_functions:
- backup_chrome_bookmarks_bash_browser
- delete_chrome_profile_bash_browser
- create_chrome_profile_bash_browser
- restore_chrome_bookmarks_bash_browser
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: "--user-data-dir <dir>"
desc: "Raíz del user-data-dir de Chromium cuyos perfiles se resetean (ej. ~/.config/chromium-cdp)."
- name: "--profile <dir=legible>"
desc: "Perfil a resetear, formato carpeta=nombre-legible (repetible). Default los 4 reales: Default=Work, Personal=Personal, 'Profile 1'=Aurgi, Automation=Automation."
- name: "--backup-dir <dir>"
desc: "Directorio donde se guardan los backups de bookmarks. Default ~/.local/share/web_scraping/bookmarks-backups."
- name: "--base-port <N>"
desc: "Puerto CDP base para recrear perfiles (cada perfil usa base+i). Default 9250."
- name: "--keep <ext_id>"
desc: "ID de extensión esperada tras el reset (repetible). Default uBlock Origin Lite + web_proxy toggle. Solo se usa en la verificación final."
- name: "--dry-run"
desc: "Previsualiza los 6 pasos sin tocar el sistema."
- name: "--yes"
desc: "Confirma la operación destructiva (obligatorio en modo real)."
output: "Ejecuta backup → cerrar chromium → delete → create → restore → verify. Emite el progreso de cada paso y un resumen. Sale 0 si todo OK y cada perfil quedó solo con la whitelist; != 0 si falla algún paso o la verificación detecta extensiones fuera de la whitelist."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/pipelines/reset_chrome_profiles.sh"
---
## Ejemplo
```bash
# Previsualizar el reset de los 4 perfiles del chromium diario (no toca nada)
fn run reset_chrome_profiles --user-data-dir "$HOME/.config/chromium-cdp" --dry-run
# Reset real (destructivo): backup bookmarks, borrar+recrear los 4 perfiles, restaurar bookmarks
fn run reset_chrome_profiles --user-data-dir "$HOME/.config/chromium-cdp" --yes
# Reset de un solo perfil con nombre legible
fn run reset_chrome_profiles --user-data-dir "$HOME/.config/chromium-cdp" \
--profile "Automation=Automation" --yes
```
## Cuando usarla
Cuando quieras dejar los perfiles de un Chromium **limpios desde cero** conservando solo la whitelist de extensiones (uBlock + la de captura del web_proxy) y preservando los bookmarks, pero descartando todo el resto del estado (cookies, logins, historial). Útil para volver a un estado conocido de scraping/captura o para limpiar perfiles contaminados. La managed policy de `/etc` ya fuerza la whitelist, así que los perfiles recreados nacen correctos.
## Gotchas
- **DESTRUCTIVO**: cookies, logins, historial y contraseñas de los perfiles se pierden de forma irreversible. Solo los bookmarks se preservan (backup + restore byte a byte). Por eso requiere `--yes` en modo real.
- **Cierra el chromium del user-data-dir indicado** (pkill por `--user-data-dir`), no cualquier chromium. Si tienes otro chromium con otro user-data-dir, no se toca.
- **Depende de la managed policy**: los perfiles recreados solo tendrán uBlock + web_proxy si la policy de `/etc/chromium/policies/managed/extensions.json` las fuerza (ver `apply_chromium_extension_policy_bash_browser`). Si la policy no está, los perfiles nacen sin extensiones.
- La verificación final comprueba las carpetas en `<profile>/Extensions/`; para una auditoría detallada (nombre, versión, enabled, fromPolicy) usar `list_chrome_profile_extensions_go_browser`.
- Lanzar chromium desde el Bash tool da exit-144; `create_chrome_profile` usa `systemd-run --user` internamente para evitarlo.
@@ -0,0 +1,216 @@
#!/usr/bin/env bash
# reset_chrome_profiles — Pipeline de reset destructivo de perfiles de Chromium.
#
# Compone funciones del registry para: hacer backup de los bookmarks de todos los perfiles,
# cerrar chromium, borrar los perfiles (carpeta + entradas en Local State), recrearlos
# (la managed policy reinstala la whitelist de extensiones: uBlock + web_proxy), restaurar
# los bookmarks y verificar que cada perfil quedó solo con la whitelist.
#
# DESTRUCTIVO: borra cookies, logins, historial y contraseñas de los perfiles. Solo los
# bookmarks se preservan (backup + restore). Requiere --yes en modo real (o --dry-run).
#
# Uso:
# reset_chrome_profiles --user-data-dir <dir>
# [--profile "<dir>=<legible>"]... [--backup-dir <dir>] [--base-port 9250]
# [--keep <ext_id>]... [--dry-run] [--yes]
#
# Defaults de --profile (los 4 perfiles reales): "Default=Work" "Personal=Personal"
# "Profile 1=Aurgi" "Automation=Automation".
# Default de --keep (whitelist esperada tras el reset): uBlock Origin Lite + web_proxy toggle.
reset_chrome_profiles() {
local _udd="" _backup_dir="${HOME}/.local/share/web_scraping/bookmarks-backups"
local _base_port=9250 _dry_run=0 _yes=0
local -a _profiles=()
local -a _keep=()
while [[ $# -gt 0 ]]; do
case "$1" in
--user-data-dir) _udd="$2"; shift 2 ;;
--profile) _profiles+=("$2"); shift 2 ;;
--backup-dir) _backup_dir="$2"; shift 2 ;;
--base-port) _base_port="$2"; shift 2 ;;
--keep) _keep+=("$2"); shift 2 ;;
--dry-run) _dry_run=1; shift ;;
--yes) _yes=1; shift ;;
-h|--help)
grep '^#' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; return 0 ;;
*) echo "reset_chrome_profiles: argumento desconocido: $1" >&2; return 1 ;;
esac
done
if [[ -z "$_udd" ]]; then
echo "reset_chrome_profiles: --user-data-dir es obligatorio" >&2; return 1
fi
if [[ ${#_profiles[@]} -eq 0 ]]; then
_profiles=("Default=Work" "Personal=Personal" "Profile 1=Aurgi" "Automation=Automation")
fi
if [[ ${#_keep[@]} -eq 0 ]]; then
_keep=("ddkjiahejlhfcafbddmgiahcphecmpfh" "nanldmckabfghgdebblpfbdbhphhbnde")
fi
# Localizar las funciones del registry que componemos.
local _dir _root _browser
_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_root="$(cd "$_dir/../../.." && pwd)"
_browser="$_root/bash/functions/browser"
local _f
for _f in backup_chrome_bookmarks restore_chrome_bookmarks delete_chrome_profile create_chrome_profile; do
if [[ ! -f "$_browser/$_f.sh" ]]; then
echo "reset_chrome_profiles: falta función $_f en $_browser" >&2; return 1
fi
# shellcheck disable=SC1090
source "$_browser/$_f.sh"
done
echo "=== reset_chrome_profiles ==="
echo " user-data-dir : $_udd"
echo " perfiles : ${_profiles[*]}"
echo " whitelist ext : ${_keep[*]}"
echo " backup-dir : $_backup_dir"
echo " modo : $([[ $_dry_run -eq 1 ]] && echo DRY-RUN || echo REAL)"
echo ""
# Confirmación obligatoria en modo real.
if [[ $_dry_run -eq 0 && $_yes -eq 0 ]]; then
echo "reset_chrome_profiles: operación DESTRUCTIVA (se pierden cookies/logins/historial)." >&2
echo " Repite con --yes para confirmar, o usa --dry-run para previsualizar." >&2
return 3
fi
# ── [1/6] Backup de bookmarks (solo lee; chromium puede estar abierto) ──────
echo "[1/6] Backup de bookmarks..."
local _bk_json _ts_dir
if [[ $_dry_run -eq 1 ]]; then
backup_chrome_bookmarks --user-data-dir "$_udd" --backup-dir "$_backup_dir" --dry-run
_ts_dir="<dry-run>"
else
_bk_json="$(backup_chrome_bookmarks --user-data-dir "$_udd" --backup-dir "$_backup_dir")" || {
echo "reset_chrome_profiles: backup falló" >&2; return 1; }
echo "$_bk_json"
_ts_dir="$(printf '%s' "$_bk_json" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["backup_dir"]+"/"+d["ts"])')"
echo " backup en: $_ts_dir"
fi
echo ""
# ── [2/6] Cerrar chromium que tenga ESTE user-data-dir abierto ─────────────
echo "[2/6] Cerrando chromium con --user-data-dir=$_udd ..."
if [[ $_dry_run -eq 1 ]]; then
echo " (dry-run: no se cierra nada)"
else
# Por-PID con comm=chromium (pgrep -x) para no auto-matchear grep/pgrep (el path del udd
# contiene la cadena "chromium").
local _p _kpids _i=0
_kpids=""
for _p in $(pgrep -x chromium 2>/dev/null); do
tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_udd" && _kpids="$_kpids $_p"
done
if [[ -n "${_kpids// }" ]]; then
# shellcheck disable=SC2086
kill -TERM $_kpids 2>/dev/null || true
while :; do
_kpids=""
for _p in $(pgrep -x chromium 2>/dev/null); do
tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_udd" && _kpids="$_kpids $_p"
done
[[ -z "${_kpids// }" ]] && break
_i=$((_i+1)); [[ $_i -ge 20 ]] && { kill -9 $_kpids 2>/dev/null || true; break; }
sleep 0.5
done
echo " chromium cerrado."
else
echo " (no había chromium con ese user-data-dir)"
fi
fi
echo ""
# ── [3/6] Borrar perfiles (carpeta + Local State) ──────────────────────────
echo "[3/6] Borrando perfiles..."
local _del_args=() _pair _pdir
for _pair in "${_profiles[@]}"; do
_pdir="${_pair%%=*}"
_del_args+=(--profile "$_pdir")
done
if [[ $_dry_run -eq 1 ]]; then
delete_chrome_profile --user-data-dir "$_udd" "${_del_args[@]}" --dry-run
else
delete_chrome_profile --user-data-dir "$_udd" "${_del_args[@]}" || {
echo "reset_chrome_profiles: delete falló" >&2; return 1; }
fi
echo ""
# ── [4/6] Recrear perfiles (la policy reinstala la whitelist al arrancar) ───
echo "[4/6] Recreando perfiles..."
local _idx=0 _name _port
for _pair in "${_profiles[@]}"; do
_pdir="${_pair%%=*}"; _name="${_pair#*=}"; _port=$((_base_port + _idx))
if [[ $_dry_run -eq 1 ]]; then
create_chrome_profile --user-data-dir "$_udd" --profile "$_pdir" --name "$_name" --port "$_port" --dry-run
else
create_chrome_profile --user-data-dir "$_udd" --profile "$_pdir" --name "$_name" --port "$_port" || {
echo "reset_chrome_profiles: create de '$_pdir' falló" >&2; return 1; }
fi
_idx=$((_idx+1))
done
echo ""
# ── [5/6] Restaurar bookmarks ──────────────────────────────────────────────
echo "[5/6] Restaurando bookmarks..."
if [[ $_dry_run -eq 1 ]]; then
echo " (dry-run: restauraría desde el backup recién creado)"
else
restore_chrome_bookmarks --user-data-dir "$_udd" --backup-dir "$_ts_dir" || {
echo "reset_chrome_profiles: restore falló (continúo a verify)" >&2; }
fi
echo ""
# ── [6/6] Verificar extensiones por perfil (carpetas en Extensions/) ───────
echo "[6/6] Verificando extensiones (esperado: solo la whitelist)..."
if [[ $_dry_run -eq 1 ]]; then
echo " (dry-run: verificaría que cada perfil tiene solo ${_keep[*]})"
echo ""
echo "reset_chrome_profiles: DRY-RUN completado, nada se modificó."
return 0
fi
local _ok=1
for _pair in "${_profiles[@]}"; do
_pdir="${_pair%%=*}"
local _extdir="$_udd/$_pdir/Extensions"
local -a _present=()
if [[ -d "$_extdir" ]]; then
local _e
for _e in "$_extdir"/*/; do
_e="$(basename "$_e")"
[[ "$_e" == "Temp" || "$_e" == "*" ]] && continue
_present+=("$_e")
done
fi
# Comprobar que todo lo presente está en la whitelist.
local _extra=()
local _id _found
for _id in "${_present[@]}"; do
_found=0
local _k
for _k in "${_keep[@]}"; do [[ "$_id" == "$_k" ]] && _found=1; done
[[ $_found -eq 0 ]] && _extra+=("$_id")
done
if [[ ${#_extra[@]} -gt 0 ]]; then
echo "$_pdir: extensiones fuera de whitelist: ${_extra[*]}"
_ok=0
else
echo "$_pdir: ${_present[*]:-<vacío, aún sin arrancar>}"
fi
done
echo ""
if [[ $_ok -eq 1 ]]; then
echo "reset_chrome_profiles: OK — perfiles recreados, bookmarks restaurados, solo la whitelist presente."
return 0
else
echo "reset_chrome_profiles: verificación con avisos (revisar arriba)." >&2
return 1
fi
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
reset_chrome_profiles "$@"
fi
+74 -6
View File
@@ -1,8 +1,10 @@
package main
import (
"bytes"
"database/sql"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
@@ -56,8 +58,19 @@ func cmdRun(args []string) {
os.Exit(1)
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// When fn run executes a scoped `go test -run`, mirror its output into a
// buffer so we can detect a "no tests to run" result — which go test reports
// with exit 0 and would otherwise be a silent false-green (e.g. the extracted
// unit_tests names drifted from the code). See issue 0167.
guardGoTest := fn.Lang == "go" && isGoTestRun(cmd)
var outBuf bytes.Buffer
if guardGoTest {
cmd.Stdout = io.MultiWriter(os.Stdout, &outBuf)
cmd.Stderr = io.MultiWriter(os.Stderr, &outBuf)
} else {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
cmd.Stdin = os.Stdin
fmt.Fprintf(os.Stderr, "[fn run] %s (%s/%s) %s\n", fn.ID, fn.Lang, fn.Kind, strings.Join(passArgs, " "))
@@ -66,6 +79,13 @@ func cmdRun(args []string) {
runErr := cmd.Run()
durationMs := time.Since(t0).Milliseconds()
// A scoped go test that matched zero tests is a false-green: treat as failure.
if guardGoTest && runErr == nil && strings.Contains(outBuf.String(), "no tests to run") {
fmt.Fprintf(os.Stderr, "\n[fn run] error: -run no encontro ningun test para %s — los nombres de test extraidos no existen en el codigo; corre 'fn index'\n", fn.ID)
logFnRunTelemetry(registryRoot, fn.ID, durationMs, false, "no_tests_run")
os.Exit(1)
}
exitCode := 0
errClass := ""
if runErr != nil {
@@ -140,7 +160,7 @@ func resolveFunction(db *registry.DB, idOrName string) (*registry.Function, erro
func buildCommand(fn *registry.Function, db *registry.DB, registryRoot, absPath string, args []string) (*exec.Cmd, error) {
switch fn.Lang {
case "go":
return buildGoCommand(fn, registryRoot, absPath, args)
return buildGoCommand(fn, db, registryRoot, absPath, args)
case "py":
return buildPyRunnerCommand(fn, db, registryRoot, args)
case "bash":
@@ -154,7 +174,7 @@ func buildCommand(fn *registry.Function, db *registry.DB, registryRoot, absPath
}
}
func buildGoCommand(fn *registry.Function, registryRoot, absPath string, args []string) (*exec.Cmd, error) {
func buildGoCommand(fn *registry.Function, db *registry.DB, registryRoot, absPath string, args []string) (*exec.Cmd, error) {
dir := filepath.Dir(absPath)
env := append(os.Environ(), "CGO_ENABLED=1")
@@ -168,13 +188,23 @@ func buildGoCommand(fn *registry.Function, registryRoot, absPath string, args []
return cmd, nil
}
// Library code: if it has tests → go test
// Library code with tests → go test, but scoped to THIS function's tests via
// -run, so a flaky test of a sibling function in the same package does not
// break `fn run`. Test names come from the indexer-extracted unit_tests table
// (parsed from the real .go, reliable), never the .md frontmatter (can drift).
// The cmdRun guard fails the run if -run matches zero tests, preventing a
// silent "no tests to run" false-green. See issue 0167.
if fn.Tested && fn.TestFilePath != "" {
testAbs := filepath.Join(registryRoot, fn.TestFilePath)
if _, err := os.Stat(testAbs); err == nil {
relPkg, _ := filepath.Rel(registryRoot, dir)
pkgPath := "./" + filepath.ToSlash(relPkg)
cmdArgs := append([]string{"test", "-v", "-count=1", "-tags", "fts5", pkgPath}, args...)
cmdArgs := []string{"test", "-v", "-count=1", "-tags", "fts5"}
if names := goTestNames(db, fn.ID); len(names) > 0 {
cmdArgs = append(cmdArgs, "-run", "^("+strings.Join(names, "|")+")$")
}
cmdArgs = append(cmdArgs, pkgPath)
cmdArgs = append(cmdArgs, args...)
cmd := exec.Command("go", cmdArgs...)
cmd.Dir = registryRoot
cmd.Env = env
@@ -193,6 +223,44 @@ func buildGoCommand(fn *registry.Function, registryRoot, absPath string, args []
return cmd, nil
}
// goTestNames returns the top-level Go test function names registered for fn in
// the indexer-extracted unit_tests table. These drive `go test -run` so that
// `fn run` only executes the function's own tests, isolating it from flaky tests
// of sibling functions in the same package. Returns nil if none are known (db is
// nil, lookup fails, or no tests extracted), in which case the caller falls back
// to running the whole package.
func goTestNames(db *registry.DB, functionID string) []string {
if db == nil {
return nil
}
uts, err := db.GetUnitTestsByFunction(functionID)
if err != nil {
return nil
}
var names []string
for _, ut := range uts {
if ut.Name != "" {
names = append(names, ut.Name)
}
}
return names
}
// isGoTestRun reports whether cmd is a `go test ... -run ...` invocation, used to
// enable the zero-tests-matched guard in cmdRun.
func isGoTestRun(cmd *exec.Cmd) bool {
var hasTest, hasRun bool
for _, a := range cmd.Args {
switch a {
case "test":
hasTest = true
case "-run":
hasRun = true
}
}
return hasTest && hasRun
}
func buildBashCommand(absPath string, args []string) (*exec.Cmd, error) {
cmdArgs := append([]string{absPath}, args...)
+33
View File
@@ -169,6 +169,39 @@ Para diagnosticar un diff: revisar el PNG actual en
`cpp/build/tests/visual_actual/<demo>.png` vs el golden en
`cpp/tests/golden/<demo>.png`.
### Tests de UI headless (Dear ImGui Test Engine)
`fn::run_app_test` (el harness del Test Engine usado por `/e2e-cpp`) crea la
ventana GLFW **oculta por defecto** (`GLFW_VISIBLE=FALSE`). El contexto OpenGL
real se crea igual, así que el render que el Test Engine ejercita sigue siendo
fiel, pero la ventana nunca se mapea en pantalla: cero parpadeo y no roba foco
mientras corre la suite. Es el comportamiento preferente para tests de
frontend en C++.
Control del modo (en orden de prioridad):
| Mecanismo | Efecto |
|---|---|
| `FN_HEADLESS=0` (env) | Fuerza ventana **visible** — para depurar un test a ojo. |
| `FN_HEADLESS=1` (env) | Fuerza oculta (es el default del path de test). |
| `cfg.headless = true` | Oculta también `fn::run_app` (apps reales, p.ej. smoke/capture). |
| sin nada | `run_app_test` → oculta; `run_app` → visible. |
Cómo correr la suite sin parpadeo:
```bash
# Host con GL nativo (GPU real): binario directo, ventana oculta, sin parpadeo.
./build/linux_tests/apps/<app>/<app>_tests
# CI / WSL sin display: display virtual en RAM (también headless).
xvfb-run -a -s "-screen 0 1280x800x24" \
env LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe \
./build/linux_tests/apps/<app>/<app>_tests
# Ver un test a ojo (desactiva headless):
FN_HEADLESS=0 ./build/linux_tests/apps/<app>/<app>_tests
```
### CI gate `check_tested.sh`
`cpp/scripts/check_tested.sh [days]` (default `30`) consulta `registry.db` y
+32
View File
@@ -23,6 +23,7 @@
#include <atomic>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <string>
@@ -647,6 +648,25 @@ static void draw_header_badge_on_floating_panels(const AppConfig& cfg) {
}
}
// Resuelve si la ventana GLFW debe crearse oculta (GLFW_VISIBLE=FALSE).
// default_hidden : politica base del path de entrada (apps reales = false,
// tests de UI = true).
// config_headless: AppConfig.headless explicito de la app.
// El entorno FN_HEADLESS gana sobre ambos: "0"/"false" fuerza visible,
// cualquier otro valor no vacio fuerza oculta. Sin la variable, se respeta
// default_hidden || config_headless.
static bool resolve_headless(bool default_hidden, bool config_headless) {
bool hidden = default_hidden || config_headless;
if (const char* e = std::getenv("FN_HEADLESS")) {
if (std::strcmp(e, "0") == 0 || std::strcmp(e, "false") == 0) {
hidden = false;
} else if (e[0] != '\0') {
hidden = true;
}
}
return hidden;
}
int run_app(AppConfig config, std::function<void()> render_fn) {
// Logger primero para capturar fallos del propio init (GLFW, ventana, GL).
if (config.log.file_path != nullptr) {
@@ -672,6 +692,11 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
// Apps reales: ventana visible por defecto. Solo se oculta si la app pide
// headless o el entorno FN_HEADLESS lo fuerza (smoke/capture sin parpadeo).
const bool hidden = resolve_headless(/*default_hidden=*/false, config.headless);
glfwWindowHint(GLFW_VISIBLE, hidden ? GLFW_FALSE : GLFW_TRUE);
GLFWwindow* window = glfwCreateWindow(config.width, config.height, config.title, nullptr, nullptr);
if (!window) {
fprintf(stderr, "Failed to create GLFW window\n");
@@ -1178,6 +1203,13 @@ int run_app_test(AppConfig config,
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
// Tests de frontend: ventana OCULTA por defecto (headless) para no parpadear
// en la pantalla del desarrollador ni robar foco mientras el Test Engine
// ejercita la UI. El contexto GL real se crea igual, asi que el render sigue
// siendo fiel. Opt-out para depurar visualmente: FN_HEADLESS=0.
const bool hidden = resolve_headless(/*default_hidden=*/true, config.headless);
glfwWindowHint(GLFW_VISIBLE, hidden ? GLFW_FALSE : GLFW_TRUE);
GLFWwindow* window = glfwCreateWindow(
config.width, config.height,
config.title ? config.title : "fn_test", nullptr, nullptr);
+15
View File
@@ -101,6 +101,21 @@ struct AppConfig {
int height = 720;
bool vsync = true;
bool viewports = true; // Multi-viewport ON por defecto: ventanas ImGui arrastrables fuera del main window
// Headless: si true, la ventana GLFW se crea oculta (GLFW_VISIBLE=FALSE).
// El contexto OpenGL real se sigue creando y el render ocurre offscreen,
// por lo que las pruebas visuales y de UI siguen siendo fieles, pero la
// ventana nunca se mapea en pantalla (cero parpadeo, no roba foco).
//
// Politica por path:
// - run_app (apps reales): default visible (headless = false).
// - run_app_test (Dear ImGui Test Engine): default OCULTA. Los tests de
// frontend corren headless salvo opt-out explicito para debug visual.
//
// Override por entorno (gana sobre el default del path y sobre este flag):
// FN_HEADLESS=1 / true -> fuerza ventana oculta.
// FN_HEADLESS=0 / false -> fuerza ventana visible (ej. ver un test).
bool headless = false;
ThemeMode theme = ThemeMode::FnDark; // Identidad visual unificada por defecto
float bg_r = 0.102f; // fn_tokens::colors::bg (dark.7 #1A1B1E)
float bg_g = 0.106f;
@@ -0,0 +1,162 @@
---
id: "0167"
title: "fn run de library function Go ejecuta go test del paquete entero (arrastra tests flaky vecinos)"
status: completado
type: enhancement
domain:
- registry-quality
scope: registry-only
priority: media
depends: []
blocks: []
related: ["0077"]
created: 2026-06-03
updated: 2026-06-03
tags: [fn-run, go, testing, flaky, dag-engine, reliability]
---
# 0167 — fn run de library function Go ejecuta go test del paquete entero
## APP Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0167 |
| **Estado** | pendiente |
| **Prioridad** | media |
| **Tipo** | enhancement — dispatcher de `fn run` |
## Contexto
Cuando `fn run <id>` recibe una **library function Go sin `main.go`** que tiene tests
declarados (`tested: true` + `test_file_path`), el dispatcher (`cmd/fn/run.go:171-181`)
ejecuta:
```
go test -v -count=1 -tags fts5 ./functions/<domain> # el PAQUETE ENTERO
```
Es decir, no ejecuta "la función" (no se puede: no tiene `main`), sino que corre **todos
los tests del paquete**. Consecuencia: el éxito de `fn run miFuncion` depende de que pasen
los tests de **todas las demás funciones del mismo paquete**, no solo los suyos.
### Cómo se manifestó
Los DAGs `daily-registry-audit` y `weekly-deep-scan` del `dag_engine` invocaban funciones
`*_go_infra` (`find_unused_functions`, `artefact_doctor`, etc.) como `function:` steps.
Cada step disparaba `go test ./functions/infra` (paquete completo), que contiene tests
impuros con recursos fijos:
- `TestSSHTunnelOpenClose``bind [127.0.0.1]:19876: Address already in use`
- `TestDockerContainerExec``listen unix .../docker_exec_test.sock: bind: invalid argument` (path de socket > 108 chars con TMPDIR largo)
Al correr dos `function:` steps en paralelo (ambos `depends` del mismo padre), las dos
invocaciones de `go test ./functions/infra` colisionaban en el **puerto fijo 19876**
una pasaba y la otra fallaba de forma no determinista. Resultado: el DAG fallaba sin
auditar nada, y el fallo parecía "la auditoría encontró un problema" cuando en realidad
era un test de red vecino.
> Nota: el síntoma operativo en los DAGs ya se resolvió por otra vía (2026-06-03): los
> steps ahora usan `audit_doctor_snapshot_bash_infra` (Bash), que ejecuta `fn doctor <sub>`
> real en vez de `go test` del paquete. Este issue es la **causa raíz general** del
> dispatcher, que sigue afectando a cualquier `fn run <library_go_fn_con_tests>`.
## Problema
1. `fn run` de una library function NO ejecuta la función — corre el paquete de test entero.
2. Los tests impuros de un paquete (puertos/sockets/red fijos) no son seguros para
ejecuciones concurrentes ni reproducibles en cualquier entorno (TMPDIR, CI).
3. Un único test flaky en `functions/infra` rompe `fn run` de las ~N funciones testeadas
del paquete, y por extensión cualquier DAG/cron que las invoque.
## Opciones de solución (decidir en implementación)
### Opción A — library Go sin main → siempre compile-check (`go vet`/`go build`)
`fn run <lib_fn>` significa "verifica que la función va"; para código sin `main` eso es
"compila". Testear es responsabilidad de `go test` / CI, no de `fn run` en un cron.
- **Pro**: determinista, rápido, elimina el flaky de raíz.
- **Contra**: rompe el comportamiento documentado en `CLAUDE.md` ("`fn run filter_slice_go_core`
→ Go function con tests → `go test -v`"). Perderíamos la capacidad de correr los tests de
una función vía `fn run`.
### Opción B — go test acotado con `-run` a los tests de la función
Si la función declara sus tests, ejecutar solo esos:
```
go test -v -count=1 -tags fts5 -run '^(TestX|TestY)$' ./functions/<domain>
```
- **Pro**: aísla del flaky vecino manteniendo "fn run corre mis tests".
- **Contra / RIESGO**: si los nombres de `fn.Tests` (frontmatter YAML, `registry/parser.go:32`)
tienen **drift** respecto al código, `-run` no matchea y `go test` sale 0 con
"no tests to run" → **falso-verde** en una primitiva crítica de todo el ecosistema.
Mitigación obligatoria si se elige B: reconciliar `fn.Tests` con los tests extraídos por
el indexer (`registry/test_parser.go::parseGoTests`, que ya puebla `unit_tests`) y/o
detectar "0 tests ejecutados" parseando el output y tratarlo como fallo.
### Opción C — aislar los tests impuros del paquete
Hacer robustos los tests culpables: puerto efímero (`:0` en vez de `19876`), socket en path
corto bajo `/tmp` con nombre acotado, `t.Parallel`-safe. No cambia el dispatcher pero reduce
la probabilidad de colisión.
- **Pro**: no toca `fn run` (cero blast radius sistémico).
- **Contra**: no resuelve el problema conceptual (sigue corriendo el paquete entero); otros
paquetes pueden introducir tests impuros nuevos y reincidir.
## Recomendación
Combinar **C** (saneamiento inmediato de `TestSSHTunnelOpenClose` y `TestDockerContainerExec`,
bajo riesgo) con **B** endurecida (acotar `-run` + guard anti-falso-verde apoyado en
`unit_tests` extraídos, no en el frontmatter manual). La Opción A es la más limpia
conceptualmente pero rompe comportamiento documentado; evaluar si ese comportamiento
("fn run corre los tests") aún se usa de verdad o puede deprecarse hacia `go test` directo.
## Definition of Done
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|---|---|---|---|
| Golden: `fn run` de library fn testeada | e2e | `./fn run find_unused_functions_go_infra` | exit 0 sin depender de tests de funciones vecinas |
| Edge: dos `fn run` concurrentes del mismo paquete | e2e | dos invocaciones en paralelo de funciones de `functions/infra` | ambas exit 0, sin colisión de puerto/socket |
| Error: nombres de test con drift (si se elige B) | unit | `fn.Tests` con un nombre inexistente | NO produce falso-verde (se detecta "0 tests run" → fallo) |
| Tests impuros saneados | unit | `go test -run 'TestSSHTunnelOpenClose\|TestDockerContainerExec' ./functions/infra` repetido 5× | 5/5 PASS deterministas |
## Resolución (2026-06-03)
Implementada la combinación **C + B** recomendada.
### C — Tests impuros saneados (`functions/infra/`)
- `ssh_tunnel_test.go`: el puerto fijo `19876` pasa a **puerto efímero** (`freeTCPPort` pide `:0` al kernel). Elimina el `bind: address already in use` bajo concurrencia.
- `docker_container_exec_test.go`: el socket Unix deja de colgar de `t.TempDir()` (path largo con el nombre del subtest) y usa un **directorio corto** bajo `/tmp` (`os.MkdirTemp("/tmp", "dk")` + cleanup). Elimina el `bind: invalid argument` por exceder los ~108 bytes de `sun_path`.
- Verificado: `go test -run 'TestSSHTunnelOpenClose|TestDockerContainerExec' -count=5 ./functions/infra/``ok` (5×, determinista).
### B — `fn run` acota los tests a la función (`cmd/fn/run.go`)
- Para una library Go function con tests, el dispatcher ahora añade
`-run '^(<tests>)$'` con los nombres **extraídos por el indexer** (`unit_tests`,
vía `db.GetUnitTestsByFunction`), no los del frontmatter `.md` (que pueden driftar).
Así `fn run` ejecuta solo los tests de esa función, aislándola de tests flaky de
funciones vecinas del mismo paquete. Si no hay nombres extraídos, cae al paquete
entero (comportamiento previo).
- **Guard anti-falso-verde**: `cmdRun` refleja el output de un `go test -run` a un
buffer; si go test reporta `no tests to run` (que sale con exit 0), el run se trata
como **fallo** (exit 1 + mensaje pidiendo `fn index`). Evita que un drift de nombres
produzca un verde silencioso.
### Evidencia (DoD)
| Escenario | Resultado |
|---|---|
| Golden: `fn run find_unused_functions_go_infra` | Corre solo sus 2 tests (`TestFindUnusedFunctions_*`) en 0.06s, exit 0. No toca SSH/Docker. |
| Edge concurrente: 2 `fn run` del paquete `infra` en paralelo | Ambos exit 0, sin colisión de puerto. |
| Error/drift: `unit_tests` con nombre inexistente | `go test` da `[no tests to run]`; el guard lo intercepta → exit 1 con mensaje. NO falso-verde. |
| Tests saneados 5× | `ok` determinista. |
`go vet ./cmd/fn/` y `go test ./cmd/fn/` verdes tras los cambios.
## Notas
- Archivos clave: `cmd/fn/run.go` (dispatcher, líneas 145-194), `registry/parser.go`
(campo `Tests`), `registry/test_parser.go` (extracción de nombres de test),
`functions/infra/ssh_tunnel_open_close_test.go` y `functions/infra/docker_container_exec_test.go`
(tests culpables).
- Relacionado con 0077 (fn-run-bash-output-mudo): familia de issues sobre la semántica y
observabilidad de `fn run`.
+71
View File
@@ -0,0 +1,71 @@
# ADR 0005 — Mantener el `.git` del repo padre ligero: no trackear artefactos hijos, purgar basura del historial, submódulos shallow
- **Fecha:** 2026-06-03
- **Estado:** accepted
## Contexto
El `.git` del repo padre `fn_registry` había crecido a **475 MB**, un tamaño que ralentiza clones, `fn sync` y la operación diaria entre los tres PCs del ecosistema (`aurgi-pc`, `home-wsl`, `lucas-linux`). El diagnóstico identificó tres causas independientes, todas evitables:
1. **Artefactos hijos forzados al índice.** Pese a que el `.gitignore` ya tiene las reglas correctas (`apps/*/`, `analysis/*/`, `projects/*/`), un `.gitignore` no des-trackea archivos que ya estaban en el índice. Dos apps tenían contenido forzado: `apps/dag_engine/` (31 archivos: código Go + frontend + `app.md` + `README.md`) y `apps/shaders_lab/` (`app.md` + un binario `shaders_lab.exe`). El commit `d8db05e9` ("chore(dag_engine): app.md ... metadata trackeada por el padre") había trackeado dag_engine deliberadamente; esta decisión queda **anulada**. La convención correcta ya estaba demostrada por `projects/*/apps` (p.ej. `registry_dashboard`, `call_monitor`), que el padre no versiona en absoluto y funcionan bien.
2. **Basura en el historial.** Versiones antiguas de directorios que nunca debieron versionarse seguían viviendo en los commits pasados: `frontend/node_modules` (168 MB de binarios), `build/` raíz (54 MB de artefactos de compilación C++, 2299 archivos), `registry.db` (29 MB en ~7 versiones; regenerable con `fn index`) y `apps/shaders_lab/shaders_lab.exe` (~190 MB acumulados en ~10 versiones). En total ~440 MB de blobs muertos.
3. **Submódulos C++ con historia completa.** `cpp/vendor/{imgui,implot,implot3d,tracy,glfw,sdl3}` + `emsdk` son submódulos git legítimos (deps necesarias para compilar las apps imgui). Cada uno clonaba **toda la historia upstream**: `.git/modules` pesaba 338 MB para servir un working tree de 118 MB. imgui solo: 129 MB de `.git` (11.552 commits) para 8.9 MB de headers; sdl3: 146 MB (21.539 commits) para 55 MB de código. El proyecto compila contra **un único commit pinneado** por submódulo — el resto es historia ajena que nadie consulta.
## Decisión
Mantener el `.git` del padre ligero con tres medidas:
1. **El repo padre NO versiona el contenido de los artefactos hijos.** Cada app/analysis/project-app es un sub-repo Gitea independiente con su propio `.git` (ADR 0002); el padre solo conserva su metadata en `registry.db` (regenerable con `fn index`, que lee los artefactos del disco). Se sacan del índice con `git rm -r --cached` (con `--cached` SIEMPRE — sin él se borraría el working tree de los sub-repos). Único contenido versionado bajo `apps/` y `analysis/`: los marcadores `.gitkeep`. Bajo `projects/`: solo los `project.md`.
2. **El historial pasado se purga de basura con `git filter-repo`.** Se eliminan los blobs de `frontend/node_modules`, `build/` (raíz), `registry.db` y `apps/shaders_lab/shaders_lab.exe` de todos los commits. Esto reescribe la historia (cambian los SHAs) y requiere `git push --force`. Se añade `build/` (raíz) al `.gitignore` para evitar reincidencia (`node_modules`, `*.exe` y `registry.db` ya estaban).
3. **Los submódulos C++ se configuran shallow (`depth 1`).** Cada submódulo descarga solo el commit pinneado, no la historia upstream. Se marca `shallow = true` en `.gitmodules` para que los clones futuros nazcan shallow. El working tree mantiene el snapshot completo de cada dependencia, así que la compilación C++ no cambia.
## Cómo se ejecutó (2026-06-03)
```bash
# 1. Untrack del índice (los archivos quedan en disco; los .git de los sub-repos conservan el código)
git rm -r --cached apps/dag_engine apps/shaders_lab
git commit -m "chore: untrack contenido de artefactos hijos (dag_engine, shaders_lab)"
# 2. Purga del historial (con git-filter-repo, descargado standalone)
python3 git-filter-repo --strip-blobs-bigger-than 10M --force
python3 git-filter-repo --invert-paths --path frontend/node_modules --path build \
--path registry.db --path apps/dag_engine --path apps/shaders_lab --force
git remote add origin <url> # filter-repo elimina el remote por seguridad
git push --force origin master
# 3. Submódulos shallow (deinit + borrar el .git/modules full + re-clone --depth 1)
for sm in cpp/vendor/{sdl3,imgui,tracy,glfw,implot,implot3d} emsdk; do
git submodule deinit -f "$sm"
rm -rf ".git/modules/$sm" # clave: deinit NO borra .git/modules
git -c "submodule.$sm.shallow=true" submodule update --init --depth 1 "$sm"
done
sed -i '/^\turl = /a\\tshallow = true' .gitmodules
git commit -m "chore: submodulos C++ en modo shallow (depth 1)" && git push origin master
```
Resultado: `.git` **475 MB → 51 MB** (89%). Desglose: `.git/objects` 137 MB → 16 MB (historial del registry limpio); `.git/modules` 338 MB → 35 MB (submódulos shallow). `cpp/vendor` en disco intacto (118 MB). `cmake configure` de `cpp/` OK con las deps shallow. Backup completo del `.git` pre-purga en `~/backups/fn_registry_purge_20260603/`.
## Alternativas descartadas
- **Solo `git rm --cached` sin purgar el historial.** Detiene el crecimiento futuro pero deja los ~440 MB de basura en los commits pasados. No reduce el `.git`. Insuficiente para el objetivo.
- **Purgar solo `shaders_lab.exe`.** Mismo coste (force-push + re-clone en otros PCs) por mucha menos ganancia: deja `node_modules`, `build/` y `registry.db` en el historial.
- **Borrar o purgar los submódulos C++.** Son deps legítimas necesarias para compilar las apps imgui. Purgarlas rompería la compilación. La vía correcta es shallow, no eliminación.
- **`git filter-branch` en vez de `git-filter-repo`.** Más lento, deja `refs/original` y es propenso a errores. `filter-repo` es la herramienta recomendada por el propio git.
## Consecuencias
- **Force-push reescribe la historia del padre.** Los otros PCs (`aurgi-pc`, `home-wsl`) quedan con historia divergente y deben re-sincronizar: `git fetch origin && git reset --hard origin/master && git submodule update --init --recursive`. Trabajo local del padre sin pushear se pierde — verificar antes. Esta es la única parte irreversible y outward-facing; requiere confirmación humana explícita.
- **Shallow es local y reversible.** No toca el repo padre ni los gitlinks; solo adelgaza el `.git` interno de cada submódulo. Reversible por dep con `git fetch --unshallow`. El `.gitmodules` con `shallow = true` hace que los clones frescos nazcan ligeros; los clones existentes deben re-aplicar el `deinit + rm .git/modules/<x> + update --depth 1` o re-clonar.
- **Bumpear una dep shallow cuesta un `git fetch --depth 1 <commit>` extra** antes del checkout, porque el commit nuevo no está en el clon mínimo. Es fricción, no bloqueo.
- **Se pierde `git log/blame/bisect` dentro de los submódulos** (la historia de SDL3/imgui), algo que casi nunca se hace en deps vendored.
- **Operación repetible.** Si el `.git` vuelve a crecer por basura, el procedimiento de este ADR (untrack + filter-repo + shallow) es el runbook.
## Relación con otras reglas y ADRs
- [ADR 0002](0002-apps-analyses-as-dataforge-master.md) — apps/analyses como sub-repos `dataforge/<name>`. Este ADR refuerza su corolario: el padre no versiona su contenido.
- `.claude/rules/apps_subrepo.md` — gotcha de pérdida de código en worktrees; misma raíz (artefactos = sub-repos independientes).
- `.claude/rules/db_locations.md``registry.db` solo en la raíz y regenerable; por eso es purgable del historial.
+1
View File
@@ -62,3 +62,4 @@ Qué se aprendió después. Útil cuando un ADR se supersede.
| [0002](0002-apps-analyses-as-dataforge-master.md) | Apps y analyses como sub-repos `dataforge/<name>` con branch master | accepted |
| [0003](0003-orphan-tu-as-separate-function-entry.md) | TU adicional de un parent function como entrada propia | accepted |
| [0004](0004-telemetry-driven-capability-growth.md) | Telemetria de ejecuciones de Claude como motor de crecimiento del registry | accepted |
| [0005](0005-keep-parent-git-lean.md) | Mantener el `.git` del padre ligero: no trackear artefactos hijos, purgar historial, submódulos shallow | accepted |
+3 -1
View File
@@ -23,7 +23,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [nlp](nlp.md) | 33 | Extraccion NLP: PDFs, OCR, chunking, GLiNER/GLiREL, dedup, agregacion de entities/relations |
| [docker](docker.md) | 38 | Operar Docker desde Go/Bash: build/run/stop, compose, networks, volumes, logs, deploys |
| [android](android.md) | 37 | Toolbelt Android desde WSL2: adb, emuladores AVD, APK build/install, Capacitor, logcat |
| [web-proxy](web-proxy.md) | 4 | Captura de trafico HTTP/HTTPS liviana (mitmproxy): proxy con rotacion, navegador proxeado, consulta de capturas. Alternativa ligera a ZAP/Burp |
| [web-proxy](web-proxy.md) | 5 | Captura de trafico HTTP/HTTPS liviana (mitmproxy): proxy con rotacion, navegador proxeado, consulta de capturas, tee del SSE de claude. Alternativa ligera a ZAP/Burp |
| [metabase](metabase.md) | 106 | Operar Metabase via API REST: auth, cards, dashboards, collections, snippets, permissions |
| [doctor](doctor.md) | 11 | Diagnostico read-only del registry: artefactos, servicios, drift, funciones huerfanas |
| [notebook](notebook.md) | 5 | Operar Jupyter Lab colaborativo (discover/read/exec/write/kernel) |
@@ -45,6 +45,8 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [wireguard](wireguard.md) | 7 | Instalar, configurar, operar y monitorizar mesh WireGuard hub-and-spoke: keygen, hub setup, peer add/revoke, status JSON |
| [matrix-mas](matrix-mas.md) | 5 | Migración Synapse→MAS: habilitar MSC3861, verificar login flows, parche .well-known, registro clientes OAuth2, syn2mas |
| [mesh-3d](mesh-3d.md) | 3 | Carga y upload a GPU de meshes 3D (OBJ, GLB/glTF 2.0): loaders CPU + mesh_gpu_upload OpenGL |
| [terminal-capture](terminal-capture.md) | 6 | Automatizar y capturar el texto de una CLI/TUI interactiva via PTY headless: spawn+input scripteado (one-shot y streaming), render del layout 2D (emulador VT), strip ANSI, delta por prefijo, y parseo de la TUI de claude a datos |
| [claude-direct](claude-direct.md) | 3 | Hablar directamente con la API de Anthropic Messages usando el token OAuth de Claude Code (Claude Max): leer token, stream SSE, bucle agentico de tool-use |
## Como anadir grupo
+8 -4
View File
@@ -1,12 +1,14 @@
# Capability: android
Toolbelt Android operable desde WSL2. Cubre: ADB (`adb_wsl`, conexion al daemon Windows), AVD emulator management (list/start/stop/wait, geo-fix), APK lifecycle (`android_apk_install`, `android_app_clear`, `android_app_launch`, `android_uninstall`), Capacitor build pipelines (`capacitor_build_apk`, `deploy_capacitor_to_emulator`), logcat streaming. WSL2 -> Windows adb daemon, no requiere Android Studio.
Toolbelt Android **Linux-first** (con fallback WSL2 legacy). Cubre: ADB (`adb_wsl` resuelve el adb nativo del SDK), AVD emulator management (list/start/stop/wait, geo-fix), APK lifecycle (`android_apk_install`, `android_app_clear`, `android_app_launch`, `android_uninstall`), Capacitor build pipelines (`capacitor_build_apk`, `deploy_capacitor_to_emulator`), build Gradle nativo (`gradle_*`, `init_kotlin_app`, `run_kotlin_app_tests`), logcat streaming. Usa el SDK nativo en `~/android-sdk` (via `install_android_sdk`); el adb/emulator de Windows solo se usa como fallback cuando se detecta WSL2.
Design system Compose: las apps Kotlin nativas (`init_kotlin_app`) heredan `FnTheme` + `FnTokens` del módulo `kotlin/functions/ui` (`fn.compose:ui`), con la paleta exacta de Mantine v9 dark + indigo (misma que web `@fn_library` y C++ `fn_tokens`).
## Funciones
| ID | Firma | Que hace |
|---|---|---|
| `adb_wsl_bash_infra` | `source adb_wsl.sh [ADB=<path>] [ANDROID_SDK_WIN=<sdk_root>]` | Wrapper sourceable para usar adb.exe Windows desde WSL2. Resuelve binario, convierte paths, espera boot del emulador. |
| `adb_wsl_bash_infra` | `source adb_wsl.sh [ADB=<path>] [ANDROID_HOME=<sdk_root>]` | Wrapper sourceable para resolver e invocar adb. Linux-first: usa el adb nativo del Android SDK ($ANDROID_HOME) o del PATH; fallback a adb.exe solo si detecta WSL2. Expone adb_run, adb_devices, adb_pick_serial, adb_s, adb_wait_boot. |
| `android_apk_install_bash_infra` | `android_apk_install([--serial S], apk_path: string, package_name?: string, activity_name?: string) -> void` | Instala APK en device/emulador via adb y opcionalmente lanza la app. Multi-emulator via --serial. |
| `android_app_clear_bash_infra` | `android_app_clear([--serial <S>], package: string) -> void` | Wipe app data + cache via pm clear. App keeps installed but factory-state. Multi-emulator via --serial. |
| `android_app_info_bash_infra` | `android_app_info([--serial <S>], package, [--json]) -> stdout` | Inspect installed app: version, target SDK, activities via dumpsys package. |
@@ -16,8 +18,8 @@ Toolbelt Android operable desde WSL2. Cubre: ADB (`adb_wsl`, conexion al daemon
| `android_emu_battery_bash_infra` | `android_emu_battery([--serial <S>], level: int, [--charging <true\|false>]) -> void` | Simulate battery state on emulator (level + charging). Emulator-only. |
| `android_emu_geo_fix_bash_infra` | `android_emu_geo_fix([--serial <S>], longitude: string, latitude: string, [altitude: string]) -> void` | Fake GPS location on Android emulator via emu geo fix. Emulator-only (not physical devices). |
| `android_emu_rotate_bash_infra` | `android_emu_rotate([--serial <S>] [portrait\|landscape\|0\|90\|180\|270])` | Rotate emulator screen. Empty=toggle, or fixed orientation. Locks autorotate. |
| `android_emulator_list_bash_infra` | `android_emulator_list([--json])` | Lista los AVDs disponibles invocando emulator.exe Windows desde WSL2. |
| `android_emulator_start_bash_infra` | `android_emulator_start(avd_name: string, timeout_s: int) -> string` | Arranca un AVD en background y espera a que termine de bootear. Idempotente: si ya hay emulador corriendo no lanza otro. |
| `android_emulator_list_bash_infra` | `android_emulator_list([--json])` | Lista los AVDs disponibles. Linux-first: usa el emulator nativo del Android SDK ($ANDROID_HOME); fallback a emulator.exe solo bajo WSL2. |
| `android_emulator_start_bash_infra` | `android_emulator_start(avd_name: string, timeout_s: int) -> string` | Arranca un AVD Android en background y espera a que termine de bootear. Linux-first: resuelve el emulator/adb nativos del SDK; fallback a binarios .exe solo bajo WSL2. Idempotente: si ya hay un emulador corriendo, imprime 'already running' y su serial sin lanzar otro. |
| `android_emulator_stop_bash_infra` | `android_emulator_stop(serial?: string) -> void` | Para uno o todos los emuladores Android via adb emu kill. Si serial esta vacio, detecta todos los emulator-* activos y los para. Idempotente: exit 0 aunque no haya nada que matar. |
| `android_input_keyevent_bash_infra` | `android_input_keyevent([--serial <S>] key: string)` | Send key event via adb shell input keyevent. Accepts aliases (BACK, HOME, POWER, ENTER, MENU, RECENT_APPS, VOLUME_UP, VOLUME_DOWN), raw numeric codes, or explicit KEYCODE_* names. |
| `android_input_swipe_bash_infra` | `android_input_swipe([--serial <S>], x1: int, y1: int, x2: int, y2: int, [duration_ms: int])` | Send swipe gesture between two points with duration. |
@@ -31,6 +33,8 @@ Toolbelt Android operable desde WSL2. Cubre: ADB (`adb_wsl`, conexion al daemon
| `android_shell_bash_infra` | `android_shell([--serial <S>], cmd ...args)` | Execute arbitrary shell command on Android device. Multi-emulator via --serial. |
| `capacitor_build_apk_bash_pipelines` | `capacitor_build_apk(web_app_dir: string, [app_id: string], [app_name: string]) -> void` | Pipeline que convierte una web app en un APK de Android usando Capacitor. Valida el entorno (ANDROID_HOME, Java 17+), construye el bundle web si no existe dist/, inicializa Capacitor si no está configurado, añade la plataforma Android, sincroniza y compila el APK con Gradle. El APK final queda en el directorio raíz de la web app. |
| `deploy_capacitor_to_emulator_bash_pipelines` | `deploy_capacitor_to_emulator(app_dir: string, avd_name?: string, package_name?: string) -> void` | Pipeline end-to-end: build Capacitor APK + arranca AVD + instala + opcionalmente lanza la app. Valida que el AVD existe, construye el APK con capacitor_build_apk, arranca el emulador de forma idempotente, instala el APK y lanza la app si se da package_name. Imprime comando logcat sugerido al final. |
| `fn_theme_kt_ui` | `@Composable fun FnTheme(darkMode: Boolean = true, content: @Composable () -> Unit)` | Provider raiz del design system Compose del registry (@fn_compose). Envuelve MaterialTheme con un ColorScheme derivado de FnColors (Mantine v9 dark + indigo). Dark por defecto, mirror de FnMantineProvider (web) y fn::run_app ThemeMode::FnDark (C++). Toda app del registry envuelve su contenido en FnTheme. |
| `fn_tokens_kt_ui` | `object FnTokens { colors; spacing; radius; typography; shadows }` | Design tokens del design system Compose del registry (@fn_compose). Paleta heredada exacta (mismos hex) de cpp/DESIGN_SYSTEM.md / Mantine v9 dark + indigo: FnColors, FnSpacing (Dp), FnRadius (Dp), FnTypography (sp + weights), FnShadows (Dp). Fuente unica de valores visuales para apps Android del registry. |
| `gradle_assemble_debug_bash_infra` | `gradle_assemble_debug(project_dir: string, module: string) -> string` | Build APK debug de un modulo Android via gradlew assembleDebug. |
| `gradle_clean_bash_infra` | `gradle_clean(project_dir: string) -> int` | Limpia build artifacts de un proyecto Android (gradle clean + rm .gradle + rm build). |
| `gradle_instrumented_test_bash_infra` | `gradle_instrumented_test(project_dir: string, module: string) -> int` | Corre instrumented tests Compose en emulador/device Android conectado. |
+91
View File
@@ -0,0 +1,91 @@
# Capability: claude-direct
Hablar directamente con `https://api.anthropic.com/v1/messages` usando el token OAuth de Claude Code (Claude Max), sin lanzar la CLI `claude` ni necesitar una API key de pago separada. 3 funciones Python en `domain: core`.
## Funciones
| ID | Firma | Que hace |
|---|---|---|
| `load_claude_oauth_token_py_core` | `def load_claude_oauth_token(credentials_path: str = "", refresh_if_expired: bool = True) -> str` | Lee el access token OAuth desde `~/.claude/.credentials.json`. Verifica expiry (ms-epoch). Intenta refresh best-effort si expirado. |
| `stream_anthropic_messages_py_core` | `def stream_anthropic_messages(messages: list, model: str = "claude-opus-4-8", ...) -> Iterator[dict]` | POST streaming a `/v1/messages`. Yield de eventos normalizados: `text`, `tool_use_start`, `tool_input_delta`, `done`, `error`. Parser SSE puro testeable por separado. |
| `run_claude_tool_loop_py_core` | `def run_claude_tool_loop(messages, tools, dispatch, ...) -> dict` | Bucle agentico tool-use. Llama `stream_anthropic_messages` en loop, despacha tools via `dispatch{name: callable}`, anade `tool_result`, repite hasta `end_turn` o `max_iters`. |
## Ejemplo canonico end-to-end
### Pregunta simple (sin tools)
```python
import sys
sys.path.insert(0, "python/functions/core")
from stream_anthropic_messages import stream_anthropic_messages
text = ""
for event in stream_anthropic_messages(
messages=[{"role": "user", "content": "di solo PONG"}],
model="claude-haiku-4-5-20251001",
max_tokens=32,
):
if event["type"] == "text":
text += event["text"]
print(event["text"], end="", flush=True)
elif event["type"] == "done":
print(f"\n[stop={event['stop_reason']}]")
# Output: PONG
# [stop=end_turn]
```
### Bucle agentico con tool propia
```python
import sys
sys.path.insert(0, "python/functions/core")
from run_claude_tool_loop import run_claude_tool_loop
from datetime import datetime
tools = [
{
"name": "get_time",
"description": "Devuelve la hora actual en formato HH:MM:SS.",
"input_schema": {"type": "object", "properties": {}, "required": []},
}
]
dispatch = {
"get_time": lambda _inp: datetime.now().strftime("%H:%M:%S"),
}
result = run_claude_tool_loop(
messages=[{"role": "user", "content": "que hora es exactamente ahora?"}],
tools=tools,
dispatch=dispatch,
model="claude-haiku-4-5-20251001",
on_text=lambda d: print(d, end="", flush=True),
)
print(f"\n[iters={result['iterations']} stop={result['stop_reason']}]")
# Claude llama a get_time() -> "14:32:07"
# Luego responde: "Ahora son las 14:32:07."
```
### Solo leer el token (para uso manual)
```python
import sys
sys.path.insert(0, "python/functions/core")
from load_claude_oauth_token import load_claude_oauth_token
token = load_claude_oauth_token(refresh_if_expired=False)
# Pasar como header: {"authorization": f"Bearer {token}"}
```
## Fronteras
- **NO cubre** el flujo de refresh OAuth (endpoint no documentado publicamente) — el refresh es best-effort y puede fallar silenciosamente.
- **NO es un cliente completo** de la API de Anthropic: solo `/v1/messages` con streaming. Files, embeddings, etc. quedan fuera.
- **NO reemplaza** el uso de API keys oficiales para produccion — este grupo es exclusivamente para uso local del token OAuth de Claude Max.
- **NO gestiona rate limits** — el caller debe manejar errores `{"type": "error"}` con `429` en el mensaje.
## Prerequisitos
- Claude Code instalado y usuario logueado (`~/.claude/.credentials.json` debe existir).
- `httpx` disponible en el venv: `python/.venv/bin/python3 -c "import httpx"`.
- Token fresco (Claude Code normalmente lo renueva en background mientras esta abierto).
+97
View File
@@ -0,0 +1,97 @@
---
group: e2e-messaging
description: "Criptografía extremo a extremo para bus de mensajería: identidades duales Ed25519/X25519, distribución de claves de sala con sealed box anónimo, cifrado simétrico AEAD por mensaje, y firma/verificación de mensajes."
functions:
- generate_identity_go_cybersecurity
- seal_aead_go_cybersecurity
- open_aead_go_cybersecurity
- seal_key_box_go_cybersecurity
- open_key_box_go_cybersecurity
- sign_ed25519_go_cybersecurity
- verify_ed25519_go_cybersecurity
---
## Funciones del grupo
| ID | Firma corta | Qué hace |
|---|---|---|
| `generate_identity_go_cybersecurity` | `GenerateIdentity() (Identity, error)` | Genera par Ed25519 (firma) + par X25519 (kex) para un participante |
| `seal_aead_go_cybersecurity` | `SealAEAD(key, plaintext, aad []byte) (nonce, ct []byte, err error)` | Cifra mensaje con ChaCha20-Poly1305, nonce aleatorio por llamada |
| `open_aead_go_cybersecurity` | `OpenAEAD(key, nonce, ct, aad []byte) ([]byte, error)` | Descifra y autentica; error explícito si el tag falla |
| `seal_key_box_go_cybersecurity` | `SealKeyBox(recipientKexPub, secret []byte) ([]byte, error)` | Cifra room key para un destinatario con su X25519 pubkey (sealed box anónimo) |
| `open_key_box_go_cybersecurity` | `OpenKeyBox(kexPub, kexPriv, sealedMsg []byte) ([]byte, error)` | Abre sealed box con el par X25519 propio para recuperar la room key |
| `sign_ed25519_go_cybersecurity` | `SignEd25519(priv, msg []byte) []byte` | Firma determinista Ed25519 (pura, sin I/O) |
| `verify_ed25519_go_cybersecurity` | `VerifyEd25519(pub, msg, sig []byte) bool` | Verifica firma Ed25519 (pura, sin I/O) |
## Ejemplo canónico end-to-end
```go
package main
import (
"fmt"
"log"
cs "fn-registry/functions/cybersecurity"
)
func main() {
// 1. Cada participante genera su identidad una sola vez
server, err := cs.GenerateIdentity()
if err != nil { log.Fatal(err) }
user, err := cs.GenerateIdentity()
if err != nil { log.Fatal(err) }
// 2. Servidor genera room key y la distribuye al usuario cifrada
roomKey := make([]byte, 32)
// ... llenar roomKey con crypto/rand en producción ...
sealed, err := cs.SealKeyBox(user.KexPub, roomKey)
if err != nil { log.Fatal(err) }
// 3. Usuario recupera la room key
gotKey, err := cs.OpenKeyBox(user.KexPub, user.KexPriv, sealed)
if err != nil { log.Fatal(err) }
// 4. Usuario cifra un mensaje con la room key
aad := []byte("room:sala-general:seq:1")
nonce, ct, err := cs.SealAEAD(gotKey, []byte("hola sala"), aad)
if err != nil { log.Fatal(err) }
// 5. Usuario firma el ciphertext para autenticar autoría
sig := cs.SignEd25519(user.SignPriv, ct)
// 6. Receptor verifica firma y descifra
if !cs.VerifyEd25519(user.SignPub, ct, sig) {
log.Fatal("firma inválida")
}
plain, err := cs.OpenAEAD(gotKey, nonce, ct, aad)
if err != nil { log.Fatal(err) }
fmt.Printf("recibido: %s\n", plain)
_ = server // server.SignPub publicado en directorio de participantes
}
```
## Fronteras
Este grupo cubre las primitivas criptográficas del bus, no el protocolo completo:
- **No cubre**: transporte (WebSocket, gRPC), gestión de sesiones, ratchet de claves (doble ratchet), persistencia de identidades, revocación de claves.
- **No cubre**: cifrado de archivos adjuntos (usar SealAEAD directamente con una key derivada).
- **No reemplaza**: libsodium ni libolm para implementaciones de producción de Signal/Matrix — estas funciones son el sustrato criptográfico, no el protocolo completo.
## Prerequisitos
- `golang.org/x/crypto` ya en `go.mod` (presente en fn-registry).
- `crypto/ed25519` de stdlib (Go 1.13+).
- Identidades persistidas de forma segura (keyring, HSM, archivo cifrado): este grupo no gestiona almacenamiento.
## Patrón de uso recomendado
```
GenerateIdentity() → persiste Identity por participante
SealKeyBox(kexPub, roomKey) → distribuye room key al unirse a sala
OpenKeyBox(kexPub, kexPriv) → recupera room key
SealAEAD(roomKey, msg, aad) → cifra cada mensaje
SignEd25519(signPriv, ct) → autentica autoría sobre ciphertext
VerifyEd25519(signPub, ct) → verifica antes de descifrar
OpenAEAD(roomKey, nonce, ct)→ descifra mensaje verificado
```
+86
View File
@@ -0,0 +1,86 @@
# terminal-capture
Automatizar una CLI/TUI interactiva y capturar su texto, de forma headless, a través de un
pseudo-terminal (PTY). Cubre el ciclo completo: lanzar el proceso con un TTY real, inyectarle
input scripteado, esperar a que el render se estabilice, y convertir el stream crudo de bytes a
texto plano — bien reconstruyendo el layout 2D (TUIs con cursor absoluto), bien limpiando ANSI
de output secuencial.
Existe porque muchas CLIs (sobre todo la CLI `claude`) solo entran en su modo interactivo rico
cuando detectan un TTY; un pipe normal las degrada. El PTY es virtual, en memoria: **nunca abre
una ventana de terminal**.
## Funciones
| ID | Firma | Qué hace |
|---|---|---|
| `pty_capture_idle_go_infra` | `func PTYCaptureIdle(ctx, name string, args []string, warmup time.Duration, inputs []string, stepDelay, idle, maxDur time.Duration) (string, error)` | Lanza `name args` en un PTY (40×120), espera `warmup`, escribe cada `inputs` separado por `stepDelay`, y captura todos los bytes hasta que pasa `idle` sin output nuevo o se alcanza `maxDur`. Devuelve el stream **crudo** (ANSI intacto). One-shot. |
| `pty_capture_stream_go_infra` | `func PTYCaptureStream(ctx, name string, args []string, warmup time.Duration, inputs []string, stepDelay, snapshotInterval, idle, maxDur time.Duration) (<-chan string, error)` | Igual que `pty_capture_idle` pero emite **snapshots acumulativos** del buffer por un canal cada `snapshotInterval` — para hacer streaming de la TUI mientras renderiza. El consumidor renderiza/parsea cada snapshot. |
| `text_prefix_delta_go_core` | `func PrefixDelta(prev, curr string) string` | Devuelve la parte de `curr` que sigue al prefijo común con `prev` (delta de streaming por snapshots). Pura, compara por runas. Heurística ante reflow. |
| `vt_render_go_tui` | `func VTRender(raw string, rows, cols int) string` | Emula un terminal VT100 de `rows×cols`, alimenta `raw`, y devuelve el estado final de la pantalla como texto plano **con el layout reconstruido** (espacios reales donde el stream tenía movimientos de cursor). Pura. |
| `strip_ansi_go_core` | `func StripANSI(s string) string` | Elimina secuencias ANSI/VT100 y caracteres de control de un stream **secuencial** (logs), preservando `\n`, `\t`, `\r`. Pura. NO reconstruye layout 2D. |
| `parse_claude_tui_go_tui` | `func ParseClaudeTUI(screen string) ClaudeTUIParse` | Parsea la pantalla renderizada de la TUI de `claude` (salida de `vt_render`) y extrae los turnos (user/assistant/tool_use/tool_result) + la respuesta final (`Answer`), equivalente a lo que devolvería `claude -p`. Pura, heurística, específica de la TUI de claude. |
## Cuándo usar cada limpiador
El corazón del grupo es `pty_capture_idle` (la captura). Lo que cambia es cómo conviertes el raw a texto:
| Si la salida es… | Usa | Porque |
|---|---|---|
| Una TUI con posicionamiento absoluto (`claude`, `htop`, `dialog`) | `vt_render_go_tui` (modo screen) | Los "espacios" entre columnas eran movimientos de cursor; sin emular el grid las palabras se pegan (`2newMCPservers`). |
| Output secuencial línea a línea (logs, builds) | `strip_ansi_go_core` (modo stream) | No hay layout 2D que reconstruir; basta quitar los escape codes. |
| Quieres procesar los escape codes tú mismo | (ninguno — usa el raw) | El raw de `pty_capture_idle` ya los conserva. |
## Ejemplo canónico (end-to-end)
Capturar la respuesta de la CLI `claude` como texto con layout, en Go:
```go
import (
"context"
"time"
"fn-registry/functions/infra"
"fn-registry/functions/tui"
)
func main() {
ctx := context.Background()
// Teclear el prompt y pulsar Enter como pasos separados: un "\r" pegado al
// texto lo trata claude como newline literal, no como submit.
inputs := []string{"resume el README en 3 lineas", "\r"}
raw, _ := infra.PTYCaptureIdle(ctx, "claude", nil,
4*time.Second, // warmup: deja cargar la TUI
inputs, 600*time.Millisecond,
4*time.Second, // idle: corta tras 4s de silencio
60*time.Second) // maxDur: tope duro
screen := tui.VTRender(raw, 40, 120) // reconstruye el layout 2D
print(screen)
}
```
La app `claude_extract` (`apps/claude_extract`) empaqueta exactamente este flujo como CLI, con
modos `screen|stream|raw`, `--exec` para pipear a otro proceso, y `--cwd` para saltar el diálogo
de arranque de claude. Es el consumidor de referencia del grupo.
La app `claude_pipe` (`apps/claude_pipe`) va un paso más allá: añade `parse_claude_tui_go_tui`
al final del pipeline para devolver la respuesta de claude **como dato** con el mismo shape que
`claude -p --output-format json` (`--format json|text|turns`). Es la alternativa "parsea la TUI"
a `claude -p`, para cuando se quiere expresamente ir a través de la TUI en vez del stream-json.
## Fronteras
- **No es `claude -p`**: este grupo captura la TUI real (lo que se ve). Para interacción programática
limpia con la CLI `claude`, usa `claude_stream_go_core` (`claude -p --output-format stream-json`).
- **Linux/Unix only**: PTY POSIX (`creack/pty`). No Windows.
- **Sin color**: `vt_render` reconstruye texto y layout, no atributos de color.
- **Idle es heurístico**: TUIs con render periódico (spinners, relojes) no disparan el idle y caen
al `maxDur`. Para `claude` el spinner se detiene al terminar la respuesta, así que corta bien.
- **Dimensiones fijas 40×120**: el render debe usar el mismo tamaño que la captura o el wrapping no
cuadra.
## Notas
- Las dos funciones de limpieza son **puras**; solo `pty_capture_idle` es impura (lanza procesos).
Puras en los bordes, impura en el centro de la captura.
- `pty_capture_idle` no fija el cwd del hijo: para controlarlo, cambia el cwd del proceso que la
invoca antes de llamarla (lo que hace `claude_extract --cwd`).
+1
View File
@@ -12,6 +12,7 @@ Filtro MCP: `mcp__registry__fn_search query="" tag="web-proxy"`.
| [rotate_capture_flows_py_cybersecurity](../../python/functions/cybersecurity/rotate_capture_flows.md) | `mitmdump -s rotate_capture_flows.py --set rotate_min=N --set capture_dir=DIR` | Addon de mitmproxy que trocea las capturas en archivos `traffic-YYYYmmdd-HHMMSS.mitm` por ventanas de tiempo. Hace `flush()` por flujo, asi que la captura sobrevive a un `kill -9`. |
| [query_mitm_flows_bash_cybersecurity](../../bash/functions/cybersecurity/query_mitm_flows.md) | `query_mitm_flows <file_or_glob> [--filter EXPR] [--har OUT]` | Consulta capturas `.mitm` guardadas: vuelca los flujos que matchean un filtro de mitmproxy, o exporta a HAR. Acepta globs de varios archivos. |
| [launch_chromium_proxy_bash_browser](../../bash/functions/browser/launch_chromium_proxy.md) | `launch_chromium_proxy [--proxy URL] [--profile DIR] [--url URL]` | Lanza Chromium apuntando al proxy con un perfil aislado, sin contaminar la sesion normal. Maneja el CA del proxy o cae a `--ignore-certificate-errors`. |
| [tee_anthropic_sse_py_cybersecurity](../../python/functions/cybersecurity/tee_anthropic_sse.md) | `mitmdump -s tee_anthropic_sse.py` | Addon mitmproxy que intercepta el SSE de `POST api.anthropic.com/v1/messages` (la respuesta del modelo de la CLI claude) y emite el texto exacto token a token como NDJSON. Filtra la respuesta principal (`has_tools`) de las auxiliares (titulo/clasificador en haiku). Strip de `Accept-Encoding` para ver el SSE sin comprimir. Lo consume `apps/claude_wire`. |
Complementa: `port_kill_bash_infra` (limpieza de puertos ocupados).
+9
View File
@@ -0,0 +1,9 @@
# 2026-06-03
## 00:54 — Limpieza del repo padre + dag_engine v0.3.0
- Hecho: dag_engine v0.3.0 — acciones por DAG (pausar via tabla `dag_state`, eliminar con recarga del scheduler), vista Timeline (ejecuciones con barra de duracion), buscador de DAGs, tooltip de cron estilo crontab.guru (funcion nueva `describe_cron_expr_go_core` + endpoint `/api/cron/describe`), formato de fecha europeo DD/MM/AAAA, y fix del mismatch de casing snake_case entre el store y el frontend. Pusheado al sub-repo (ef91c3d). Daemon systemd-user sirviendo React + API en :4200.
- Hecho: DAG de prueba `hola-mundo` (echo "hola mundo"), validado y ejecutado OK.
- Hecho: Limpieza del `.git` del repo padre — ver ADR [0005](../adr/0005-keep-parent-git-lean.md). Tres medidas: (1) untrack de artefactos hijos forzados al indice (`apps/dag_engine` 31 archivos + `apps/shaders_lab` incl. .exe) con `git rm --cached`, revierte el commit d8db05e9; (2) purga del historial con `git-filter-repo` (frontend/node_modules 168MB, build/ 54MB, registry.db regenerable 29MB, shaders_lab.exe ~190MB en ~10 versiones); (3) submodulos C++ de `cpp/vendor` en modo shallow (depth 1), `.gitmodules` marcado `shallow = true`.
- Resultado: `.git` 475MB -> 51MB (-89%). Codigo intacto (cpp/vendor 118MB en disco), `cmake configure` de cpp/ OK. Commits c5fb6ca (force-push tras purga) + ba5d262 (shallow). Backup completo en `~/backups/fn_registry_purge_20260603/`.
- Pendiente: los otros 2 PCs (aurgi-pc, home-wsl) deben re-sincronizar tras el force-push: `git fetch origin && git reset --hard origin/master && git submodule update --init --recursive`. Trabajo local del padre sin pushear se perderia — verificar antes. Para que sus submodulos tambien adelgacen, re-clonar fresco o re-aplicar el shallow por submodulo.
+44
View File
@@ -0,0 +1,44 @@
---
title: <objetivo de la sesion en una frase>
artefacto: <app|analysis|project|registry|none> · <ruta relativa si aplica>
created: DD/MM/AAAA HH:mm
updated: DD/MM/AAAA HH:mm
status: in_progress
related_issues: []
related_flows: []
---
## Objetivo
<que se quiere conseguir, condiciones de done. Una o dos lineas. Esto fija el alcance.>
## Pendiente
- [ ] 1. <tarea> — <nota / dependencia>
- [ ] 2. <tarea>
- [ ] 3. <tarea>
## En curso
- [~] <tarea> — <progreso actual, donde se quedo, siguiente paso concreto>
## Hecho
- [x] <tarea>
- resultado: <que produjo / como se verifico>
- enlace: <url, path de archivo, id de funcion, hash de commit>
## Enlaces
- <descripcion> — <url o path>
## Issues / flows relacionados
- issue <NNNN> — <titulo> — <estado>
- flow <slug> — <titulo> — <estado>
## Notas
- <decision tomada y por que>
- <bloqueo / pregunta abierta>
- <contexto que no cabe en el codigo>
+66
View File
@@ -0,0 +1,66 @@
---
id: cdp_activate_tab_go_browser
name: cdp_activate_tab
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Pone una pestaña Chrome en foreground (foco) por su ID via GET /json/activate/<id>. Sin WebSocket — solo HTTP. Útil para traer al frente una pestaña específica antes de capturar pantalla o interactuar con ella."
tags: [cdp, browser, tabs, navegator]
signature: "func CdpActivateTab(host string, port int, tabID string) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_list_tabs.go"
example: |
tabs, _ := browser.CdpListTabs("localhost", 9222)
// Activar la primera pestaña cuyo título contenga "Dashboard"
for _, t := range tabs {
if strings.Contains(t.Title, "Dashboard") {
_ = browser.CdpActivateTab("localhost", 9222, t.ID)
break
}
}
params:
- name: host
desc: "Hostname de la instancia Chrome (vacío = localhost)"
- name: port
desc: "Puerto CDP de remote debugging (habitualmente 9222)"
- name: tabID
desc: "ID de la pestaña a activar, obtenido de CdpTab.ID via CdpListTabs"
output: "nil si la pestaña pasó a foreground correctamente; error si tabID está vacío, la conexión falla o Chrome devuelve status != 200"
---
## Ejemplo
```go
// Listar tabs y traer al frente la que corresponda a una URL concreta
tabs, err := browser.CdpListTabs("localhost", 9222)
if err != nil {
log.Fatal(err)
}
for _, t := range tabs {
if t.URL == "https://metabase.local/dashboard/1" {
if err := browser.CdpActivateTab("localhost", 9222, t.ID); err != nil {
log.Printf("error activando tab %s: %v", t.ID, err)
}
break
}
}
```
## Cuando usarla
Antes de hacer un screenshot o interactuar via CDP con una pestaña concreta que podría estar en segundo plano. También útil en dashboards que muestran el inventario de pestañas y necesitan enfocar una al hacer clic.
## Gotchas
- No requiere conexión WebSocket previa; usa HTTP puro contra `/json/activate/<id>`.
- Solo cambia el foco dentro del contexto CDP; si la ventana de Chrome está minimizada a nivel de OS, `activate` la pone como pestaña activa dentro de Chrome pero no restaura la ventana.
- El ID debe obtenerse de `CdpListTabs` — no es el índice visible del tab, es el UUID interno de Chrome.
- Si el tabID no existe, Chrome devuelve un status HTTP distinto de 200 y la función retorna error.
+12
View File
@@ -0,0 +1,12 @@
package browser
// CdpClearCookies borra TODAS las cookies del browser via Network.clearBrowserCookies.
// Equivalente a "Borrar datos de navegacion > Cookies" en Chrome.
// Cierra todas las sesiones activas — usar solo en tests o resets completos.
func CdpClearCookies(c *CDPConn) error {
if _, err := c.sendCDP("Network.enable", nil); err != nil {
return err
}
_, err := c.sendCDP("Network.clearBrowserCookies", nil)
return err
}
+54
View File
@@ -0,0 +1,54 @@
---
id: cdp_clear_cookies_go_browser
name: cdp_clear_cookies
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Borra TODAS las cookies del browser via Network.clearBrowserCookies; equivalente a 'Borrar datos de navegacion > Cookies' en Chrome."
tags: [cdp, browser, cookie, network, navegator]
signature: "func CdpClearCookies(c *CDPConn) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_clear_cookies.go"
example: |
conn, _ := CdpConnect(9222)
if err := CdpClearCookies(conn); err != nil {
log.Fatal(err)
}
// browser ahora sin cookies — todas las sesiones cerradas
params:
- name: c
desc: "Conexion CDP activa al browser (obtenida con CdpConnect)"
output: "nil si se borraron todas las cookies; error si falla la comunicacion CDP."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
// Reset completo antes de un test de login
if err := CdpClearCookies(conn); err != nil {
log.Fatal(err)
}
// A partir de aqui el browser no tiene sesion en ningun dominio
```
## Cuando usarla
Usar al inicio de un test e2e que necesita partir de un browser sin sesion previa, o cuando quieres resetear completamente el estado de autenticacion del browser en un entorno de CI.
## Gotchas
- Destructivo e irreversible: cierra TODAS las sesiones activas en todos los dominios del browser.
- Llama `Network.enable` internamente antes del clear; es idempotente.
- No afecta a LocalStorage ni SessionStorage — solo cookies.
- Para borrar solo una cookie especifica usar `CdpDeleteCookies` en su lugar.
- En un browser de perfil de usuario real (no headless de test) puede cerrar sesiones de trabajo activas.
+12 -8
View File
@@ -14,11 +14,19 @@ func CdpClick(c *CDPConn, selector string) error {
return fmt.Errorf("cdp click: conexion nula")
}
// Obtener coordenadas del centro del elemento
// Obtener coordenadas del centro del elemento, tras hacer scroll para que sea
// visible. Verificamos visibilidad: un elemento existente pero oculto
// (display:none, visibility:hidden, opacity 0 o tamaño 0) daria un rect en
// (0,0) y clicariamos en la esquina sin efecto — devolvemos error en su lugar.
js := fmt.Sprintf(`(function() {
var el = document.querySelector(%q);
if (!el) return null;
el.scrollIntoView({block:'center'});
var r = el.getBoundingClientRect();
var s = window.getComputedStyle(el);
var visible = r.width > 0 && r.height > 0 &&
s.visibility !== 'hidden' && s.display !== 'none' && s.opacity !== '0';
if (!visible) return '__HIDDEN__';
return JSON.stringify({x: r.left + r.width/2, y: r.top + r.height/2});
})()`, selector)
@@ -29,6 +37,9 @@ func CdpClick(c *CDPConn, selector string) error {
if coordStr == "" || coordStr == "null" {
return fmt.Errorf("cdp click: elemento %q no encontrado en el DOM", selector)
}
if strings.Contains(coordStr, "__HIDDEN__") {
return fmt.Errorf("cdp click: elemento %q existe pero no es visible/clickable (display:none, oculto, opacity 0 o tamaño 0)", selector)
}
// Parsear "{x:...,y:...}" — CdpEvaluate ya retorna el JSON como string
coordStr = strings.Trim(coordStr, `"`)
@@ -37,13 +48,6 @@ func CdpClick(c *CDPConn, selector string) error {
return fmt.Errorf("cdp click: parsear coordenadas %q: %w", coordStr, err)
}
// Hacer scroll al elemento para que este visible
scrollJS := fmt.Sprintf(`document.querySelector(%q).scrollIntoView({block:'center'})`, selector)
if _, err := CdpEvaluate(c, scrollJS); err != nil {
// No es fatal si el scroll falla
_ = err
}
// Despachar mousedown
mouseParams := map[string]any{
"type": "mousePressed",
+94
View File
@@ -0,0 +1,94 @@
package browser
import (
"fmt"
"math/rand"
"strings"
)
// CdpClickHuman hace click en el elemento identificado por selector CSS con
// movimiento humano: obtiene el bbox, calcula un punto destino ligeramente
// desplazado del centro, mueve el ratón por una trayectoria de Bézier cúbica
// y luego despacha mousePressed/mouseReleased con una micro-pausa entre ellos.
//
// opts controla la trayectoria del movimiento previo al click.
// Para configurar el origen del movimiento usa opts.FromX / opts.FromY.
func CdpClickHuman(c *CDPConn, selector string, opts MouseHumanOpts) error {
if c == nil {
return fmt.Errorf("cdp click human: conexion nula")
}
// Obtener bounding box del selector
js := fmt.Sprintf(`(function() {
var el = document.querySelector(%q);
if (!el) return null;
var r = el.getBoundingClientRect();
return JSON.stringify({x: r.left, y: r.top, w: r.width, h: r.height});
})()`, selector)
bboxStr, err := CdpEvaluate(c, js)
if err != nil {
return fmt.Errorf("cdp click human: obtener bbox de %q: %w", selector, err)
}
if bboxStr == "" || bboxStr == "null" {
return fmt.Errorf("cdp click human: elemento %q no encontrado en el DOM", selector)
}
bboxStr = strings.Trim(bboxStr, `"`)
bx, by, bw, bh, err := parseBbox(bboxStr)
if err != nil {
return fmt.Errorf("cdp click human: parsear bbox %q: %w", bboxStr, err)
}
// Scroll al elemento para que sea visible
scrollJS := fmt.Sprintf(`document.querySelector(%q).scrollIntoView({block:'center'})`, selector)
if _, err := CdpEvaluate(c, scrollJS); err != nil {
_ = err // no fatal
}
// Punto destino: centro + pequeño offset aleatorio (±15% del tamaño)
offX := (rand.Float64()*2 - 1) * bw * 0.15
offY := (rand.Float64()*2 - 1) * bh * 0.15
toX := bx + bw/2 + offX
toY := by + bh/2 + offY
// Delegar en el primitivo compartido: mueve el ratón con trayectoria humana
// y despacha press/release con micro-pausa.
if err := CdpClickXYHuman(c, toX, toY, opts); err != nil {
return fmt.Errorf("cdp click human: %w", err)
}
return nil
}
// parseBbox extrae left, top, width, height de un JSON como {"x":10,"y":20,"w":100,"h":40}.
func parseBbox(s string) (left, top, width, height float64, err error) {
// Reutiliza el mismo parser manual que parseCoords para evitar encoding/json
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "{")
s = strings.TrimSuffix(s, "}")
for part := range strings.SplitSeq(s, ",") {
kv := strings.SplitN(strings.TrimSpace(part), ":", 2)
if len(kv) != 2 {
continue
}
k := strings.Trim(strings.TrimSpace(kv[0]), `"`)
var v float64
if _, e := fmt.Sscanf(strings.TrimSpace(kv[1]), "%f", &v); e != nil {
err = fmt.Errorf("parsear valor %q: %w", kv[1], e)
return
}
switch k {
case "x":
left = v
case "y":
top = v
case "w":
width = v
case "h":
height = v
}
}
return
}
+72
View File
@@ -0,0 +1,72 @@
---
name: cdp_click_human
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpClickHuman(c *CDPConn, selector string, opts MouseHumanOpts) error"
description: "Hace click en el elemento identificado por selector CSS con comportamiento humano: obtiene el bounding box, calcula un destino ligeramente desplazado del centro, mueve el ratón con CdpMoveMouseHuman (curva de Bézier cúbica + easing + jitter) y despacha mousePressed/mouseReleased con micro-pausa de 30-90 ms entre ellos."
tags: [cdp, chrome, browser, mouse, human, click, navegator]
uses_functions:
- cdp_evaluate_go_browser
- cdp_move_mouse_human_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- fmt
- math/rand
- strings
- time
tested: false
tests: []
test_file_path: ""
file_path: "functions/browser/cdp_click_human.go"
params:
- name: c
desc: "Conexión CDP activa obtenida con CdpConnect."
- name: selector
desc: "Selector CSS del elemento a clickear (ej. '#submit-btn', '.nav-item:first-child')."
- name: opts
desc: "MouseHumanOpts que controla la trayectoria del movimiento previo. Usa opts.FromX/FromY para definir el origen del movimiento (default 0,0)."
output: "error si la conexión es nula, el elemento no existe en el DOM, o falla algún evento CDP."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
defer CdpClose(conn, 0)
CdpNavigate(conn, "https://example.com/login")
CdpWaitElement(conn, "#username", 5*time.Second)
// Click humano en el campo usuario desde la esquina superior izquierda
err := CdpClickHuman(conn, "#username", MouseHumanOpts{
FromX: 50,
FromY: 50,
})
// Click en el botón submit viniendo desde donde está el campo usuario
err = CdpClickHuman(conn, "#submit-btn", MouseHumanOpts{
FromX: 350, // aproximadamente donde quedó el cursor anterior
FromY: 280,
DurationMs: 500,
Steps: 30,
})
```
## Cuando usarla
Sustituye a `CdpClick` cuando el sitio detecta clicks instantáneos sin movimiento previo o cuando el punto de click exactamente en el centro del elemento activa checks anti-bot. Usar en formularios de login, CAPTCHAs de comportamiento, botones con honeypot invisible en el centro exacto.
## Gotchas
- El destino final se desplaza ±15% del tamaño del elemento respecto al centro para evitar siempre clickear en el pixel exacto. En elementos muy pequeños (<5px) el offset puede salir fuera del elemento — usar `CdpClick` en esos casos.
- Hace `scrollIntoView` antes del movimiento. Si el elemento está en el fold inferior, el scroll ocurre y las coordenadas de la curva Bézier ya reflejan la posición post-scroll. Sin embargo, si el scroll produce reflow del DOM (lazy-load), puede que el selector cambie de posición durante el movimiento.
- La micro-pausa de 30-90 ms entre mousePressed y mouseReleased está codificada en el rango típico humano. No hay opción para ajustarla — si necesitas control total, llama `CdpMoveMouseHuman` + `Input.dispatchMouseEvent` manualmente.
- No garantiza indetectabilidad total. Ver `## Gotchas` de `cdp_move_mouse_human_go_browser`.
- Requiere que el elemento sea visible (no `display:none` ni `visibility:hidden`). `getBoundingClientRect` retorna todos ceros para elementos ocultos, produciendo click en (0,0).
- `opts.FromX` y `opts.FromY` deben ser la posición actual real del cursor para que la trayectoria sea convincente. Si no conoces la posición actual, pasa el centro aproximado de la última acción.
+40
View File
@@ -0,0 +1,40 @@
package browser
import "fmt"
// refBoxCenter resuelve el centro (x,y) en coords de página de un nodo DOM por su
// backendDOMNodeId, vía DOM.getBoxModel. El content quad son 8 floats (4 esquinas).
func refBoxCenter(c *CDPConn, backendNodeID int) (float64, float64, error) {
res, err := c.sendCDP("DOM.getBoxModel", map[string]any{"backendNodeId": backendNodeID})
if err != nil {
return 0, 0, fmt.Errorf("getBoxModel ref %d: %w", backendNodeID, err)
}
model, ok := res["model"].(map[string]any)
if !ok {
return 0, 0, fmt.Errorf("ref %d: sin boxModel (nodo no visible o inexistente)", backendNodeID)
}
content, ok := model["content"].([]any)
if !ok || len(content) < 8 {
return 0, 0, fmt.Errorf("ref %d: content quad invalido", backendNodeID)
}
num := func(i int) float64 { f, _ := content[i].(float64); return f }
cx := (num(0) + num(2) + num(4) + num(6)) / 4
cy := (num(1) + num(3) + num(5) + num(7)) / 4
return cx, cy, nil
}
// CdpClickRef hace click humanizado (Bézier + jitter) sobre el elemento del #ref.
// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive.
// Hace scroll al elemento si es necesario antes de calcular las coordenadas.
func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error {
if c == nil {
return fmt.Errorf("cdp click ref: conexión nil")
}
// scroll al elemento si no está visible; ignorar error (no fatal)
_, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID})
cx, cy, err := refBoxCenter(c, backendNodeID)
if err != nil {
return fmt.Errorf("cdp click ref: %w", err)
}
return CdpClickXYHuman(c, cx, cy, opts)
}
+51
View File
@@ -0,0 +1,51 @@
---
name: cdp_click_ref
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error"
description: "Click humanizado (Bézier + jitter) sobre el elemento identificado por su #ref del AX outline. El #ref es el backendDOMNodeId estable del nodo DOM. Hace scroll al elemento si no está en viewport antes de calcular las coordenadas vía DOM.getBoxModel."
tags: [cdp, browser, action, ref, humanized, navegator]
uses_functions: [cdp_click_xy_human_go_browser]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
params:
- name: c
desc: "Conexión CDP activa al tab objetivo."
- name: backendNodeID
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
- name: opts
desc: "Opciones de trayectoria humanizada (jitter, velocidad, curva Bézier). Zero-value da humanización por defecto."
output: "nil si el click se completó; error si la conexión es nil, el nodo no tiene boxModel visible, o el click CDP falla."
file_path: "functions/browser/cdp_click_ref.go"
---
## Ejemplo
```go
// Tras un page_perceive que devuelve outline con #ref=1234:
conn, _ := CdpConnect(9222)
err := CdpClickRef(conn, 1234, MouseHumanOpts{})
if err != nil {
log.Fatal(err)
}
```
## Cuando usarla
Tras `page_perceive` / `render_ax_outline`, cuando el agente tiene el `#ref` de un elemento del outline y quiere hacer click sobre él sin necesitar un selector CSS — cierra el bucle percibir→actuar. Preferir sobre `CdpClickHuman` cuando el nodo viene del AX outline (más estable que un selector).
## Gotchas
- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir antes de actuar.
- `DOM.getBoxModel` falla si el elemento no está en el DOM renderizado (display:none, fuera del shadow DOM accesible, o ya eliminado). El error describe la causa.
- `DOM.scrollIntoViewIfNeeded` se invoca antes del cálculo de coordenadas pero su fallo se ignora (no fatal) — si el elemento no es scrollable al viewport el click puede caer en coordenadas incorrectas.
- El click va por `CdpClickXYHuman` (Bézier): no despaches `Input.dispatchMouseEvent` crudo en código que use esta función.
+49
View File
@@ -0,0 +1,49 @@
package browser
import (
"fmt"
"math/rand"
"time"
)
// CdpClickXYHuman hace click en las coordenadas absolutas (x, y) de la página con
// comportamiento humano: mueve el ratón hasta el punto por una trayectoria de
// Bézier cúbica (CdpMoveMouseHuman) y despacha mousePressed/mouseReleased con una
// micro-pausa variable (30-90 ms) entre ambos.
//
// Es el PRIMITIVO de click compartido por las tres vías de acción del agente:
// - por selector CSS → CdpClickHuman (obtiene el bbox y llama aquí).
// - por #ref del AX tree → CdpClickRef (resuelve backendDOMNodeId → bbox → aquí).
// - por visión → click sobre el bounding box que devuelve OCR/YOLO.
// Construir un único primitivo evita tener tres caminos de click divergentes.
func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error {
if c == nil {
return fmt.Errorf("cdp click xy human: conexion nula")
}
// Mover el ratón hasta el destino con trayectoria humana.
if err := CdpMoveMouseHuman(c, x, y, opts); err != nil {
return fmt.Errorf("cdp click xy human: mover raton: %w", err)
}
clickParams := map[string]any{
"type": "mousePressed",
"x": x,
"y": y,
"button": "left",
"clickCount": 1,
}
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
return fmt.Errorf("cdp click xy human: mousePressed: %w", err)
}
// Micro-pausa humana entre press y release (30-90 ms).
time.Sleep(time.Duration(30+rand.Intn(61)) * time.Millisecond)
clickParams["type"] = "mouseReleased"
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
return fmt.Errorf("cdp click xy human: mouseReleased: %w", err)
}
return nil
}
+62
View File
@@ -0,0 +1,62 @@
---
id: cdp_click_xy_human_go_browser
name: cdp_click_xy_human
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Click humanizado en coordenadas absolutas (x,y): mueve el ratón con trayectoria Bézier y despacha mousePressed/mouseReleased con micro-pausa variable. Primitivo de click compartido por las tres vías de acción del agente: por selector, por #ref del AX tree y por visión (bounding box de OCR/YOLO)."
tags: [cdp, browser, action, humanized, click, navegator]
signature: "func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error"
uses_functions:
- cdp_move_mouse_human_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_click_xy_human.go"
example: |
conn, _ := browser.CdpConnect(9333)
defer browser.CdpClose(conn, 0)
// Click humanizado en el centro de un elemento detectado por visión (bbox):
browser.CdpClickXYHuman(conn, 412.0, 318.0, browser.MouseHumanOpts{})
params:
- name: c
desc: "Conexión CDP activa (de CdpConnect)."
- name: x
desc: "Coordenada X absoluta en la página, en px CSS del viewport."
- name: y
desc: "Coordenada Y absoluta en la página, en px CSS del viewport."
- name: opts
desc: "Opciones de la trayectoria humana (zero-value = defaults). Origen del movimiento via FromX/FromY."
output: "error si el movimiento del ratón o el despacho de eventos falla; nil en éxito."
---
## Ejemplo
```go
conn, _ := browser.CdpConnect(9333)
defer browser.CdpClose(conn, 0)
// El centro del bounding box lo da el #ref del AX tree (DOM.getBoxModel) o la
// detección de visión (OCR/YOLO). Aquí, click humanizado sobre ese punto:
if err := browser.CdpClickXYHuman(conn, 412.0, 318.0, browser.MouseHumanOpts{}); err != nil {
log.Fatal(err)
}
```
## Cuando usarla
Cuando ya tienes las coordenadas de píxel del objetivo: el centro del bounding box de un elemento
(resuelto por `#ref` del AX outline vía `DOM.getBoxModel`, o detectado por visión OCR/YOLO). Es el
único primitivo de click del agente — no despaches `Input.dispatchMouseEvent` a mano.
## Gotchas
- Coordenadas en el sistema de la página (px CSS del viewport), no de pantalla física.
- La humanización añade latencia (movimiento Bézier + micro-pausa). Para scraping masivo de alto
volumen, el llamador debe usar un preset rápido de `MouseHumanOpts` (política de sesión `fast`),
no humanización completa por acción.
- El destino debe estar dentro del viewport visible; haz scroll al elemento antes si hace falta.
+15 -9
View File
@@ -3,9 +3,12 @@ package browser
import (
"fmt"
"os"
"syscall"
)
// CdpClose cierra la conexion WebSocket CDP y, si pid > 0, mata el proceso Chrome.
// En Linux nativo mata el grupo de proceso completo (chromium lanza zygote, gpu,
// renderers como hijos del mismo grupo cuando ChromeLaunch seteo Setpgid: true).
// Siempre intenta cerrar la conexion aunque el kill falle, y viceversa.
// Retorna el primer error encontrado.
func CdpClose(c *CDPConn, pid int) error {
@@ -19,16 +22,19 @@ func CdpClose(c *CDPConn, pid int) error {
}
if pid > 0 {
proc, err := os.FindProcess(pid)
if err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("cdp close: encontrar proceso %d: %w", pid, err)
}
} else {
if err := proc.Kill(); err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("cdp close: matar proceso %d: %w", pid, err)
// Intentar matar el grupo de proceso completo (pid == pgid cuando Setpgid=true).
// syscall.Kill con pid negativo envia la señal a todos los procesos del grupo.
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
// Fallback: matar solo el proceso raiz si el grupo falla
// (ej: proceso ya terminado, o chrome.exe en WSL sin Setpgid).
if proc, e := os.FindProcess(pid); e == nil {
if killErr := proc.Kill(); killErr != nil {
if firstErr == nil {
firstErr = fmt.Errorf("cdp close: matar proceso %d: %w", pid, killErr)
}
}
} else if firstErr == nil {
firstErr = fmt.Errorf("cdp close: encontrar proceso %d: %w", pid, e)
}
}
}
+25 -10
View File
@@ -3,23 +3,23 @@ name: cdp_close
kind: function
lang: go
domain: browser
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "func CdpClose(c *CDPConn, pid int) error"
description: "Cierra la conexion WebSocket CDP y opcionalmente mata el proceso Chrome por PID. Si c es nil, solo mata el proceso. Si pid <= 0, solo cierra la conexion. Siempre intenta ambas operaciones aunque una falle."
tags: [chrome, cdp, browser, automation, cleanup, devtools]
description: "Cierra la conexion WebSocket CDP y opcionalmente mata el proceso Chrome por PID. En Linux nativo mata el grupo de proceso completo (pid == pgid cuando ChromeLaunch seteo Setpgid=true), lo que incluye zygote, gpu-process y renderers. Si c es nil, solo mata el proceso. Si pid <= 0, solo cierra la conexion. Siempre intenta ambas operaciones aunque una falle."
tags: [chrome, cdp, browser, automation, cleanup, devtools, linux]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os]
imports: [fmt, os, syscall]
params:
- name: c
desc: "conexión CDP (puede ser nil)"
desc: "conexión CDP (puede ser nil para solo matar el proceso)"
- name: pid
desc: "PID del proceso Chrome (0 para no matar)"
output: "error si falla la desconexión o el cierre del proceso"
desc: "PID del proceso Chrome (0 para no matar; en Linux nativo este PID es tambien el PGID cuando ChromeLaunch uso Setpgid)"
output: "error si falla la desconexion o el cierre del proceso; nil si todo OK"
tested: false
tests: []
test_file_path: ""
@@ -32,13 +32,28 @@ file_path: "functions/browser/cdp_close.go"
pid, _ := ChromeLaunch(ChromeLaunchOpts{Port: 9222, Headless: true})
conn, _ := CdpConnect(9222)
defer CdpClose(conn, pid) // cierra WebSocket y mata Chrome
defer CdpClose(conn, pid) // cierra WebSocket y mata grupo Chrome completo
// O por separado:
defer CdpClose(conn, 0) // solo cierra WebSocket
defer CdpClose(nil, pid) // solo mata Chrome
defer CdpClose(nil, pid) // solo mata Chrome (y su grupo en Linux)
```
## Cuando usarla
Usar siempre en `defer` después de `ChromeLaunch` para garantizar cleanup del proceso Chrome y del WebSocket CDP. En Linux nativo mata el árbol completo de procesos (zygote, gpu, renderers) evitando procesos zombie.
## Gotchas
- **Kill por grupo (Linux nativo)**: usa `syscall.Kill(-pid, SIGKILL)` que envía la señal a todos los procesos del grupo. Funciona porque `ChromeLaunch` setea `Setpgid: true` en Linux, haciendo que `pid == pgid`. En WSL+chrome.exe el Setpgid no se aplica, por lo que el fallback a `os.FindProcess(pid).Kill()` maneja ese caso.
- **Fallback automático**: si el kill de grupo falla (proceso ya terminado, PID no encontrado, o es WSL+exe), intenta matar solo el proceso raiz. En ambos casos el error no es fatal si el proceso ya no existe.
- **Doble cierre seguro**: marca `c.closed = true` para evitar doble cierre del WebSocket. El segundo `CdpClose` con la misma conexión es un no-op en el lado WebSocket.
- **Primer error**: si tanto el cierre WebSocket como el kill fallan, retorna el error del WebSocket (el primero en ejecutarse). El kill siempre se intenta aunque el WebSocket falle.
## Notas
Usar en `defer` para garantizar cleanup. Si tanto la conexion como el proceso son invalidos, el error retornado corresponde al primero que fallo. Marca `c.closed = true` para evitar doble cierre.
Usar en `defer` para garantizar cleanup. Si tanto la conexion como el proceso son invalidos, el error retornado corresponde al primero que fallo.
## Capability growth log
- v1.1.0 (2026-06-05) — Linux-native kill: usa syscall.Kill(-pid, SIGKILL) para matar grupo completo (zygote, gpu, renderers), con fallback a os.FindProcess para WSL+exe o proceso ya terminado
+63
View File
@@ -0,0 +1,63 @@
---
id: cdp_close_tab_go_browser
name: cdp_close_tab
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Cierra una pestaña Chrome por su ID via GET /json/close/<id>. Sin WebSocket — solo HTTP. Util para limpiar pestañas abiertas por automatizaciones."
tags: [cdp, browser, tabs, navegator]
signature: "func CdpCloseTab(host string, port int, tabID string) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_list_tabs.go"
example: |
tabs, _ := browser.CdpListTabs("localhost", 9222)
for _, t := range tabs {
if t.URL == "https://example.com" {
_ = browser.CdpCloseTab("localhost", 9222, t.ID)
}
}
params:
- name: host
desc: "Hostname de la instancia Chrome (vacío = localhost)"
- name: port
desc: "Puerto CDP de remote debugging (habitualmente 9222)"
- name: tabID
desc: "ID de la pestaña a cerrar, obtenido de CdpTab.ID via CdpListTabs"
output: "nil si la pestaña se cerró correctamente; error si tabID está vacío, la conexión falla o Chrome devuelve status != 200"
---
## Ejemplo
```go
// Listar tabs y cerrar la primera que coincida con una URL
tabs, err := browser.CdpListTabs("localhost", 9222)
if err != nil {
log.Fatal(err)
}
for _, t := range tabs {
if t.URL == "https://example.com/login" {
if err := browser.CdpCloseTab("localhost", 9222, t.ID); err != nil {
log.Printf("error cerrando tab %s: %v", t.ID, err)
}
}
}
```
## Cuando usarla
Después de terminar una sesión de scraping o automatización: cierra las pestañas abiertas programáticamente sin afectar el resto del perfil. También útil para liberar recursos cuando `CdpNewTab` ha creado muchas pestañas temporales.
## Gotchas
- No requiere conexión WebSocket previa; usa HTTP puro contra `/json/close/<id>`.
- Si Chrome ya cerró la pestaña (o el ID es inválido), devuelve error de status HTTP.
- El ID debe obtenerse de `CdpListTabs` — no es el índice visible del tab, es el UUID interno de Chrome.
- No espera confirmación de cierre; para saber si la pestaña desapareció, volver a llamar `CdpListTabs`.
+17 -14
View File
@@ -67,18 +67,9 @@ func CdpConnect(port int) (*CDPConn, error) {
return CdpConnectHost("localhost", port)
}
// CdpConnectHost es como CdpConnect pero permite especificar el host.
// Util para WSL2 donde Chrome Windows escucha en una IP distinta a localhost.
func CdpConnectHost(host string, port int) (*CDPConn, error) {
if host == "" {
host = "localhost"
}
wsURL, err := cdpGetPageWSURL(host, port)
if err != nil {
return nil, fmt.Errorf("cdp connect: %w", err)
}
// Parsear la URL del WebSocket para extraer host y path
// cdpConnectWS abre la conexion CDP a partir de un webSocketDebuggerUrl ya resuelto.
// Es el helper compartido por CdpConnectHost y CdpConnectTarget para evitar duplicacion.
func cdpConnectWS(wsURL string, port int) (*CDPConn, error) {
u, err := url.Parse(wsURL)
if err != nil {
return nil, fmt.Errorf("cdp connect: parsear ws url %q: %w", wsURL, err)
@@ -96,8 +87,7 @@ func CdpConnectHost(host string, port int) (*CDPConn, error) {
}
// Realizar handshake WebSocket
path := u.RequestURI()
reader, err := wsHandshake(tcpConn, wsHost, path)
reader, err := wsHandshake(tcpConn, wsHost, u.RequestURI())
if err != nil {
tcpConn.Close()
return nil, fmt.Errorf("cdp connect: ws handshake: %w", err)
@@ -115,3 +105,16 @@ func CdpConnectHost(host string, port int) (*CDPConn, error) {
return c, nil
}
// CdpConnectHost es como CdpConnect pero permite especificar el host.
// Util para WSL2 donde Chrome Windows escucha en una IP distinta a localhost.
func CdpConnectHost(host string, port int) (*CDPConn, error) {
if host == "" {
host = "localhost"
}
wsURL, err := cdpGetPageWSURL(host, port)
if err != nil {
return nil, fmt.Errorf("cdp connect: %w", err)
}
return cdpConnectWS(wsURL, port)
}
+56
View File
@@ -0,0 +1,56 @@
package browser
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
// CdpConnectTarget se conecta a un target CDP DETERMINISTA identificado por match.
//
// Si host es "" se usa "localhost".
// match puede ser:
// - "" → primer target con Type "page" y WebSocketDebuggerURL no vacío (misma
// semántica que CdpConnectHost, útil como fallback compatible).
// - ID exacto del target (campo "id" en /json).
// - Substring case-insensitive de la URL del target.
//
// Retorna error si ningún target type=page satisface el match.
func CdpConnectTarget(host string, port int, match string) (*CDPConn, error) {
if host == "" {
host = "localhost"
}
resp, err := http.Get(fmt.Sprintf("http://%s:%d/json", host, port))
if err != nil {
return nil, fmt.Errorf("cdp connect target: listar targets: %w", err)
}
defer resp.Body.Close()
var targets []cdpTarget
if err := json.NewDecoder(resp.Body).Decode(&targets); err != nil {
return nil, fmt.Errorf("cdp connect target: decode targets: %w", err)
}
matchLower := strings.ToLower(match)
for _, t := range targets {
if t.Type != "page" || t.WebSocketDebuggerURL == "" {
continue
}
if match == "" {
// Sin filtro: primera tab page disponible.
return cdpConnectWS(t.WebSocketDebuggerURL, port)
}
// Coincidencia por ID exacto o substring de URL (case-insensitive).
if t.ID == match || strings.Contains(strings.ToLower(t.URL), matchLower) {
return cdpConnectWS(t.WebSocketDebuggerURL, port)
}
}
if match == "" {
return nil, fmt.Errorf("cdp connect target: no hay ninguna tab 'page' disponible en %s:%d", host, port)
}
return nil, fmt.Errorf("cdp connect target: no hay tab 'page' que matchee %q en %s:%d", match, host, port)
}
+58
View File
@@ -0,0 +1,58 @@
---
name: cdp_connect_target
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpConnectTarget(host string, port int, match string) (*CDPConn, error)"
description: "Conecta por CDP a un target DETERMINISTA elegido por ID exacto o substring de URL, evitando engancharse a una pestaña al azar con el CDP global en 9222."
tags: [cdp, browser, connection, security, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: host
desc: "Host donde escucha el CDP. Vacío usa 'localhost'. Útil en WSL2 para apuntar a la IP de Windows."
- name: port
desc: "Puerto CDP del navegador (habitualmente 9222)."
- name: match
desc: "Filtro de target: vacío = primera tab page (compat con CdpConnectHost); ID exacto del target; o substring case-insensitive de la URL de la pestaña."
output: "*CDPConn listo para enviar comandos CDP al target elegido. Error si ninguna tab 'page' satisface el match."
tested: false
tests: []
test_file_path: ""
file_path: "functions/browser/cdp_connect_target.go"
---
## Ejemplo
```go
// Fijar la pestaña de GitHub para que el agente no toque otras abiertas
conn, err := browser.CdpConnectTarget("", 9222, "github.com")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// Por ID exacto de target (obtenido de GET http://localhost:9222/json)
conn2, err := browser.CdpConnectTarget("", 9222, "ABCD1234-target-id")
// Compatibilidad: sin filtro = primera tab page (igual que CdpConnect)
conn3, err := browser.CdpConnectTarget("", 9222, "")
```
## Cuando usarla
Cuando un agente debe atarse a UNA pestaña concreta (por URL) y NO a la primera al azar — crítico con CDP global en 9222 para no operar sobre pestañas ajenas (banca, correo, sesiones activas). Usar en lugar de `CdpConnect`/`CdpConnectHost` siempre que el contexto del agente sea "esta URL concreta" y no "cualquier tab disponible".
## Gotchas
- Si hay varias tabs cuya URL contiene el substring dado, se elige la **primera** que aparezca en `/json` (orden interno del navegador). Para mayor precisión, usar el ID exacto del target.
- El match de URL es substring **case-insensitive**; `"github"` matchea `"https://github.com/usuario/repo"`.
- Con CDP global en 9222 y muchas pestañas abiertas, un `match=""` sigue siendo tan arriesgado como `CdpConnect`. Especificar siempre el match en producción.
- La forma más segura para agentes automatizados es lanzar un perfil Chromium dedicado con `--user-data-dir` aislado y `--remote-debugging-port` propio, de modo que `/json` solo exponga las pestañas del agente.
- `WebSocketDebuggerURL` puede cambiar entre reinicios del navegador; recalcular en cada sesión, no cachear entre ejecuciones.
+15
View File
@@ -0,0 +1,15 @@
package browser
// CdpDeleteCookies borra las cookies que coincidan con name (y opcionalmente domain)
// via Network.deleteCookies. Si domain es "" se borran todas las cookies con ese
// nombre en cualquier dominio.
func CdpDeleteCookies(c *CDPConn, name, domain string) error {
params := map[string]any{
"name": name,
}
if domain != "" {
params["domain"] = domain
}
_, err := c.sendCDP("Network.deleteCookies", params)
return err
}
+61
View File
@@ -0,0 +1,61 @@
---
id: cdp_delete_cookies_go_browser
name: cdp_delete_cookies
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Borra las cookies que coincidan con name (+ domain opcional) via Network.deleteCookies; si domain es vacío elimina en todos los dominios."
tags: [cdp, browser, cookie, network, navegator]
signature: "func CdpDeleteCookies(c *CDPConn, name, domain string) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_delete_cookies.go"
example: |
conn, _ := CdpConnect(9222)
// Borrar cookie de sesion solo en el dominio concreto
err := CdpDeleteCookies(conn, "session_id", "app.example.com")
// Borrar en todos los dominios (sin filtro de dominio)
err = CdpDeleteCookies(conn, "tracking_cookie", "")
params:
- name: c
desc: "Conexion CDP activa al browser (obtenida con CdpConnect)"
- name: name
desc: "Nombre exacto de la cookie a borrar; obligatorio para Network.deleteCookies"
- name: domain
desc: "Dominio donde borrar la cookie; cadena vacía borra en todos los dominios que tengan esa cookie"
output: "nil si la cookie fue borrada (o no existia); error si falla la comunicacion CDP."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
// Borrar cookie de sesion solo en dominio especifico
if err := CdpDeleteCookies(conn, "session_id", "app.example.com"); err != nil {
log.Fatal(err)
}
// Borrar cookie en todos los dominios
if err := CdpDeleteCookies(conn, "analytics_token", ""); err != nil {
log.Fatal(err)
}
```
## Cuando usarla
Usar cuando necesitas forzar un logout de sesion especifica, limpiar una cookie de tracking antes de un test, o resetear el estado de autenticacion de un dominio concreto sin tocar el resto de cookies.
## Gotchas
- `name` es obligatorio en `Network.deleteCookies`; CDP devuelve error si se omite.
- Sin `domain`, CDP borra la cookie en TODOS los dominios que tengan esa cookie — puede cerrar sesiones inesperadas en otros dominios abiertos.
- No devuelve error si la cookie no existia; la operacion es idempotente.
- Para borrar todas las cookies de golpe usar `CdpClearCookies` en su lugar.
+83
View File
@@ -0,0 +1,83 @@
package browser
import (
"encoding/json"
"fmt"
)
// CdpEvalInFrame ejecuta una expresion JavaScript en el contexto aislado de un iframe
// especifico usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame.
// Retorna el resultado serializado como string.
func CdpEvalInFrame(c *CDPConn, frameID, expression string) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp eval in frame: conexion nula")
}
if frameID == "" {
return "", fmt.Errorf("cdp eval in frame: frameID vacio")
}
// Page.enable es idempotente; necesario antes de crear mundos aislados
if _, err := c.sendCDP("Page.enable", nil); err != nil {
return "", fmt.Errorf("cdp eval in frame: Page.enable: %w", err)
}
// Crear un mundo aislado en el frame indicado para no contaminar su contexto JS
ctxRes, err := c.sendCDP("Page.createIsolatedWorld", map[string]any{
"frameId": frameID,
"worldName": "fn_registry_isolated",
"grantUniveralAccess": false,
})
if err != nil {
return "", fmt.Errorf("cdp eval in frame: createIsolatedWorld: %w", err)
}
ctxIDRaw, ok := ctxRes["executionContextId"]
if !ok {
return "", fmt.Errorf("cdp eval in frame: executionContextId no encontrado en respuesta")
}
ctxID, ok := ctxIDRaw.(float64)
if !ok {
return "", fmt.Errorf("cdp eval in frame: executionContextId tipo inesperado: %T", ctxIDRaw)
}
// Evaluar la expresion en el contexto aislado del frame
evRes, err := c.sendCDP("Runtime.evaluate", map[string]any{
"expression": expression,
"contextId": int(ctxID),
"returnByValue": true,
"awaitPromise": true,
})
if err != nil {
return "", fmt.Errorf("cdp eval in frame: Runtime.evaluate: %w", err)
}
// Verificar excepcion JS
if exc, ok := evRes["exceptionDetails"]; ok && exc != nil {
excMap, _ := exc.(map[string]any)
text, _ := excMap["text"].(string)
return "", fmt.Errorf("cdp eval in frame: excepcion JS en frame %q: %s", frameID, text)
}
// Extraer valor del resultado (mismo patron que CdpEvaluate)
resVal, ok := evRes["result"].(map[string]any)
if !ok {
return "", fmt.Errorf("cdp eval in frame: resultado inesperado: %v", evRes)
}
value, ok := resVal["value"]
if !ok {
// undefined u otro tipo no serializable
typ, _ := resVal["type"].(string)
return typ, nil
}
// Strings tal cual; objetos/arrays JS a JSON real (no la repr de Go de "%v").
if s, ok := value.(string); ok {
return s, nil
}
b, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%v", value), nil
}
return string(b), nil
}
+73
View File
@@ -0,0 +1,73 @@
---
id: cdp_eval_in_frame_go_browser
name: cdp_eval_in_frame
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Ejecuta una expresión JavaScript en el contexto aislado de un iframe concreto usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame."
tags: [cdp, browser, iframe, javascript, eval, navegator]
signature: "func CdpEvalInFrame(c *CDPConn, frameID string, expression string) (string, error)"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_eval_in_frame.go"
example: |
conn, _ := CdpConnect("localhost", 9222, "")
frames, _ := CdpListFrames(conn)
// Tomar el primer iframe (índice 1, el 0 es el frame raíz)
result, err := CdpEvalInFrame(conn, frames[1].ID, "document.title")
fmt.Println(result) // "Título del iframe"
params:
- name: c
desc: "Conexión CDP activa obtenida con CdpConnect."
- name: frameID
desc: "ID del frame donde ejecutar el JS; obtenido de CdpListFrames (campo CdpFrame.ID)."
- name: expression
desc: "Expresión JavaScript a evaluar en el contexto del frame; puede ser una expresión simple o una Promise."
output: "Resultado de la expresión serializado como string (fmt.Sprintf del valor CDP); error si la conexión es nula, el frameID está vacío, la comunicación CDP falla o la expresión lanza una excepción JS."
---
## Ejemplo
```go
conn, err := CdpConnect("localhost", 9222, "")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
frames, err := CdpListFrames(conn)
if err != nil {
log.Fatal(err)
}
// frames[0] es el frame raíz; frames[1] sería el primer iframe
iframeID := frames[1].ID
title, err := CdpEvalInFrame(conn, iframeID, "document.title")
if err != nil {
log.Fatal(err)
}
fmt.Println("Título del iframe:", title)
// Leer un elemento del DOM del iframe
text, _ := CdpEvalInFrame(conn, iframeID, "document.querySelector('h1').innerText")
fmt.Println("H1 del iframe:", text)
```
## Cuando usarla
Cuando necesites leer o manipular el DOM de un iframe específico sin afectar el contexto JS de la página principal. Útil para extraer datos de iframes de terceros, formularios embebidos o widgets. Obtén el `frameID` con `CdpListFrames` antes de llamar a esta función.
## Gotchas
- El mundo aislado (`fn_registry_isolated`) puede leer el DOM del iframe pero NO accede a variables JS definidas en el page-world del iframe (ej. `window.miVariable`). Para acceder a variables JS del frame, evalúa sin `createIsolatedWorld` usando el `contextId` principal del frame (no expuesto por esta función).
- Requiere `Page.enable` (se llama internamente, idempotente).
- Si el iframe tiene `sandbox` attribute sin `allow-scripts`, el CDP puede crear el mundo aislado pero las evaluaciones fallarán con excepción de seguridad.
- Cross-origin iframes en Chrome permiten evaluación CDP siempre que la conexión tenga acceso al target; no aplican las restricciones CORS de JS normal.
- El `frameID` debe obtenerse con `CdpListFrames`; si se pasa un ID obsoleto (frame recargado o destruido), `createIsolatedWorld` retorna error.
+13 -1
View File
@@ -1,6 +1,7 @@
package browser
import (
"encoding/json"
"fmt"
)
@@ -44,5 +45,16 @@ func CdpEvaluate(c *CDPConn, expression string) (string, error) {
return typ, nil
}
return fmt.Sprintf("%v", value), nil
// Strings se devuelven tal cual (sin comillas). Objetos y arrays JS, que Chrome
// deserializa a map/slice cuando returnByValue=true, se serializan a JSON real
// en vez de la repr de Go de fmt.Sprintf("%v") (que produciria "map[a:1]" en lugar
// de {"a":1}). Asi el caller puede parsear datos estructurados.
if s, ok := value.(string); ok {
return s, nil
}
b, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%v", value), nil
}
return string(b), nil
}
+5 -3
View File
@@ -25,8 +25,10 @@ type FindByTextOpts struct {
// - "#<id>" si el elemento tiene id.
// - path "tag:nth-of-type(n) > tag:nth-of-type(n) > ..." si no.
//
// Retorna ("", nil) si no encuentra nada (no es error). Error solo si la
// evaluacion JS rompe (conexion CDP caida).
// Retorna error si no encuentra ningun elemento con ese texto. Antes devolvia
// ("", nil) en silencio, lo que hacia que el caller creyera que habia encontrado
// algo y operara sobre un selector vacio. Tambien error si la evaluacion JS rompe
// (conexion CDP caida).
func CdpFindByText(c *CDPConn, text string, opts FindByTextOpts) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp find by text: conexion nula")
@@ -96,7 +98,7 @@ func CdpFindByText(c *CDPConn, text string, opts FindByTextOpts) (string, error)
// CdpEvaluate retorna el valor stringificado. Para "" devuelve cadena vacia.
res = strings.TrimSpace(res)
if res == "" || res == "<nil>" {
return "", nil
return "", fmt.Errorf("cdp find by text: no se encontro elemento con texto %q", text)
}
return res, nil
}
+63
View File
@@ -0,0 +1,63 @@
package browser
// CdpCookie representa una cookie del browser tal como la devuelve CDP.
type CdpCookie struct {
Name string `json:"name"`
Value string `json:"value"`
Domain string `json:"domain"`
Path string `json:"path"`
Expires float64 `json:"expires"`
HTTPOnly bool `json:"httpOnly"`
Secure bool `json:"secure"`
SameSite string `json:"sameSite"`
}
// cookieFromMap convierte un map[string]any CDP a CdpCookie con casts defensivos.
func cookieFromMap(m map[string]any) CdpCookie {
c := CdpCookie{}
if v, ok := m["name"].(string); ok {
c.Name = v
}
if v, ok := m["value"].(string); ok {
c.Value = v
}
if v, ok := m["domain"].(string); ok {
c.Domain = v
}
if v, ok := m["path"].(string); ok {
c.Path = v
}
if v, ok := m["expires"].(float64); ok {
c.Expires = v
}
if v, ok := m["httpOnly"].(bool); ok {
c.HTTPOnly = v
}
if v, ok := m["secure"].(bool); ok {
c.Secure = v
}
if v, ok := m["sameSite"].(string); ok {
c.SameSite = v
}
return c
}
// CdpGetCookies devuelve todas las cookies del browser via Network.getAllCookies.
// El caller puede filtrar por dominio, nombre, etc. sobre el slice retornado.
func CdpGetCookies(c *CDPConn) ([]CdpCookie, error) {
if _, err := c.sendCDP("Network.enable", nil); err != nil {
return nil, err
}
result, err := c.sendCDP("Network.getAllCookies", nil)
if err != nil {
return nil, err
}
raw, _ := result["cookies"].([]any)
cookies := make([]CdpCookie, 0, len(raw))
for _, item := range raw {
if m, ok := item.(map[string]any); ok {
cookies = append(cookies, cookieFromMap(m))
}
}
return cookies, nil
}
+59
View File
@@ -0,0 +1,59 @@
---
id: cdp_get_cookies_go_browser
name: cdp_get_cookies
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Devuelve todas las cookies del browser via Network.getAllCookies; el caller filtra por dominio o nombre sobre el slice []CdpCookie."
tags: [cdp, browser, cookie, network, navegator]
signature: "func CdpGetCookies(c *CDPConn) ([]CdpCookie, error)"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_get_cookies.go"
example: |
conn, _ := CdpConnect(9222)
cookies, err := CdpGetCookies(conn)
if err != nil { log.Fatal(err) }
for _, ck := range cookies {
if ck.Domain == "app.example.com" {
fmt.Printf("name=%s value=%s httpOnly=%v\n", ck.Name, ck.Value, ck.HTTPOnly)
}
}
params:
- name: c
desc: "Conexion CDP activa al browser (obtenida con CdpConnect)"
output: "Slice de CdpCookie con todas las cookies del browser; error si falla la comunicacion CDP."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
cookies, err := CdpGetCookies(conn)
if err != nil {
log.Fatal(err)
}
for _, ck := range cookies {
if ck.Domain == "app.example.com" {
fmt.Printf("name=%s value=%s httpOnly=%v\n", ck.Name, ck.Value, ck.HTTPOnly)
}
}
```
## Cuando usarla
Usar cuando necesitas inspeccionar el estado de cookies del browser tras un login CDP, antes de propagarlas a otro contexto, o para auditar sesiones activas en tests e2e.
## Gotchas
- Llama `Network.enable` internamente antes de `getAllCookies`; es idempotente pero suma latencia en la primera llamada.
- `Network.getAllCookies` devuelve cookies de TODOS los dominios del browser, no solo la tab activa. Filtrar por `Domain` en el caller.
- Las cookies HttpOnly son visibles via CDP aunque no lo sean desde JavaScript del browser.
- `Expires == -1` indica cookie de sesion (sin fecha de expiración).
+23
View File
@@ -0,0 +1,23 @@
package browser
import (
"fmt"
)
// CdpGetFrameHTML retorna el HTML completo (outerHTML del documentElement) de un iframe
// especifico usando CdpEvalInFrame con la expresion "document.documentElement.outerHTML".
func CdpGetFrameHTML(c *CDPConn, frameID string) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp get frame html: conexion nula")
}
if frameID == "" {
return "", fmt.Errorf("cdp get frame html: frameID vacio")
}
html, err := CdpEvalInFrame(c, frameID, "document.documentElement.outerHTML")
if err != nil {
return "", fmt.Errorf("cdp get frame html: %w", err)
}
return html, nil
}
+70
View File
@@ -0,0 +1,70 @@
---
id: cdp_get_frame_html_go_browser
name: cdp_get_frame_html
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Devuelve el HTML completo (document.documentElement.outerHTML) de un iframe concreto componiendo sobre CdpEvalInFrame con un mundo aislado CDP."
tags: [cdp, browser, iframe, html, scraping, navegator]
signature: "func CdpGetFrameHTML(c *CDPConn, frameID string) (string, error)"
uses_functions: [cdp_eval_in_frame_go_browser]
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_get_frame_html.go"
example: |
conn, _ := CdpConnect("localhost", 9222, "")
frames, _ := CdpListFrames(conn)
html, err := CdpGetFrameHTML(conn, frames[1].ID)
fmt.Println(html[:200]) // primeros 200 chars del HTML del iframe
params:
- name: c
desc: "Conexión CDP activa obtenida con CdpConnect."
- name: frameID
desc: "ID del frame cuyo HTML se quiere obtener; obtenido de CdpListFrames (campo CdpFrame.ID)."
output: "String con el HTML completo del iframe (outerHTML del documentElement); error si la conexión es nula, el frameID está vacío o la evaluación CDP falla."
---
## Ejemplo
```go
conn, err := CdpConnect("localhost", 9222, "")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// 1. Listar frames para obtener el ID del iframe deseado
frames, err := CdpListFrames(conn)
if err != nil {
log.Fatal(err)
}
// frames[0] = frame raíz, frames[1] = primer iframe
for _, f := range frames {
if f.ParentID != "" { // es un iframe, no el raíz
html, err := CdpGetFrameHTML(conn, f.ID)
if err != nil {
log.Printf("error en frame %s: %v", f.ID, err)
continue
}
fmt.Printf("=== iframe %s (%s) ===\n%s\n", f.ID, f.URL, html[:min(500, len(html))])
}
}
```
## Cuando usarla
Cuando necesites el HTML completo de un iframe para parsearlo, scrapearlo o inspeccionarlo. Flujo típico: `CdpListFrames` → elegir frame por URL → `CdpGetFrameHTML` → parsear con `golang.org/x/net/html` o regexp.
## Gotchas
- El mundo aislado ve el DOM pero NO las variables JS del page-world del iframe; suficiente para leer `outerHTML` y hacer scraping estructural.
- `frameID` debe obtenerse de `CdpListFrames`; un ID obsoleto (frame recargado) provoca error en `CdpEvalInFrame`.
- Para iframes con contenido dinámico (renderizado por JS), espera a que el iframe termine de cargar antes de llamar a esta función; de lo contrario el HTML puede estar incompleto.
- En páginas con muchos iframes pesados, el outerHTML puede ser muy grande (MBs); considera evaluar selectores más específicos con `CdpEvalInFrame` si solo necesitas parte del DOM.
+54
View File
@@ -0,0 +1,54 @@
package browser
import (
"encoding/json"
"fmt"
"unicode/utf8"
)
// CdpGetText retorna el texto visible (innerText) de la pagina o de un elemento.
// Si selector es "" lee document.body.innerText completo.
// Si selector no matchea ningun elemento retorna error.
// Si maxBytes > 0 trunca al limite dado (corte rune-safe) y añade sufijo con total original.
// Si maxBytes <= 0 no hay limite.
func CdpGetText(c *CDPConn, selector string, maxBytes int) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp get text: conexion nula")
}
var expr string
if selector == "" {
expr = `document.body ? document.body.innerText : ""`
} else {
// Escapa el selector como string JSON para evitar inyeccion via comillas/backslash.
selectorJSON, err := json.Marshal(selector)
if err != nil {
return "", fmt.Errorf("cdp get text: escapar selector: %w", err)
}
expr = fmt.Sprintf(
`(function(){var e=document.querySelector(%s); return e ? e.innerText : "__FN_GET_TEXT_NOTFOUND__";})()`,
string(selectorJSON),
)
}
text, err := CdpEvaluate(c, expr)
if err != nil {
return "", fmt.Errorf("cdp get text: %w", err)
}
if selector != "" && text == "__FN_GET_TEXT_NOTFOUND__" {
return "", fmt.Errorf("cdp get text: elemento no encontrado: %s", selector)
}
if maxBytes > 0 && len(text) > maxBytes {
total := len(text)
// Corte rune-safe: retrocede hasta encontrar un rune valido completo.
cut := maxBytes
for cut > 0 && !utf8.RuneStart(text[cut]) {
cut--
}
text = text[:cut] + fmt.Sprintf("\n…[truncado, total %d bytes]", total)
}
return text, nil
}
+59
View File
@@ -0,0 +1,59 @@
---
name: cdp_get_text
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpGetText(c *CDPConn, selector string, maxBytes int) (string, error)"
description: "Retorna el texto visible (innerText) de la pagina o de un elemento CSS, con truncado opcional. Alternativa compacta a cdp_get_html cuando solo se necesita el texto legible."
tags: [cdp, browser, read, perception, navegator]
uses_functions: [cdp_evaluate_go_browser]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [encoding/json, fmt, unicode/utf8]
params:
- name: c
desc: "Conexion CDP activa a una tab de Chrome. Debe estar conectada a una tab tipo 'page'."
- name: selector
desc: "Selector CSS del elemento del que leer el innerText. Si es cadena vacia, lee document.body.innerText (toda la pagina)."
- name: maxBytes
desc: "Limite maximo de bytes del texto retornado. Si es <= 0 no hay limite. Si el texto supera el limite, se trunca con corte rune-safe y se añade un sufijo con el total original."
output: "Texto visible del elemento o de toda la pagina. Si maxBytes > 0 y el texto supera el limite, retorna el texto truncado con sufijo '…[truncado, total N bytes]'. Error si el selector no matchea ningun elemento o si la conexion falla."
tested: false
tests: []
test_file_path: ""
file_path: "functions/browser/cdp_get_text.go"
---
## Ejemplo
```go
// Leer todo el body con limite de 20000 bytes (apto para LLM)
text, err := CdpGetText(conn, "", 20000)
if err != nil {
log.Fatal(err)
}
fmt.Println(text)
// Leer un elemento concreto sin limite
price, err := CdpGetText(conn, ".product-price", 0)
if err != nil {
// err contiene "elemento no encontrado: .product-price" si no existe en el DOM
log.Fatal(err)
}
fmt.Println(price)
```
## Cuando usarla
Para que un LLM lea el contenido de una pagina sin reventar su ventana de contexto. Preferir sobre `cdp_get_html` cuando solo necesitas el texto — innerText es 5-50x mas compacto que el HTML crudo. Usar `selector` para acotar a la seccion relevante (articulo, tabla, formulario) y `maxBytes` para garantizar el presupuesto de tokens.
## Gotchas
- `innerText` solo devuelve el texto de nodos visibles: elementos con `display:none` o `visibility:hidden` quedan excluidos. Si necesitas leer contenido oculto usa `cdp_get_html` y parsea.
- El truncado corta en boundary de rune pero puede partir a media frase o a medio parrafo. Si necesitas preservar estructura semantica, ajusta `maxBytes` con margen o usa el selector para acotar la region.
- Requiere conexion activa a una tab de tipo `page` (no `background_page`, no `service_worker`). Tabs en estado de carga pueden devolver texto parcial; esperar con `cdp_wait_load` si el contenido es dinamico.
- El selector se escapa via `json.Marshal` — caracteres especiales como comillas simples, backslash o comillas dobles en el selector CSS son seguros.
+35
View File
@@ -0,0 +1,35 @@
package browser
import "fmt"
// CdpHandleDialog instala un auto-handler que responde automaticamente a todos
// los dialogos JS (alert, confirm, prompt, beforeunload) hasta que se llame
// la funcion cancel devuelta. Usa el evento Page.javascriptDialogOpening y
// Page.handleJavaScriptDialog del protocolo CDP.
//
// IMPORTANTE: el handler interno despacha la respuesta en una goroutine nueva
// para evitar deadlock — el evento llega en la goroutine de lectura del
// WebSocket, y sendCDP bloquea esperando una respuesta que leeria esa misma
// goroutine si se llamara de forma sincrona.
func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), error) {
if c == nil {
return nil, fmt.Errorf("cdp handle dialog: conexion nula")
}
if _, err := c.sendCDP("Page.enable", nil); err != nil {
return nil, fmt.Errorf("cdp handle dialog: %w", err)
}
cancel := c.OnEvent("Page.javascriptDialogOpening", func(method string, params map[string]any) {
p := map[string]any{"accept": accept}
if promptText != "" {
p["promptText"] = promptText
}
// go es OBLIGATORIO: el handler corre en la goroutine de lectura del
// WebSocket. Llamar sendCDP aqui directamente provoca deadlock porque
// sendCDP espera una respuesta que la misma goroutine deberia leer.
go c.sendCDP("Page.handleJavaScriptDialog", p) //nolint:errcheck
})
return cancel, nil
}
+74
View File
@@ -0,0 +1,74 @@
---
id: cdp_handle_dialog_go_browser
name: cdp_handle_dialog
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
tests: []
test_file_path: ""
description: "Instala un auto-handler que responde automaticamente a dialogos JS (alert/confirm/prompt/beforeunload) via Page.javascriptDialogOpening CDP hasta que se llame el cancel devuelto."
tags: [cdp, browser, dialog, input, navegator]
signature: "func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), error)"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_handle_dialog.go"
example: |
// Aceptar automaticamente confirm() antes de navegar
cancel, _ := CdpHandleDialog(c, true, "")
defer cancel()
_ = CdpClick(c, "#delete-account-btn")
_ = CdpWaitIdle(c, 2000)
params:
- name: c
desc: "Conexion CDP activa obtenida con CdpConnect."
- name: accept
desc: "true para aceptar/OK el dialogo; false para rechazar/Cancel. Para alert() el valor no importa (siempre se cierra), para confirm() determina el valor de retorno, para prompt() determina si se devuelve el texto o null."
- name: promptText
desc: "Texto a inyectar en dialogos prompt(). Vacio string para no inyectar texto. Ignorado en alert() y confirm()."
output: "cancel func() para des-registrar el handler cuando ya no se necesite, y error si la conexion es nula o Page.enable falla. El cancel devuelto es seguro llamarlo multiples veces."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
_ = CdpNavigate(conn, "https://example.com/admin")
_ = CdpWaitLoad(conn, 3000)
// Instalar handler antes de la accion que dispara el dialogo
cancel, err := CdpHandleDialog(conn, true, "")
if err != nil {
log.Fatal(err)
}
defer cancel()
// Este boton dispara confirm("¿Seguro que quieres borrar?")
// El handler lo acepta automaticamente sin bloquear
_ = CdpClick(conn, "#btn-delete-all")
_ = CdpWaitIdle(conn, 2000)
// Ejemplo con prompt(): responder con texto especifico
cancelPrompt, _ := CdpHandleDialog(conn, true, "mi-respuesta-secreta")
defer cancelPrompt()
_ = CdpClick(conn, "#btn-ask-password")
_ = CdpWaitIdle(conn, 1000)
```
## Cuando usarla
Instalar antes de cualquier accion que pueda disparar `alert()`, `confirm()`, `prompt()` o `beforeunload` en la pagina. Sin este handler, el dialogo bloquea el tab del navegador indefinidamente y todas las llamadas CDP siguientes se quedan colgadas esperando. Imprescindible en scraping de paneles de administracion, flujos de borrado con confirmacion, y paginas con `beforeunload` que pregunta si quieres salir.
## Gotchas
- DEADLOCK GARANTIZADO si se llama `sendCDP` de forma sincrona dentro del handler de evento. El handler corre en la goroutine de lectura del WebSocket; `sendCDP` espera una respuesta que esa misma goroutine deberia leer. La implementacion ya usa `go c.sendCDP(...)` para evitarlo — no modificar este patron.
- El handler se instala de forma permanente hasta que se llame `cancel()`. Si la pagina dispara multiples dialogos, todos seran respondidos con los mismos parametros `accept` y `promptText`.
- `Page.enable` es idempotente pero tiene coste de red; no llamar CdpHandleDialog en bucles tight.
- Para `beforeunload` (cuando el usuario cierra/navega fuera), `accept: true` permite la navegacion y `accept: false` la bloquea.
- Llamar `cancel()` no cierra dialogos ya abiertos; solo evita que los futuros sean respondidos automaticamente.
+19
View File
@@ -0,0 +1,19 @@
package browser
import "fmt"
// CdpHoverRef mueve el ratón con trayectoria humanizada (Bézier) sobre el
// elemento del #ref. Útil para activar menús y tooltips que reaccionan a hover.
// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive.
func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error {
if c == nil {
return fmt.Errorf("cdp hover ref: conexión nil")
}
// scroll al elemento si no está visible; ignorar error (no fatal)
_, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID})
cx, cy, err := refBoxCenter(c, backendNodeID)
if err != nil {
return fmt.Errorf("cdp hover ref: %w", err)
}
return CdpMoveMouseHuman(c, cx, cy, opts)
}
+53
View File
@@ -0,0 +1,53 @@
---
name: cdp_hover_ref
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error"
description: "Mueve el ratón con trayectoria humanizada (Bézier) sobre el elemento identificado por su #ref del AX outline. Útil para activar menús desplegables, tooltips y cualquier interacción que dependa de hover. El #ref es el backendDOMNodeId estable del nodo DOM."
tags: [cdp, browser, action, ref, humanized, navegator]
uses_functions: [cdp_move_mouse_human_go_browser]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
params:
- name: c
desc: "Conexión CDP activa al tab objetivo."
- name: backendNodeID
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
- name: opts
desc: "Opciones de trayectoria humanizada (jitter, velocidad, curva Bézier). Zero-value da humanización por defecto."
output: "nil si el movimiento de ratón se completó; error si la conexión es nil, el nodo no tiene boxModel visible, o el movimiento CDP falla."
file_path: "functions/browser/cdp_hover_ref.go"
---
## Ejemplo
```go
// Activar un menú desplegable cuyo trigger tiene #ref=9999:
conn, _ := CdpConnect(9222)
err := CdpHoverRef(conn, 9999, MouseHumanOpts{})
if err != nil {
log.Fatal(err)
}
// esperar a que el menú aparezca y re-percibir el outline
```
## Cuando usarla
Tras `page_perceive` / `render_ax_outline`, cuando el agente necesita hacer hover sobre un elemento del `#ref` para revelar contenido oculto (menús, submenús, tooltips, dropdowns) — cierra el bucle percibir→actuar para interacciones hover. Seguir con otro `page_perceive` tras el hover para capturar el nuevo estado del DOM.
## Gotchas
- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir antes de actuar.
- `DOM.getBoxModel` falla si el elemento no está en el DOM renderizado. El error describe la causa.
- `DOM.scrollIntoViewIfNeeded` se invoca antes del cálculo de coordenadas pero su fallo se ignora (no fatal).
- Solo mueve el ratón — no hace click. Para activar elementos que requieren click usar `CdpClickRef`.
- Algunos menús hover requieren un pequeño `time.Sleep` o `CdpWaitIdle` tras el hover para que el DOM se actualice antes del siguiente `page_perceive`.
+73
View File
@@ -0,0 +1,73 @@
package browser
import (
"fmt"
)
// CdpFrame representa un frame/iframe del arbol de navegacion.
type CdpFrame struct {
ID string `json:"id"`
ParentID string `json:"parentId"`
URL string `json:"url"`
Name string `json:"name"`
}
// CdpListFrames lista todos los frames de la pagina actual (frame raiz + iframes anidados)
// usando Page.getFrameTree. Retorna el arbol aplanado con cada frame y su parentId.
func CdpListFrames(c *CDPConn) ([]CdpFrame, error) {
if c == nil {
return nil, fmt.Errorf("cdp list frames: conexion nula")
}
// Page.enable es idempotente; necesario para que Page.getFrameTree funcione
if _, err := c.sendCDP("Page.enable", nil); err != nil {
return nil, fmt.Errorf("cdp list frames: Page.enable: %w", err)
}
result, err := c.sendCDP("Page.getFrameTree", nil)
if err != nil {
return nil, fmt.Errorf("cdp list frames: Page.getFrameTree: %w", err)
}
frameTree, ok := result["frameTree"].(map[string]any)
if !ok {
return nil, fmt.Errorf("cdp list frames: frameTree no encontrado en respuesta")
}
var frames []CdpFrame
frameFlatten(frameTree, "", &frames)
return frames, nil
}
// frameFlatten recorre recursivamente el arbol de frames CDP y acumula CdpFrame.
// parentID es el ID del nodo padre; el frame raiz lo recibe vacio.
func frameFlatten(node map[string]any, parentID string, acc *[]CdpFrame) {
frameData, ok := node["frame"].(map[string]any)
if !ok {
return
}
f := CdpFrame{
ID: stringField(frameData, "id"),
ParentID: parentID,
URL: stringField(frameData, "url"),
Name: stringField(frameData, "name"),
}
*acc = append(*acc, f)
// Recorrer hijos
children, _ := node["childFrames"].([]any)
for _, child := range children {
childNode, ok := child.(map[string]any)
if !ok {
continue
}
frameFlatten(childNode, f.ID, acc)
}
}
// stringField extrae un campo string de un map[string]any de forma segura.
func stringField(m map[string]any, key string) string {
v, _ := m[key].(string)
return v
}
+62
View File
@@ -0,0 +1,62 @@
---
id: cdp_list_frames_go_browser
name: cdp_list_frames
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Lista todos los frames/iframes de la pestaña activa usando Page.getFrameTree y devuelve el árbol aplanado con ID, parentID, URL y nombre de cada frame."
tags: [cdp, browser, iframe, frames, page, navegator]
signature: "func CdpListFrames(c *CDPConn) ([]CdpFrame, error)"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_list_frames.go"
example: |
conn, _ := CdpConnect("localhost", 9222, "")
frames, err := CdpListFrames(conn)
for _, f := range frames {
fmt.Printf("frame %s parent=%s url=%s\n", f.ID, f.ParentID, f.URL)
}
params:
- name: c
desc: "Conexión CDP activa obtenida con CdpConnect; apunta a la pestaña cuyo árbol de frames se quiere inspeccionar."
output: "Slice de CdpFrame con ID, ParentID, URL y Name de cada frame aplanado; error si la conexión es nula, Page.enable falla o la respuesta CDP es inesperada."
---
## Ejemplo
```go
conn, err := CdpConnect("localhost", 9222, "")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
frames, err := CdpListFrames(conn)
if err != nil {
log.Fatal(err)
}
for _, f := range frames {
fmt.Printf("id=%-40s parent=%-40s url=%s\n", f.ID, f.ParentID, f.URL)
}
// Salida ejemplo:
// id=ABCD1234 parent= url=https://example.com
// id=EFGH5678 parent=ABCD1234 url=https://ads.example.com/iframe
```
## Cuando usarla
Antes de evaluar JS en un iframe con `CdpEvalInFrame`: necesitas el `frameID` exacto que usa CDP, no el `src` del iframe. También útil para auditar la estructura de frames de una página o detectar iframes de terceros.
## Gotchas
- Requiere que la pestaña ya esté cargada; si se llama justo tras `CdpNavigate` en páginas con lazy-load de iframes, puede devolver un listado incompleto — espera a `Page.loadEventFired` o usa un breve delay.
- `Page.enable` se llama internamente (idempotente); no hace falta llamarlo manualmente antes.
- El frame raíz tiene `ParentID` vacío. Los iframes anidados tienen como `ParentID` el `ID` del frame contenedor.
- `Name` puede ser vacío si el `<iframe>` no tiene atributo `name`.

Some files were not shown because too many files have changed in this diff Show More