Compare commits
28 Commits
auto/0129
...
7eef442444
| Author | SHA1 | Date | |
|---|---|---|---|
| 7eef442444 | |||
| 876020addf | |||
| 469e40ba40 | |||
| fce88032ca | |||
| fec8ebd4ec | |||
| f5f05e4624 | |||
| 532f3d0ea8 | |||
| fe65c5e527 | |||
| de9bfec498 | |||
| e9c64a4687 | |||
| 70ec825e32 | |||
| 22692c1ed2 | |||
| d128ad89ac | |||
| bd9f0d8437 | |||
| 207c08c3b7 | |||
| 01bc2aeb14 | |||
| 9ec7751f6f | |||
| fef86250a0 | |||
| 472b6092bb | |||
| ea5c94fc8a | |||
| a8b09ad154 | |||
| 6aa874f2b6 | |||
| 93352a7780 | |||
| 0ffae6daa4 | |||
| 74b58cf0d0 | |||
| 9752fb106a | |||
| 8cb0121573 | |||
| 90115270d2 |
+58
-21
@@ -1,37 +1,74 @@
|
||||
# /compile — Compila app C++ y la copia al escritorio de Windows
|
||||
---
|
||||
description: "Compila app del registry (C++ o Wails Go), copia el .exe a Desktop/apps/<app>/ y relanza en Windows. Wrapper sobre compile_cpp_app o compile_wails_app segun framework declarado en app.md."
|
||||
---
|
||||
|
||||
Wrapper sobre el pipeline `compile_cpp_app_bash_pipelines`. Toda la lógica vive en el registry (resolver app desde CWD/arg, cross-compile MinGW, copiar exe + DLLs + assets/ + enrichers/ + runtime/ a `/mnt/c/Users/lucas/Desktop/apps/<app>/`, taskkill previo, preservar `local_files/`).
|
||||
# /compile — Compila app C++ o Wails y la copia al escritorio de Windows
|
||||
|
||||
Wrapper sobre 2 pipelines del registry segun el framework:
|
||||
|
||||
- **C++ (imgui / cmake)** → `compile_cpp_app_bash_pipelines`. Cross-compile MinGW + assets/enrichers/runtime + taskkill, NO relanza.
|
||||
- **Wails Go (matrix_client_pc, matrix_admin_panel, etc.)** → `compile_wails_app_bash_pipelines`. `wails build -platform windows/amd64` con `-tags goolm` si E2EE + taskkill + **RELANZA** la app tras copy.
|
||||
|
||||
Toda la logica vive en el registry (resolver app desde CWD/arg, build, deploy con preservacion de `local_files/`).
|
||||
|
||||
## Dispatch
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
./fn run compile_cpp_app "$ARGUMENTS"
|
||||
|
||||
# Detecta framework via wails.json o CMakeLists.txt en el dir del app
|
||||
APP="$ARGUMENTS"
|
||||
RESOLVED=$(bash -c '
|
||||
source bash/functions/infra/resolve_cpp_app_dir.sh
|
||||
resolve_cpp_app_dir "'"$APP"'"
|
||||
' 2>/dev/null) || true
|
||||
APP_DIR="$(echo "$RESOLVED" | cut -f2)"
|
||||
|
||||
if [ -n "$APP_DIR" ] && [ -f "$APP_DIR/wails.json" ]; then
|
||||
./fn run compile_wails_app "$ARGUMENTS"
|
||||
elif [ -n "$APP_DIR" ] && [ -f "$APP_DIR/CMakeLists.txt" ]; then
|
||||
./fn run compile_cpp_app "$ARGUMENTS"
|
||||
else
|
||||
echo "ERROR: no se detecto framework (falta wails.json o CMakeLists.txt en $APP_DIR)" >&2
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## Argumento
|
||||
|
||||
`$ARGUMENTS` — opcional. Nombre de app (ej: `chart_demo`).
|
||||
`$ARGUMENTS` — opcional. Nombre de app (ej: `chart_demo`, `matrix_client_pc`).
|
||||
|
||||
- Sin argumento: deduce desde `pwd` si estás dentro de `cpp/apps/<X>/` o `projects/*/apps/<X>/`.
|
||||
- Si no se puede deducir y no se pasa argumento, el pipeline lista las apps disponibles en stderr y aborta.
|
||||
- Sin argumento: deduce desde `pwd` si estas dentro de `cpp/apps/<X>/`, `apps/<X>/` o `projects/*/apps/<X>/`.
|
||||
- Si no se puede deducir y no se pasa argumento, lista las apps disponibles en stderr y aborta.
|
||||
|
||||
## Qué hace el pipeline
|
||||
## Que hace el pipeline (C++)
|
||||
|
||||
1. `resolve_cpp_app_dir_bash_infra` — resuelve `<app_name>` y `<dir absoluto>` desde arg o CWD.
|
||||
2. Verifica `CMakeLists.txt` en el dir resuelto.
|
||||
3. `build_cpp_windows_bash_infra <app>` — cross-compila el target específico con `cpp/build/windows/` (configura toolchain `mingw-w64.cmake` la primera vez).
|
||||
1. `resolve_cpp_app_dir_bash_infra` — resuelve `<app_name>` y `<dir absoluto>`.
|
||||
2. Verifica `CMakeLists.txt`.
|
||||
3. `build_cpp_windows_bash_infra <app>` — cross-compila con MinGW.
|
||||
4. `deploy_cpp_exe_to_windows_bash_infra <app> <dir>`:
|
||||
- `taskkill.exe /IM <app>.exe /F` (pre-autorizado).
|
||||
- Copia `<app>.exe` + DLLs al top-level de `Desktop/apps/<app>/`.
|
||||
- rsync `cpp/build/windows/apps/<app>/assets/` → `Desktop/apps/<app>/assets/`.
|
||||
- rsync `<app_dir>/enrichers/` → `assets/enrichers/` si existe.
|
||||
- Si `app.md` declara `python_runtime: true`, regenera `runtime/` con `tools/freeze_python_runtime.sh` y rsync a `assets/runtime/`.
|
||||
- Copia `gx-cli`/`gx-cli.exe` si existen.
|
||||
- **NUNCA** toca `local_files/` (estado del usuario).
|
||||
5. Imprime `ls -lh` del `.exe` final.
|
||||
- `taskkill.exe /IM <app>.exe /F`.
|
||||
- Copia `<app>.exe` + DLLs.
|
||||
- rsync `assets/`, `enrichers/`, `runtime/` (si aplica).
|
||||
- Preserva `local_files/`.
|
||||
- **NO** relanza.
|
||||
|
||||
## Que hace el pipeline (Wails)
|
||||
|
||||
1. `resolve_cpp_app_dir_bash_infra` (reusado — sirve para Wails apps tambien).
|
||||
2. Verifica `wails.json` + `go.mod`.
|
||||
3. Detecta `-tags goolm` automaticamente (grep `matrix_crypto_init` en `app.md` o `build:tags` en `wails.json`).
|
||||
4. `wails build -platform windows/amd64 [-tags goolm]`.
|
||||
5. `deploy_wails_exe_to_windows_bash_infra <app> <dir>`:
|
||||
- `taskkill.exe /IM <app>.exe /F`.
|
||||
- Copia `<app>.exe` (+ `appicon.ico` si existe).
|
||||
- **Relanza** via `cmd.exe /c start "" <app>.exe`.
|
||||
- Preserva `local_files/`.
|
||||
|
||||
## Notas
|
||||
|
||||
- Solo target Windows hoy. Android / Linux quedan fuera (Linux ya lo da `cpp/build/`).
|
||||
- Solo target Windows hoy. Linux ya lo da `wails build` / `cpp/build/` nativo.
|
||||
- Variables override-ables: `BUILD_WIN`, `WIN_DESKTOP_APPS`, `FN_REGISTRY_ROOT`.
|
||||
- Si la app no está registrada en `cpp/CMakeLists.txt`, `cmake --build --target <app>` falla. Registrar siguiendo `.claude/rules/cpp_apps.md` §5.
|
||||
- Para tocar la lógica: editar `bash/functions/{infra,pipelines}/{resolve_cpp_app_dir,deploy_cpp_exe_to_windows,compile_cpp_app}.sh`, no este wrapper.
|
||||
- Si la app C++ no esta registrada en `cpp/CMakeLists.txt`, el build falla — registrar siguiendo `.claude/rules/cpp_apps.md` §5.
|
||||
- Si la app Wails falla build con `no required module provides package`, correr `go mod tidy` en el dir del app primero.
|
||||
- Para tocar la logica: editar `bash/functions/{infra,pipelines}/{resolve_cpp_app_dir,build_cpp_windows,deploy_{cpp,wails}_exe_to_windows,compile_{cpp,wails}_app}.sh`, no este wrapper.
|
||||
|
||||
@@ -38,3 +38,4 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
||||
| 31 | [autonomous_loop.md](autonomous_loop.md) | Reglas para `fn-orquestador` + `/autonomous-task`: sandbox obligatorio, paths protegidos, filtro proposals auto-aplicables, watchdog, idempotencia. Issue 0069 |
|
||||
| 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. |
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# DoD Quality Triada
|
||||
|
||||
**Definition of Done no es un checkbox que se marca a mano. Es un contrato de calidad con 3 capas obligatorias + evidencia ejecutable + uso real >=7 dias.**
|
||||
|
||||
Aplica a todos los `dev/flows/` y, por extension, a issues que cierran capabilities user-facing (`dev/issues/`). El registry mismo (funciones puras, tipos) queda exento: su DoD vive en sus tests unitarios.
|
||||
|
||||
---
|
||||
|
||||
## Por que existe esta regla
|
||||
|
||||
El antipatron a eliminar: "tarea hecha porque pase los tests una vez". Despues:
|
||||
- El flow funciona en `home-wsl` pero falla en `pc-aurgi`.
|
||||
- El error path declarado nunca se ejercito y cuando ocurre en produccion no esta manejado.
|
||||
- El dashboard de observabilidad lleva 30 dias sin abrirse.
|
||||
- El proceso muere cada noche y nadie lo ve hasta que el operador intenta usarlo.
|
||||
- El approval flow se salta porque "para test es mas comodo".
|
||||
|
||||
Resultado: deuda invisible. Cada flow "done" se rompe al primer uso real, el operador pierde confianza en el sistema, y el bucle reactivo no detecta nada porque la telemetria esta verde (los tests sintenticos pasan).
|
||||
|
||||
DoD Quality Triada cambia las reglas: cerrar = probar comportamiento + sobrevivir uso real, no = compilar verde.
|
||||
|
||||
---
|
||||
|
||||
## Las 3 capas
|
||||
|
||||
### Capa 1: Mecanica (pre-requisito, NO es DoD por si misma)
|
||||
|
||||
Compilar verde, tests verdes, indexado limpio, `fn doctor` verde, `uses_functions` sin drift.
|
||||
|
||||
**Regla**: la mecanica NO basta. Es la base para empezar a probar comportamiento. Si te quedas aqui, el flow no esta hecho.
|
||||
|
||||
### Capa 2: Cobertura de comportamiento
|
||||
|
||||
Cada escenario relevante con prueba ejecutable y assert material. NO smoke "el comando no peto". Minimo:
|
||||
|
||||
- **1 golden path** — el caso feliz documentado con assert sobre output concreto.
|
||||
- **>=2 edge cases** — inputs limite, estados raros, condiciones de borde.
|
||||
- **>=1 error path** — fallo provocado intencionalmente, manejado y observable (sin crash, sin silent-fail).
|
||||
|
||||
Formato canonico (tabla en `## Definition of Done` del flow/issue):
|
||||
|
||||
```markdown
|
||||
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||
|---|---|---|---|
|
||||
| Golden: <desc> | unit / e2e | `<cmd>` | <output concreto> |
|
||||
| Edge 1: <desc> | unit / e2e | `<cmd>` | <comportamiento concreto> |
|
||||
| Error 1: <desc> | e2e | `<cmd que rompe>` | <fallo manejado, no crash> |
|
||||
```
|
||||
|
||||
Cuando aplique, cada fila genera un `e2e_check` en el `app.md` correspondiente (issue 0068). `fn-analizador` los corre periodicamente y deja entry en `e2e_runs`.
|
||||
|
||||
### Capa 3: Vida util validada
|
||||
|
||||
El flow no esta hecho hasta que sobrevive **uso real durante >=7 dias** sin romperse silenciosamente. Cada metrica con umbral medible y dashboard observable.
|
||||
|
||||
Formato canonico:
|
||||
|
||||
```markdown
|
||||
| Metrica | Umbral | Donde se observa | Ventana |
|
||||
|---|---|---|---|
|
||||
| <metrica 1> | `>=N` | `<dashboard URL / app panel>` | 7 dias |
|
||||
| crashes | `0` | `journalctl -u <unit>` | 7 dias |
|
||||
| huecos audit chain | `0` | `cmd: <verify>` | continuo |
|
||||
```
|
||||
|
||||
Reglas:
|
||||
- Metricas NO se auto-reportan; las lee el operador del dashboard real.
|
||||
- Si el dashboard no existe o no se ha abierto en 30 dias, el item se invalida.
|
||||
- Crashes del proceso = 0, huecos en audit = 0, error_rate < umbral declarado.
|
||||
|
||||
### Capa transversal: User-facing reforzado
|
||||
|
||||
- Surface concreta NO BD ni log (UI app, room Matrix, dashboard, archivo en vault).
|
||||
- Usage real: humano usa en su PC, su contexto, >=N veces variadas en >=7 dias.
|
||||
- Variado: >=3 capabilities/casos distintos (no solo "abre dashboard y mira").
|
||||
- Onboarding: parrafo en `## Notas` que explica como usar la cosa sin leer el flow.
|
||||
- Latencia medida (no declarada).
|
||||
|
||||
---
|
||||
|
||||
## Reglas duras para marcar `status: done`
|
||||
|
||||
`/flow done` (y por extension cierres de issues user-facing) DEBE rechazar el cierre si:
|
||||
|
||||
1. Falta cualquiera de las 3 capas (mecanica + cobertura + vida).
|
||||
2. Cobertura tiene <1 golden, <2 edge, o <1 error path con evidencia.
|
||||
3. Vida util tiene tabla vacia o sin dashboard observable real.
|
||||
4. User-facing usage real <7 dias o <N usos declarados.
|
||||
5. Cualquier anti-criterio marcado como cierto.
|
||||
6. `## Notas` sin parrafo onboarding.
|
||||
7. Algun item de DoD sin comando/URL/log query asociado — solo texto.
|
||||
|
||||
Hoy parte de esta validacion es manual (revision humana del operador). La validacion programatica vive en `audit_dod_schema_go_infra` (issue 0114) + `fn doctor dod` y se ampliara hasta cubrir las 3 capas (TBD).
|
||||
|
||||
---
|
||||
|
||||
## Antipatrones (invalidan la DoD aunque los checkboxes esten verdes)
|
||||
|
||||
| Antipatron | Por que es malo | Sustituir por |
|
||||
|---|---|---|
|
||||
| Marcar `done` porque pasa una vez | Tarea "hecha" se rompe al primer uso real | Capa 3: >=7 dias de uso real |
|
||||
| Checkbox sin evidencia ejecutable | DoD se convierte en placebo | Cada item con `cmd:` / URL / log query |
|
||||
| Test que solo verifica camino feliz | El error path es donde se pierden datos | Capa 2: >=1 error path ejercitado |
|
||||
| Observabilidad declarada pero dashboard no abierto en 30 dias | Telemetria muerta = ceguera | Capa 3: dashboard real, operador lo abre |
|
||||
| "Repetible 3 veces consecutivas" con BD efimera | No prueba sobre datos reales acumulados | Capa 3: PC real del operador, datos vivos |
|
||||
| Approval saltado en algun camino | Security gate roto pero invisible | Anti-criterio explicito: `audit_log` lo prueba |
|
||||
| Error path manejado solo "en teoria" | Cuando ocurra en produccion el manejo no existe | Capa 2: entry real en `e2e_runs` o audit |
|
||||
| Solo-en-mi-PC | Falla en otra maquina del operador | Anti-criterio explicito, probar >=2 PCs |
|
||||
| Self-test que retorna `pass` sin asserts materiales | False positive sistemico | Asserts sobre output concreto, no exit-0 |
|
||||
| Silent-fail (proceso muere sin alerta) | Operador no se entera hasta intentar usar | Capa 3: crashes=0 + alerta visible |
|
||||
|
||||
---
|
||||
|
||||
## Relacion con otras reglas
|
||||
|
||||
- [[e2e_validation]] — los escenarios de Capa 2 cuando aplican a apps se materializan como `e2e_checks` en `app.md`. `fn-analizador` (fase 4 del bucle reactivo) los corre.
|
||||
- [[registry_calls]] — la evidencia de uso (`call_monitor.calls`) alimenta los umbrales de Capa 3.
|
||||
- [[function_growth_and_self_docs]] — cada funcion del registry tiene su propio contrato self-doc (Ejemplo + Cuando usarla + Gotchas). DoD del flow NO sustituye al self-doc de la funcion; lo complementa para el nivel sistema.
|
||||
- [[autonomous_loop]] — `fn-orquestador` autonomo NO puede marcar `done` sin que se cumplan las 3 capas. Su criterio de convergencia incluye DoD Quality.
|
||||
- [[apps_tbd]] — TBD garantiza master desplegable; DoD garantiza que lo desplegado funciona en uso real.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
1. **Mecanica** = compilar verde (pre-requisito, NO suficiente).
|
||||
2. **Cobertura** = golden + >=2 edge + >=1 error path con evidencia ejecutable.
|
||||
3. **Vida util** = >=7 dias de uso real sin romper silenciosamente, dashboard observable abierto.
|
||||
4. **User-facing reforzado** = humano usa en PC real, >=N veces variadas.
|
||||
5. **Anti-criterios** invalidan la DoD aunque todo este verde.
|
||||
6. Sin evidencia ejecutable (cmd/URL/log), NO es DoD: es deseo.
|
||||
@@ -3,6 +3,10 @@
|
||||
"registry": {
|
||||
"command": "./apps/registry_mcp/registry_mcp",
|
||||
"args": ["--enable-run", "--enable-write"]
|
||||
},
|
||||
"jupyter": {
|
||||
"command": "bash",
|
||||
"args": ["/home/enmanuel/fn_registry/bash/functions/infra/jupyter_mcp_serve.sh"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[2026-05-22 23:18:14.872] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:24:12.811] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:24:14.628] [INFO] [connect] testing https://agents.organic-machine.com...
|
||||
[2026-05-22 23:24:14.758] [INFO] [connect] OK
|
||||
[2026-05-22 23:24:14.765] [INFO] [db] base_url saved
|
||||
[2026-05-22 23:24:14.765] [INFO] [fetch_agents] starting
|
||||
[2026-05-22 23:24:14.766] [INFO] [fetch_agents] requesting https://agents.organic-machine.com/agents
|
||||
[2026-05-22 23:24:14.903] [INFO] [fetch_agents] response status=200 err= body_len=3146
|
||||
[2026-05-22 23:24:14.904] [INFO] [fetch_agents] parsed 11 rows
|
||||
[2026-05-22 23:24:14.904] [INFO] [fetch_agents] done
|
||||
[2026-05-22 23:24:14.910] [INFO] [agents_panel] render n_rows=11 cells=121 specs=11
|
||||
[2026-05-22 23:27:07.469] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:27:08.242] [INFO] [agents_panel] render n_rows=11 cells=121 specs=11
|
||||
[2026-05-22 23:27:36.670] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:27:37.446] [INFO] [agents_panel] render n_rows=11 cells=121 specs=11
|
||||
[2026-05-22 23:28:07.068] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:30:03.025] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:30:38.605] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:30:48.267] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:40:58.931] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:41:16.455] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:42:35.646] [INFO] app start: Agents Dashboard
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
name: chrome_load_extensions
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "chrome_load_extensions [--port N] [--profile DIR] --ext PATH [--ext PATH ...] [--proxy URL] [--url URL]"
|
||||
description: "Lanza Chrome con extensiones unpacked via --load-extension (WSL2→Windows chrome.exe, paths traducidos, join sin echo, setsid anti-exit-144). OJO: --load-extension SOLO funciona en Chrome for Testing/Chromium/Dev. En Chrome STABLE 138+ esta DESACTIVADO (feature DisableLoadExtensionCommandLineSwitch + bloqueo duro en 148) y carga 0 extensiones aunque el cmdline sea correcto. Para Chrome stable usar install via Web Store (1-clic, persiste en perfil) o enterprise policy ExtensionInstallForcelist (requiere HKLM/HKCU Policies escribible — denegado en maquinas gestionadas)."
|
||||
tags: [chrome, cdp, browser, extensions, wsl2, navegator]
|
||||
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 de remote debugging CDP. Default: 9222."
|
||||
- name: "--profile DIR"
|
||||
desc: "Chrome user-data-dir. Acepta ruta Windows (C:\\...) o ruta WSL/Linux (se traduce via wslpath -w). Default: C:\\Users\\<USERNAME>\\AppData\\Local\\fn-chrome-cdp-profile (WSL2) o /tmp/fn-chrome-cdp-profile (Linux nativo)."
|
||||
- name: "--ext PATH"
|
||||
desc: "Ruta a un directorio de extensión unpacked. Repetible. Acepta ruta Windows (se pasa intacta) o ruta WSL/Linux (se traduce via wslpath -w). Obligatorio al menos uno."
|
||||
- name: "--proxy URL"
|
||||
desc: "Proxy opcional, ej. http://127.0.0.1:8889. Agrega --proxy-server=URL a Chrome."
|
||||
- name: "--url URL"
|
||||
desc: "URL inicial opcional para abrir con --new-window."
|
||||
output: "PID del proceso Chrome lanzado (stdout). Mensajes de estado en stderr. CDP listo en 127.0.0.1:<port>."
|
||||
file_path: "bash/functions/browser/chrome_load_extensions.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/browser/chrome_load_extensions.sh
|
||||
|
||||
chrome_load_extensions \
|
||||
--port 9222 \
|
||||
--profile 'C:\Users\lucas\AppData\Local\fn-chrome-cdp-profile' \
|
||||
--ext 'C:\Users\lucas\hls-dl-ext' \
|
||||
--ext 'C:\Users\lucas\ubol' \
|
||||
--proxy http://127.0.0.1:8889 \
|
||||
--url https://www.gnularetro.cc/
|
||||
```
|
||||
|
||||
Sin proxy ni URL, sólo extensiones:
|
||||
|
||||
```bash
|
||||
source bash/functions/browser/chrome_load_extensions.sh
|
||||
|
||||
pid=$(chrome_load_extensions \
|
||||
--ext '/home/lucas/dev/hls-dl-ext' \
|
||||
--ext '/home/lucas/dev/ubol')
|
||||
# Paths WSL traducidos automáticamente a Windows.
|
||||
# CDP listo en 127.0.0.1:9222.
|
||||
echo "Chrome PID: $pid"
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites Chrome CDP con extensiones unpacked cargadas (HLS downloader, uBlock Origin, extensiones en desarrollo) y `chrome_launch_go_browser` no sirve porque hardcodea `--disable-extensions`. WSL2→Windows. Ideal para sesiones de navegator con proxy + extensión activa.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **MUERTO en Chrome STABLE 138+ (validado 2026-05-30, Chrome 148)**: `--load-extension` NO carga nada en el canal stable, ni con `--disable-extensions-except` ni con `--disable-features=DisableLoadExtensionCommandLineSwitch`. `chrome://version` muestra el flag correcto pero `chrome://extensions` sale vacío. Google lo bloqueó duro en stable. La función SOLO sirve en **Chrome for Testing / Chromium / Dev/Canary**, donde el switch sigue activo. Para stable: ver opciones abajo.
|
||||
- **Instalar en Chrome STABLE (las que SÍ funcionan)**:
|
||||
1. **Web Store 1-clic** — abre la página del store en el perfil CDP, el humano da "Añadir a Chrome". Persiste en el perfil para siempre (futuros lanzamientos ya con la extensión, sin flags). El popup de confirmación es UI del navegador (no DOM) → NO es CDP-clickable, requiere gesto humano. Único método no-admin que persiste por-perfil.
|
||||
2. **Enterprise policy** `ExtensionInstallForcelist` (HKCU/HKLM `\Software\Policies\Google\Chrome`) — force-install sin clic desde el store, browser-wide. El key `Policies\Google\Chrome` puede dar "Access denied" al escribir (visto 2026-05-30 incluso en máquina personal vía reg.exe/PowerShell desde WSL — Chrome/Windows protege el subárbol Policies). Si funciona, requiere relanzar Chrome para que descargue del store. Método global (afecta todos los perfiles).
|
||||
3. Extensiones **unpacked custom** (no en store, ej. un HLS downloader propio) en stable: no hay vía no-admin. Empaquetar a CRX + self-host `update_url` + policy, o usar Chrome for Testing. A menudo innecesario si la lógica vive fuera (ej. `grab_stream.py` descarga sin extensión).
|
||||
- **Combo flags (solo Chrome for Testing/dev)**: requiere AMBOS `--load-extension=p1,p2` Y `--disable-extensions-except=p1,p2` juntos + `--disable-features=DisableLoadExtensionCommandLineSwitch`. **NUNCA `--disable-extensions`** (desactiva todo).
|
||||
- **join sin `echo`**: rutas Windows `C:\Users\...` tienen `\U`; el `echo` de zsh (o sh con xpg_echo) lo interpreta como escape unicode y trunca la ruta a `C:`. La función usa acumulador `+=`, no `echo`. Verificable en `chrome://version` (debe verse el path completo, no `--load-extension=C:`).
|
||||
- **exit 144 en Bash tool**: si el proceso Chrome retiene el pipe stdout, la herramienta devuelve exit 144. Esta función lanza con `setsid ... </dev/null >log 2>&1 &` + `disown` para desacoplar completamente. El log queda en `/tmp/chrome_ext_<port>.log`.
|
||||
- **WSL2: traducir paths con `wslpath -w`**: los paths de `--ext` y `--profile` que sean rutas Linux se traducen automáticamente. Las rutas Windows (`C:\...`) se pasan intactas. `wslpath` debe estar disponible (estándar en WSL2 desde Windows 10 1903+).
|
||||
- **Perfil ya abierto**: si Chrome ya tiene ese perfil abierto, relanzar añade una ventana extra a la misma instancia. La función detecta si CDP ya responde en el puerto y avisa por stderr, pero procede igualmente.
|
||||
- **Web Store vs unpacked**: instalar extensiones desde la Web Store (un clic) persiste en el perfil sin necesidad de flags y sobrevive reinicios. Esta función es para extensiones unpacked en desarrollo o que no están en la Web Store. Si usas ambas, los flags no interfieren con las instaladas del store.
|
||||
- **zsh globbing**: `--remote-allow-origins=*` está dentro de comillas en la función, no se expande. Si lo pasas desde la línea de comandos, entrecomillarlo.
|
||||
- **Proxy + extensión**: si usas proxy para captura de tráfico (Burp, mitmproxy, gost), el proxy se aplica a toda la sesión Chrome, incluyendo el tráfico de las extensiones.
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env bash
|
||||
# chrome_load_extensions — lanza Chrome (WSL2→Windows chrome.exe) con extensiones unpacked cargadas en un perfil CDP.
|
||||
# Chrome 148+: requiere --load-extension=<paths> Y --disable-extensions-except=<same paths> juntos.
|
||||
# NUNCA pasar --disable-extensions (desactiva todo, incluyendo las que quieres cargar).
|
||||
|
||||
chrome_load_extensions() {
|
||||
local port=9222
|
||||
local profile=""
|
||||
local proxy=""
|
||||
local url=""
|
||||
local -a ext_paths=()
|
||||
|
||||
# --- Parse args ---
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port)
|
||||
port="$2"; shift 2 ;;
|
||||
--profile)
|
||||
profile="$2"; shift 2 ;;
|
||||
--ext)
|
||||
ext_paths+=("$2"); shift 2 ;;
|
||||
--proxy)
|
||||
proxy="$2"; shift 2 ;;
|
||||
--url)
|
||||
url="$2"; shift 2 ;;
|
||||
--*)
|
||||
echo "chrome_load_extensions: flag desconocido: $1" >&2; return 1 ;;
|
||||
*)
|
||||
# Positional = extra ext path
|
||||
ext_paths+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ${#ext_paths[@]} -eq 0 ]]; then
|
||||
echo "chrome_load_extensions: se requiere al menos un --ext PATH de extension unpacked" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- Detectar chrome.exe ---
|
||||
local chrome_bin=""
|
||||
if command -v chrome.exe &>/dev/null; then
|
||||
chrome_bin="chrome.exe"
|
||||
elif [[ -f "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe" ]]; then
|
||||
chrome_bin="/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"
|
||||
elif [[ -f "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe" ]]; then
|
||||
chrome_bin="/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe"
|
||||
else
|
||||
echo "chrome_load_extensions: chrome.exe no encontrado en PATH ni en rutas conocidas" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- Detectar WSL2 ---
|
||||
local wsl2=0
|
||||
if grep -qi 'microsoft\|wsl' /proc/version 2>/dev/null; then
|
||||
wsl2=1
|
||||
fi
|
||||
|
||||
# --- Traducir paths de extensiones a Windows si hace falta ---
|
||||
local -a win_ext_paths=()
|
||||
for p in "${ext_paths[@]}"; do
|
||||
if [[ $wsl2 -eq 1 ]] && [[ "$p" != [A-Za-z]:\\* ]]; then
|
||||
# Path Linux → traducir a Windows
|
||||
local win_p
|
||||
win_p=$(wslpath -w "$p" 2>/dev/null) || {
|
||||
echo "chrome_load_extensions: wslpath -w '$p' falló" >&2
|
||||
return 1
|
||||
}
|
||||
win_ext_paths+=("$win_p")
|
||||
else
|
||||
win_ext_paths+=("$p")
|
||||
fi
|
||||
done
|
||||
|
||||
# --- Resolver perfil ---
|
||||
if [[ -z "$profile" ]]; then
|
||||
# Default: perfil canónico fn-chrome-cdp-profile en Windows
|
||||
local win_user="${USERNAME:-${USER:-lucas}}"
|
||||
if [[ $wsl2 -eq 1 ]]; then
|
||||
profile="C:\\Users\\${win_user}\\AppData\\Local\\fn-chrome-cdp-profile"
|
||||
else
|
||||
profile="/tmp/fn-chrome-cdp-profile"
|
||||
fi
|
||||
elif [[ $wsl2 -eq 1 ]] && [[ "$profile" != [A-Za-z]:\\* ]]; then
|
||||
# Path Linux del perfil → traducir a Windows
|
||||
profile=$(wslpath -w "$profile" 2>/dev/null) || {
|
||||
echo "chrome_load_extensions: wslpath -w '$profile' falló" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
# --- Construir lista de paths separada por coma (para Chrome) ---
|
||||
# Chrome usa coma como separador en --load-extension y --disable-extensions-except.
|
||||
# NO usar `echo` para el join: rutas Windows como C:\Users tienen \U, y el echo de
|
||||
# zsh (o sh con xpg_echo) interpreta \U como escape unicode y trunca la ruta a "C:".
|
||||
# Acumulador con += y printf-safe, sin interpretacion de backslashes.
|
||||
local ext_list=""
|
||||
local p
|
||||
for p in "${win_ext_paths[@]}"; do
|
||||
ext_list+="${ext_list:+,}${p}"
|
||||
done
|
||||
|
||||
# --- Construir args de Chrome ---
|
||||
local -a args=(
|
||||
"--remote-debugging-port=${port}"
|
||||
"--user-data-dir=${profile}"
|
||||
"--no-first-run"
|
||||
"--no-default-browser-check"
|
||||
"--remote-allow-origins=*"
|
||||
"--load-extension=${ext_list}"
|
||||
"--disable-extensions-except=${ext_list}"
|
||||
# Chrome 137+ activa por defecto el feature DisableLoadExtensionCommandLineSwitch,
|
||||
# que IGNORA silenciosamente --load-extension. Hay que desactivarlo o las
|
||||
# extensiones unpacked no cargan (chrome://extensions sale vacio).
|
||||
"--disable-features=DisableLoadExtensionCommandLineSwitch"
|
||||
)
|
||||
|
||||
# WSL2: bind en 0.0.0.0 para que sea accesible desde la red WSL
|
||||
if [[ $wsl2 -eq 1 ]]; then
|
||||
args+=("--remote-debugging-address=0.0.0.0")
|
||||
fi
|
||||
|
||||
if [[ -n "$proxy" ]]; then
|
||||
args+=("--proxy-server=${proxy}")
|
||||
fi
|
||||
|
||||
if [[ -n "$url" ]]; then
|
||||
args+=("--new-window" "$url")
|
||||
fi
|
||||
|
||||
# --- Revisar si CDP ya responde en el puerto ---
|
||||
if curl -sf --max-time 1 "http://127.0.0.1:${port}/json/version" &>/dev/null; then
|
||||
echo "chrome_load_extensions: CDP ya activo en puerto ${port}; lanzando ventana extra" >&2
|
||||
fi
|
||||
|
||||
# --- Lanzar Chrome desacoplado del proceso padre ---
|
||||
# setsid + redirección evita el exit 144 en el Bash tool (el pipe no queda retenido).
|
||||
setsid "$chrome_bin" "${args[@]}" </dev/null >"/tmp/chrome_ext_${port}.log" 2>&1 &
|
||||
local chrome_pid=$!
|
||||
disown "$chrome_pid"
|
||||
|
||||
echo "chrome_load_extensions: Chrome lanzado PID=${chrome_pid} puerto=${port}" >&2
|
||||
|
||||
# --- Esperar a que CDP esté listo (hasta 15 segundos) ---
|
||||
local deadline=$(( $(date +%s) + 15 ))
|
||||
local ready=0
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if curl -sf --max-time 1 "http://127.0.0.1:${port}/json/version" &>/dev/null; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
if [[ $ready -eq 1 ]]; then
|
||||
echo "chrome_load_extensions: CDP listo en 127.0.0.1:${port}"
|
||||
else
|
||||
echo "chrome_load_extensions: advertencia — CDP no respondió en 15s en puerto ${port}; Chrome puede estar iniciando lentamente" >&2
|
||||
fi
|
||||
|
||||
echo "$chrome_pid"
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: deploy_wails_exe_to_windows
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "deploy_wails_exe_to_windows <app_name> <app_dir>"
|
||||
description: "Copia el .exe de una app Wails desde <app_dir>/build/bin/<app>.exe al escritorio de Windows, mata el proceso anterior (taskkill /F) y relanza la app via cmd.exe. Single-binary: no copia DLLs (Webview2 nativo en SO). Preserva local_files/ si existe."
|
||||
tags: ["wails", "windows", "deploy", "cross-compile", "mingw", "infra", "launch", "matrix-mas"]
|
||||
params:
|
||||
- name: app_name
|
||||
desc: "Nombre del binario sin extension (ej. matrix_client_pc). Debe coincidir con el nombre del .exe generado por wails build."
|
||||
- name: app_dir
|
||||
desc: "Ruta absoluta al directorio raiz de la app, donde vive build/bin/<app>.exe. Puede estar en projects/<project>/apps/<app>/ o apps/<app>/."
|
||||
output: "Imprime pasos en stderr. En stdout: ls -lh del .exe desplegado. Exit 0 si ok, exit 1 si build/bin/<app>.exe no existe o los args estan vacios."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests:
|
||||
- "args vacios devuelven error con mensaje de uso"
|
||||
- "app_dir inexistente devuelve exit 1"
|
||||
- "build/bin exe inexistente devuelve exit 1"
|
||||
test_file_path: "bash/functions/infra/deploy_wails_exe_to_windows_test.sh"
|
||||
file_path: "bash/functions/infra/deploy_wails_exe_to_windows.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/deploy_wails_exe_to_windows.sh
|
||||
|
||||
# Desplegar matrix_client_pc tras wails build -platform windows/amd64
|
||||
deploy_wails_exe_to_windows matrix_client_pc \
|
||||
/home/lucas/fn_registry/projects/element_agents/apps/matrix_client_pc
|
||||
```
|
||||
|
||||
Con override de destino:
|
||||
|
||||
```bash
|
||||
WIN_DESKTOP_APPS=/mnt/c/Users/lucas/Desktop/apps \
|
||||
deploy_wails_exe_to_windows matrix_admin_panel \
|
||||
/home/lucas/fn_registry/projects/element_agents/apps/matrix_admin_panel
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Tras un `wails build -platform windows/amd64` exitoso, para desplegar el binario compilado en Windows y relanzarlo en el mismo paso. Ideal en el ciclo de iteracion rapida: compilar → desplegar → ver cambios. Equivalente a `deploy_cpp_exe_to_windows_bash_infra` pero para apps Wails (single-binary sin DLLs extras).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **taskkill /F fuerza muerte** sin permitir guardado en disco. Las apps Wails persisten estado en keyring de Windows y AppData — este kill es seguro para ellas. Si la app tuviera autosave en progreso, se perderia (aceptable en ciclos de dev).
|
||||
- **UNC paths prohibidos en cmd.exe**: `cmd.exe /c start` debe ejecutarse con `cd` previo al directorio Windows (`/mnt/c/...`). Intentar lanzar con path `\\wsl.localhost\...` falla con "UNC paths are not supported as the current directory".
|
||||
- **cmd.exe start no bloquea**: la funcion espera 3s y verifica via `tasklist.exe`. Si la app cierra sola tras el arranque (error de inicio), el warn final lo indica pero no causa exit 1. Revisar logs en `%APPDATA%\<app>\` o `%LOCALAPPDATA%\<app>\`.
|
||||
- **Single-binary Wails**: no copiar DLLs. Webview2 es nativo del SO (Windows 10+ ya lo incluye). Si una version vieja de Windows no tuviera Webview2, la app falla al arrancar — solucion: instalar Webview2 Runtime en esa maquina.
|
||||
- **Build previo es responsabilidad del caller**: esta funcion NO compila. Para matrix_client_pc usa `-tags goolm` por el crypto de Matrix: `wails build -platform windows/amd64 -tags goolm`.
|
||||
- **WIN_DESKTOP_APPS override**: variable de entorno para cambiar el destino. Util en CI o maquinas con escritorio en otra ruta.
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy_wails_exe_to_windows — Copia el .exe de una app Wails compilado en
|
||||
# <app_dir>/build/bin/<app>.exe al escritorio de Windows, mata el proceso
|
||||
# anterior y relanza la app. Single-binary: no copia DLLs (Webview2 nativo SO).
|
||||
# Pre-authorized: taskkill.exe /F — idempotente, sin prompt.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
deploy_wails_exe_to_windows() {
|
||||
local app="${1:-}"
|
||||
local app_dir="${2:-}"
|
||||
|
||||
if [ -z "$app" ] || [ -z "$app_dir" ]; then
|
||||
echo "ERROR: uso: deploy_wails_exe_to_windows <app_name> <app_dir>" >&2
|
||||
echo " app_name: nombre del binario sin extension (ej. matrix_client_pc)" >&2
|
||||
echo " app_dir: ruta absoluta al directorio de la app (donde vive build/bin/)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local win_desktop_apps="${WIN_DESKTOP_APPS:-/mnt/c/Users/lucas/Desktop/apps}"
|
||||
|
||||
# --- 1. Validar que el .exe existe ---
|
||||
local exe_src="${app_dir}/build/bin/${app}.exe"
|
||||
if [ ! -f "$exe_src" ]; then
|
||||
echo "ERROR: no se encontro $exe_src" >&2
|
||||
echo "Compila primero con: wails build -platform windows/amd64" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- 2. Crear directorio destino (preserva local_files/ si existe) ---
|
||||
local dest="${win_desktop_apps}/${app}"
|
||||
mkdir -p "$dest"
|
||||
echo "[deploy_wails] dest: $dest" >&2
|
||||
|
||||
# --- 3. Matar proceso si esta corriendo en Windows ---
|
||||
# Pre-authorized. Wails apps usan AppData+keyring para estado, kill /F es seguro.
|
||||
if command -v taskkill.exe >/dev/null 2>&1; then
|
||||
echo "[deploy_wails] matando ${app}.exe si corre..." >&2
|
||||
taskkill.exe /IM "${app}.exe" /F 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# --- 4. Esperar a que Windows libere el file handle ---
|
||||
sleep 1
|
||||
|
||||
# --- 5. Copiar .exe (cp -f: overwrite sin borrar el directorio) ---
|
||||
echo "[deploy_wails] copiando ${app}.exe..." >&2
|
||||
cp -f "$exe_src" "$dest/${app}.exe"
|
||||
|
||||
# --- 6. Copiar appicon.ico si existe (opcional, algunos hubs lo leen) ---
|
||||
local icon_src="${app_dir}/appicon.ico"
|
||||
if [ -f "$icon_src" ]; then
|
||||
echo "[deploy_wails] copiando appicon.ico..." >&2
|
||||
cp -f "$icon_src" "$dest/appicon.ico"
|
||||
fi
|
||||
|
||||
# --- 7. Relanzar la app desde su dir Windows ---
|
||||
# Usar cmd.exe /c start desde el dir destino (no UNC paths — falla en cmd.exe).
|
||||
echo "[deploy_wails] lanzando ${app}.exe..." >&2
|
||||
(
|
||||
cd "$dest"
|
||||
cmd.exe /c start "" "${app}.exe"
|
||||
)
|
||||
|
||||
# --- 8. Dar tiempo a que el proceso arranque ---
|
||||
sleep 3
|
||||
|
||||
# --- 9. Verificar que el proceso esta corriendo ---
|
||||
if command -v tasklist.exe >/dev/null 2>&1; then
|
||||
local tasklist_out
|
||||
tasklist_out=$(tasklist.exe /FI "IMAGENAME eq ${app}.exe" /NH 2>/dev/null || true)
|
||||
if echo "$tasklist_out" | grep -qi "^${app}.exe"; then
|
||||
local pid
|
||||
pid=$(echo "$tasklist_out" | grep -i "^${app}.exe" | awk '{print $2}' | head -n1)
|
||||
echo "[deploy_wails] ${app}.exe corriendo con PID $pid" >&2
|
||||
else
|
||||
echo "WARN: ${app}.exe no aparece en tasklist tras el lanzamiento." >&2
|
||||
echo " Puede que la app cerro con error. Revisar AppData para logs." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 10. Resumen final en stdout ---
|
||||
ls -lh "$dest/${app}.exe"
|
||||
|
||||
echo "[deploy_wails] OK: ${app} deployado en $dest" >&2
|
||||
if [ -d "$dest/local_files" ]; then
|
||||
echo "[deploy_wails] local_files/ preservado: $(du -sh "$dest/local_files" | cut -f1)" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
||||
deploy_wails_exe_to_windows "$@"
|
||||
fi
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests para deploy_wails_exe_to_windows
|
||||
# Solo prueba validacion de argumentos y rutas — no ejecuta taskkill/cmd.exe reales.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/deploy_wails_exe_to_windows.sh"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_eq() {
|
||||
local test_name="$1" expected="$2" got="$3"
|
||||
if [[ "$expected" == "$got" ]]; then
|
||||
echo "PASS: $test_name"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "FAIL: $test_name — expected '$expected', got '$got'"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Test 1: args vacios devuelven error con mensaje de uso ---
|
||||
actual_exit=0
|
||||
deploy_wails_exe_to_windows >/dev/null 2>&1 || actual_exit=$?
|
||||
assert_eq "args vacios devuelven error con mensaje de uso" "1" "$actual_exit"
|
||||
|
||||
# --- Test 2: app_dir inexistente devuelve exit 1 ---
|
||||
actual_exit=0
|
||||
deploy_wails_exe_to_windows "myapp" "/tmp/nonexistent_dir_$(date +%s)" >/dev/null 2>&1 || actual_exit=$?
|
||||
assert_eq "app_dir inexistente devuelve exit 1" "1" "$actual_exit"
|
||||
|
||||
# --- Test 3: build/bin exe inexistente devuelve exit 1 ---
|
||||
TMPDIR_APP=$(mktemp -d)
|
||||
# Crear estructura de dir de app pero SIN el exe
|
||||
mkdir -p "$TMPDIR_APP/build/bin"
|
||||
actual_exit=0
|
||||
deploy_wails_exe_to_windows "myapp" "$TMPDIR_APP" >/dev/null 2>&1 || actual_exit=$?
|
||||
rm -rf "$TMPDIR_APP"
|
||||
assert_eq "build/bin exe inexistente devuelve exit 1" "1" "$actual_exit"
|
||||
|
||||
echo "---"
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]] || exit 1
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: jupyter_mcp_serve
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: 1.0.0
|
||||
purity: impure
|
||||
error_type: "error_go_core"
|
||||
signature: "jupyter_mcp_serve.sh [--dry-run]"
|
||||
description: "Arranca (o reusa) un Jupyter Lab colaborativo en un puerto propio y lanza el Jupyter MCP server enganchado por stdio. Entrypoint robusto para la entrada 'jupyter' de .mcp.json: garantiza que el MCP SIEMPRE tiene servidor al que conectarse, sin depender de que haya un jupyter en 8888."
|
||||
tags: [notebook, jupyter, mcp, infra, launcher-glue]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
params:
|
||||
- name: "--dry-run"
|
||||
desc: "Opcional. Arranca/verifica jupyter pero NO hace exec del MCP; loguea el comando elegido. Para tests."
|
||||
output: "Proceso jupyter-mcp-server enganchado por stdio a un Jupyter Lab colaborativo local (127.0.0.1, puerto JUPYTER_MCP_PORT, default 8899). Logs en ~/.fn_jupyter_mcp/. stdout reservado al protocolo MCP."
|
||||
---
|
||||
|
||||
## Que hace
|
||||
|
||||
El MCP de Jupyter (datalayer `jupyter-mcp-server`) **no arranca jupyter**, solo se
|
||||
conecta a uno existente. Si la URL configurada no tiene jupyter detras, el MCP
|
||||
nunca conecta. En esta maquina `localhost:8888` es el **proxy HTTP del contenedor
|
||||
VPN gluetun**, no un jupyter — por eso el MCP fallaba siempre.
|
||||
|
||||
Este wrapper resuelve la cadena entera:
|
||||
|
||||
1. Localiza el venv (`python/.venv`) y los binarios `jupyter` + `jupyter-mcp-server`.
|
||||
2. Si ya hay un jupyter gestionado vivo en `127.0.0.1:$PORT` (`/api/status` = 200) lo reusa.
|
||||
3. Si no, arranca `jupyter lab` colaborativo detached (RTC via `jupyter-collaboration`),
|
||||
en `JUPYTER_MCP_ROOT` (default = raiz del repo, asi cualquier notebook del arbol es lanzable).
|
||||
4. Detecta el dialecto de CLI del MCP (`--document-url` nuevo / `--jupyter-url` viejo / env vars)
|
||||
y hace `exec` del MCP por `--transport stdio`.
|
||||
|
||||
Self-adapting: funciona aunque cambie la version de `jupyter-mcp-server`.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Como lo usa Claude Code (entrada en .mcp.json):
|
||||
# "jupyter": { "command": "bash", "args": ["bash/functions/infra/jupyter_mcp_serve.sh"] }
|
||||
|
||||
# Test manual (arranca jupyter en 8899, no lanza el MCP):
|
||||
bash bash/functions/infra/jupyter_mcp_serve.sh --dry-run
|
||||
curl -s http://127.0.0.1:8899/api/status # {"started":..., "version":...}
|
||||
|
||||
# Cambiar puerto / raiz de notebooks:
|
||||
JUPYTER_MCP_PORT=8900 JUPYTER_MCP_ROOT=/home/enmanuel/fn_registry/analysis \
|
||||
bash bash/functions/infra/jupyter_mcp_serve.sh --dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras que el MCP de Jupyter de Claude Code **siempre** tenga servidor:
|
||||
es el `command` de la entrada `jupyter` en `.mcp.json`. No la invoques a mano salvo
|
||||
para depurar (`--dry-run`) o para levantar el jupyter colaborativo sin el MCP.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **stdout reservado**: el MCP habla por stdout (protocolo stdio). El wrapper jamas
|
||||
escribe a stdout — todo log va a stderr y `~/.fn_jupyter_mcp/wrapper.log`. No metas
|
||||
`echo` a stdout aqui o rompes el handshake del MCP.
|
||||
- **Puerto 8888 ocupado por gluetun** en esta maquina. Por eso el default es **8899**.
|
||||
Si 8899 tambien se ocupa, exporta `JUPYTER_MCP_PORT`.
|
||||
- **Token vacio**: solo escucha en `127.0.0.1` con `disable_check_xsrf` + `allow_origin '*'`.
|
||||
Aceptable en local; NO exponer el puerto a la red.
|
||||
- **venv requerido**: necesita `python/.venv` con `jupyterlab`, `jupyter-collaboration`
|
||||
y `jupyter-mcp-server`. Reconstruir: `cd python && uv sync --extra jupyter`.
|
||||
- El jupyter arrancado queda **detached** (nohup): persiste entre invocaciones del MCP.
|
||||
Para pararlo: `python/.venv/bin/jupyter server stop 8899` o `pkill -f 'jupyter-lab.*8899'`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 (2026-06-01) — version inicial. Wrapper auto-start: reusa/levanta jupyter
|
||||
colaborativo en puerto propio (8899) y autodetecta el dialecto de CLI del MCP.
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
# jupyter_mcp_serve — arranca (o reusa) un Jupyter Lab colaborativo y lanza el
|
||||
# Jupyter MCP server enganchado a el por stdio. Pensado para ser el `command` de
|
||||
# la entrada "jupyter" en .mcp.json: garantiza que el MCP SIEMPRE tiene servidor.
|
||||
#
|
||||
# Por que existe: el MCP datalayer NO arranca jupyter, solo se conecta. Si la URL
|
||||
# apunta a un puerto sin jupyter (en esta maquina 8888 = proxy VPN gluetun), el
|
||||
# MCP nunca conecta. Este wrapper levanta su propio jupyter en un puerto propio.
|
||||
#
|
||||
# Env overrides:
|
||||
# JUPYTER_MCP_ROOT raiz de notebooks (default: raiz del repo)
|
||||
# JUPYTER_MCP_PORT puerto del jupyter gestionado (default: 8899)
|
||||
# JUPYTER_MCP_VENV venv (default: <repo>/python/.venv)
|
||||
# JUPYTER_MCP_TOKEN token (default: "" — solo escucha en 127.0.0.1)
|
||||
#
|
||||
# stdout esta RESERVADO al protocolo stdio del MCP. Todo log va a stderr + LOGFILE.
|
||||
# Nunca hacer echo a stdout aqui.
|
||||
#
|
||||
# Uso directo / test:
|
||||
# bash jupyter_mcp_serve.sh --dry-run # arranca jupyter, NO exec del MCP, loguea args
|
||||
set -euo pipefail
|
||||
|
||||
DRY=0
|
||||
[ "${1:-}" = "--dry-run" ] && DRY=1
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# raiz del repo = tres niveles arriba de bash/functions/infra/
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
VENV="${JUPYTER_MCP_VENV:-$REPO_ROOT/python/.venv}"
|
||||
ROOT_DIR="${JUPYTER_MCP_ROOT:-$REPO_ROOT}"
|
||||
PORT="${JUPYTER_MCP_PORT:-8899}"
|
||||
HOST=127.0.0.1
|
||||
TOKEN="${JUPYTER_MCP_TOKEN:-}"
|
||||
LOGDIR="${HOME}/.fn_jupyter_mcp"
|
||||
mkdir -p "$LOGDIR"
|
||||
LOGFILE="$LOGDIR/wrapper.log"
|
||||
JLOG="$LOGDIR/jupyterlab.log"
|
||||
|
||||
log(){ printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*" >>"$LOGFILE"; printf '%s\n' "$*" >&2; }
|
||||
|
||||
JUPYTER="$VENV/bin/jupyter"
|
||||
MCP="$VENV/bin/jupyter-mcp-server"
|
||||
|
||||
if [ ! -x "$JUPYTER" ]; then
|
||||
log "FATAL: $JUPYTER no existe. Instala: cd $REPO_ROOT/python && uv pip install --python .venv/bin/python3 jupyterlab jupyter-collaboration jupyter-mcp-server"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -x "$MCP" ]; then
|
||||
log "FATAL: $MCP no existe. Instala jupyter-mcp-server en el venv."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
server_up(){
|
||||
local code
|
||||
code="$(curl -s -m 3 -o /dev/null -w '%{http_code}' "http://$HOST:$PORT/api/status?token=$TOKEN" 2>/dev/null || true)"
|
||||
[ "$code" = "200" ]
|
||||
}
|
||||
|
||||
if server_up; then
|
||||
log "reuso jupyter existente en $HOST:$PORT"
|
||||
else
|
||||
log "arranco jupyter colaborativo en $HOST:$PORT (root=$ROOT_DIR)"
|
||||
nohup "$JUPYTER" lab \
|
||||
--no-browser \
|
||||
--ServerApp.ip="$HOST" \
|
||||
--ServerApp.port="$PORT" \
|
||||
--ServerApp.root_dir="$ROOT_DIR" \
|
||||
--IdentityProvider.token="$TOKEN" \
|
||||
--ServerApp.disable_check_xsrf=True \
|
||||
--ServerApp.allow_origin='*' \
|
||||
>>"$JLOG" 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
# esperar hasta ~30s a que levante
|
||||
for _ in $(seq 1 60); do
|
||||
server_up && break
|
||||
sleep 0.5
|
||||
done
|
||||
if ! server_up; then
|
||||
log "FATAL: jupyter no levanto en 30s. Ver $JLOG"
|
||||
exit 1
|
||||
fi
|
||||
log "jupyter arriba"
|
||||
fi
|
||||
|
||||
BASE="http://$HOST:$PORT"
|
||||
|
||||
# Detectar el dialecto de CLI del MCP (cambia entre versiones de jupyter-mcp-server)
|
||||
HELP="$("$MCP" --help 2>&1 || true)"
|
||||
ARGS=(--transport stdio)
|
||||
if printf '%s' "$HELP" | grep -q -- '--document-url'; then
|
||||
ARGS+=(--document-url "$BASE" --runtime-url "$BASE")
|
||||
printf '%s' "$HELP" | grep -q -- '--document-token' && ARGS+=(--document-token "$TOKEN" --runtime-token "$TOKEN")
|
||||
elif printf '%s' "$HELP" | grep -q -- '--jupyter-url'; then
|
||||
ARGS+=(--jupyter-url "$BASE" --jupyter-token "$TOKEN")
|
||||
else
|
||||
# fallback: variables de entorno que las distintas versiones reconocen
|
||||
export DOCUMENT_URL="$BASE" RUNTIME_URL="$BASE" DOCUMENT_TOKEN="$TOKEN" RUNTIME_TOKEN="$TOKEN"
|
||||
export JUPYTER_URL="$BASE" JUPYTER_TOKEN="$TOKEN"
|
||||
fi
|
||||
|
||||
log "MCP cmd: $MCP ${ARGS[*]}"
|
||||
|
||||
if [ "$DRY" = "1" ]; then
|
||||
log "--dry-run: no ejecuto el MCP. Jupyter sigue corriendo en $BASE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec "$MCP" "${ARGS[@]}"
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: mas_client_register
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "mas_client_register(ssh_host: string, container: string, config_file: string, dry_run: bool) -> json"
|
||||
description: "Registra y sincroniza clientes OAuth en Matrix Authentication Service (MAS) ejecutando mas-cli config sync dentro del container Docker remoto via SSH. Verifica sintaxis YAML, soporte dry-run para ver diff antes de aplicar, y emite JSON estructurado con resultado. Idempotente: re-ejecucion con misma config no genera cambios."
|
||||
tags: [matrix, mas, oauth, oidc, migration, mas-migration, infra, docker, ssh, matrix-mas]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: ssh_host
|
||||
desc: "alias SSH del VPS donde corre MAS (ej. organic-machine.com). Debe estar en ~/.ssh/config con key auth."
|
||||
- name: container
|
||||
desc: "nombre del container Docker con MAS (ej. element_matrix_chat-mas-1). El config dentro del container se espera en /data/config.yaml."
|
||||
- name: config_file
|
||||
desc: "ruta absoluta en el VPS al archivo mas/config.yaml (ej. /home/ubuntu/CodeProyects/element_matrix_chat/mas/config.yaml). MAS lo monta como /data/config.yaml."
|
||||
- name: dry_run
|
||||
desc: "flag opcional --dry-run: ejecuta mas-cli config dump y devuelve el estado sin aplicar cambios. Util para verificar antes de activar MSC3861."
|
||||
output: "JSON con: status ('ok'|'dry-run'|'error'), applied (bool), clients_total (int), clients_diff (array de lineas del output de mas-cli), stderr (string con logs de error si aplica)."
|
||||
tested: true
|
||||
tests:
|
||||
- "help flag emite JSON parseable"
|
||||
- "args faltantes retornan JSON de error sin ssh"
|
||||
- "jq disponible en host local"
|
||||
test_file_path: "bash/functions/infra/mas_client_register_test.sh"
|
||||
file_path: "bash/functions/infra/mas_client_register.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Dry-run: verificar que clients se aplicarian correctamente
|
||||
source bash/functions/infra/mas_client_register.sh
|
||||
|
||||
mas_client_register \
|
||||
--ssh-host organic-machine.com \
|
||||
--container element_matrix_chat-mas-1 \
|
||||
--config-file /home/ubuntu/CodeProyects/element_matrix_chat/mas/config.yaml \
|
||||
--dry-run
|
||||
|
||||
# Aplicar sync real (con --prune para eliminar clients viejos)
|
||||
mas_client_register \
|
||||
--ssh-host organic-machine.com \
|
||||
--container element_matrix_chat-mas-1 \
|
||||
--config-file /home/ubuntu/CodeProyects/element_matrix_chat/mas/config.yaml
|
||||
```
|
||||
|
||||
Salida esperada (sync OK):
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"applied": true,
|
||||
"clients_total": 6,
|
||||
"clients_diff": ["synced client element-web", "synced client synapse-admin", "..."],
|
||||
"stderr": ""
|
||||
}
|
||||
```
|
||||
|
||||
Salida dry-run:
|
||||
```json
|
||||
{
|
||||
"status": "dry-run",
|
||||
"applied": false,
|
||||
"clients_total": 42,
|
||||
"clients_diff": ["clients:", " - client_id: element-web", " ..."],
|
||||
"stderr": ""
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usar despues de editar `mas/config.yaml` localmente y antes de hacer restart a Synapse con `msc3861` habilitado en `homeserver.yaml`. Ejecutar primero con `--dry-run` para verificar que los 6 clients OAuth (Element Web, Synapse-Admin, matrix_client_pc, matrix_client_android, matrix_admin_panel, Synapse-internal) estan correctamente definidos, luego sin `--dry-run` para aplicar el sync.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`--prune` elimina clients no declarados en config**: el sync real usa `--prune`, lo que borra cualquier client OAuth que exista en MAS pero no este en el `config.yaml`. Verificar con `--dry-run` antes de aplicar en produccion.
|
||||
- **Requiere `jq` en el host local**: el JSON output se construye con `jq`. Si no esta instalado, la funcion falla con error claro antes de conectar al VPS.
|
||||
- **`mas-cli` debe estar en el container**: la funcion asume que `mas-cli` esta en el PATH dentro del container MAS. Si el container usa una imagen diferente, verificar con `docker exec <container> mas-cli --version`.
|
||||
- **Config dentro del container siempre en `/data/config.yaml`**: el `--config-file` apunta a la ruta en el VPS (para que el operador sepa que archivo editar), pero el comando dentro del container usa `/data/config.yaml` (el mount point estandar de MAS). Si el compose monta el archivo en otro path, ajustar la constante `container_config` en el script.
|
||||
- **SSH key debe estar en agent o `~/.ssh/config`**: la funcion usa `ssh <alias>` directamente. Si la key requiere passphrase, ejecutar `ssh-add` antes.
|
||||
- **Si `config.yaml` es invalido, sync aborta sin tocar estado**: el paso 1 (`mas-cli config check`) detecta errores de sintaxis YAML antes de intentar sync. El estado de MAS no se modifica si la config tiene errores.
|
||||
- **Idempotente**: re-ejecutar con la misma config no genera cambios en MAS (mas-cli detecta que el estado ya coincide).
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env bash
|
||||
# mas_client_register — Registra/sincroniza clientes OAuth en Matrix Authentication Service (MAS)
|
||||
# via mas-cli config sync ejecutado en container Docker remoto a traves de SSH.
|
||||
set -euo pipefail
|
||||
|
||||
mas_client_register() {
|
||||
local ssh_host=""
|
||||
local container=""
|
||||
local config_file=""
|
||||
local dry_run=false
|
||||
|
||||
# Parse args
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--ssh-host)
|
||||
ssh_host="$2"
|
||||
shift 2
|
||||
;;
|
||||
--container)
|
||||
container="$2"
|
||||
shift 2
|
||||
;;
|
||||
--config-file)
|
||||
config_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
cat >&2 <<'USAGE'
|
||||
mas_client_register - Sincroniza clientes OAuth en MAS via mas-cli config sync
|
||||
|
||||
Usage:
|
||||
mas_client_register --ssh-host <host> --container <name> --config-file <path> [--dry-run]
|
||||
|
||||
Options:
|
||||
--ssh-host Alias SSH del VPS (ej. organic-machine.com)
|
||||
--container Nombre del container MAS (ej. element_matrix_chat-mas-1)
|
||||
--config-file Ruta en el VPS al mas/config.yaml (ej. /home/ubuntu/project/mas/config.yaml)
|
||||
--dry-run Solo valida config y muestra diff, sin aplicar cambios
|
||||
|
||||
Output: JSON en stdout con status, applied, clients_total, clients_diff, stderr
|
||||
USAGE
|
||||
# emit minimal valid JSON so callers that parse stdout don't break
|
||||
echo '{"status":"help","applied":false,"clients_total":0,"clients_diff":[],"stderr":""}'
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo "mas_client_register: argumento desconocido: $1" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validar argumentos obligatorios
|
||||
local errors=()
|
||||
[[ -z "$ssh_host" ]] && errors+=("--ssh-host es obligatorio")
|
||||
[[ -z "$container" ]] && errors+=("--container es obligatorio")
|
||||
[[ -z "$config_file" ]] && errors+=("--config-file es obligatorio")
|
||||
|
||||
if [[ ${#errors[@]} -gt 0 ]]; then
|
||||
for err in "${errors[@]}"; do
|
||||
echo "ERROR: $err" >&2
|
||||
done
|
||||
echo '{"status":"error","applied":false,"clients_total":0,"clients_diff":[],"stderr":"missing required arguments"}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Verificar dependencias locales
|
||||
if ! command -v jq &>/dev/null; then
|
||||
echo "ERROR: jq no encontrado en el host local. Instalar: apt install jq / brew install jq" >&2
|
||||
echo '{"status":"error","applied":false,"clients_total":0,"clients_diff":[],"stderr":"jq not found on local host"}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "mas_client_register: ssh-host=$ssh_host container=$container dry-run=$dry_run" >&2
|
||||
|
||||
# La ruta de config dentro del container siempre es /data/config.yaml (mount convention de MAS)
|
||||
local container_config="/data/config.yaml"
|
||||
|
||||
# ---- PASO 1: Verificar sintaxis YAML con mas-cli config check ----
|
||||
echo "mas_client_register: verificando sintaxis de config con mas-cli config check..." >&2
|
||||
local check_stdout check_stderr check_exit
|
||||
check_stdout=$(ssh "$ssh_host" \
|
||||
"docker exec ${container} mas-cli config check --config ${container_config}" 2>/tmp/mas_check_stderr_$$ || true)
|
||||
check_exit=$?
|
||||
check_stderr=$(cat /tmp/mas_check_stderr_$$ 2>/dev/null || true)
|
||||
rm -f /tmp/mas_check_stderr_$$
|
||||
|
||||
if [[ $check_exit -ne 0 ]]; then
|
||||
echo "mas_client_register: config check falló (exit=$check_exit)" >&2
|
||||
echo "$check_stderr" >&2
|
||||
local escaped_stderr
|
||||
escaped_stderr=$(printf '%s' "${check_stderr}" | jq -Rs '.')
|
||||
echo "{\"status\":\"error\",\"applied\":false,\"clients_total\":0,\"clients_diff\":[],\"stderr\":${escaped_stderr}}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "mas_client_register: config check OK" >&2
|
||||
|
||||
# ---- PASO 2: dry-run o sync ----
|
||||
if [[ "$dry_run" == "true" ]]; then
|
||||
# Ejecutar mas-cli config dump para mostrar el estado actual y lo que se aplicaria
|
||||
echo "mas_client_register: modo dry-run — ejecutando mas-cli config dump..." >&2
|
||||
local dump_stdout dump_stderr dump_exit
|
||||
dump_stdout=$(ssh "$ssh_host" \
|
||||
"docker exec ${container} mas-cli config dump --config ${container_config}" 2>/tmp/mas_dump_stderr_$$ || true)
|
||||
dump_exit=$?
|
||||
dump_stderr=$(cat /tmp/mas_dump_stderr_$$ 2>/dev/null || true)
|
||||
rm -f /tmp/mas_dump_stderr_$$
|
||||
|
||||
if [[ $dump_exit -ne 0 ]]; then
|
||||
echo "mas_client_register: config dump falló (exit=$dump_exit)" >&2
|
||||
echo "$dump_stderr" >&2
|
||||
local escaped_stderr
|
||||
escaped_stderr=$(printf '%s' "${dump_stderr}" | jq -Rs '.')
|
||||
echo "{\"status\":\"error\",\"applied\":false,\"clients_total\":0,\"clients_diff\":[],\"stderr\":${escaped_stderr}}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Extraer listado de clients del dump (buscar lineas con client_id o type: client)
|
||||
local clients_diff_raw
|
||||
clients_diff_raw=$(printf '%s\n' "$dump_stdout" | grep -E "client_id:|client_name:" | \
|
||||
sed 's/^[[:space:]]*//' | head -50 || true)
|
||||
|
||||
local diff_json
|
||||
diff_json=$(printf '%s\n' "$dump_stdout" | jq -Rs 'split("\n") | map(select(length > 0)) | map(ltrimstr(" "))' 2>/dev/null \
|
||||
|| echo '["(jq parse error — ver stderr)"]')
|
||||
|
||||
local escaped_dump_stderr
|
||||
escaped_dump_stderr=$(printf '%s' "${dump_stderr}" | jq -Rs '.')
|
||||
|
||||
echo "mas_client_register: dry-run completado. dump lines=$(echo "$dump_stdout" | wc -l)" >&2
|
||||
|
||||
jq -n \
|
||||
--argjson diff "$diff_json" \
|
||||
--argjson stderr_str "$escaped_dump_stderr" \
|
||||
'{
|
||||
status: "dry-run",
|
||||
applied: false,
|
||||
clients_total: ($diff | length),
|
||||
clients_diff: $diff,
|
||||
stderr: $stderr_str
|
||||
}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ---- PASO 3: sync real ----
|
||||
echo "mas_client_register: ejecutando mas-cli config sync --prune..." >&2
|
||||
local sync_stdout sync_stderr sync_exit
|
||||
sync_stdout=$(ssh "$ssh_host" \
|
||||
"docker exec ${container} mas-cli config sync --config ${container_config} --prune" \
|
||||
2>/tmp/mas_sync_stderr_$$ || true)
|
||||
sync_exit=$?
|
||||
sync_stderr=$(cat /tmp/mas_sync_stderr_$$ 2>/dev/null || true)
|
||||
rm -f /tmp/mas_sync_stderr_$$
|
||||
|
||||
echo "mas_client_register: sync exit=$sync_exit" >&2
|
||||
if [[ -n "$sync_stderr" ]]; then
|
||||
echo "mas_client_register stderr: $sync_stderr" >&2
|
||||
fi
|
||||
|
||||
if [[ $sync_exit -ne 0 ]]; then
|
||||
local escaped_stderr
|
||||
escaped_stderr=$(printf '%s' "${sync_stderr}" | jq -Rs '.')
|
||||
echo "{\"status\":\"error\",\"applied\":false,\"clients_total\":0,\"clients_diff\":[],\"stderr\":${escaped_stderr}}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Parsear output del sync para extraer lineas con cambios aplicados
|
||||
local diff_lines
|
||||
diff_lines=$(printf '%s\n' "$sync_stdout" | grep -E "^\s*(created|updated|deleted|unchanged|synced)" || true)
|
||||
|
||||
local diff_json
|
||||
diff_json=$(printf '%s\n' "$sync_stdout" | jq -Rs 'split("\n") | map(select(length > 0))' 2>/dev/null \
|
||||
|| echo '[]')
|
||||
|
||||
local clients_count
|
||||
clients_count=$(printf '%s\n' "$sync_stdout" | grep -cE "client" 2>/dev/null || echo 0)
|
||||
|
||||
local escaped_sync_stderr
|
||||
escaped_sync_stderr=$(printf '%s' "${sync_stderr}" | jq -Rs '.')
|
||||
|
||||
echo "mas_client_register: sync completado con exito" >&2
|
||||
|
||||
jq -n \
|
||||
--argjson diff "$diff_json" \
|
||||
--argjson total "$clients_count" \
|
||||
--argjson stderr_str "$escaped_sync_stderr" \
|
||||
'{
|
||||
status: "ok",
|
||||
applied: true,
|
||||
clients_total: $total,
|
||||
clients_diff: $diff,
|
||||
stderr: $stderr_str
|
||||
}'
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente (no sourced)
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
mas_client_register "$@"
|
||||
fi
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests para mas_client_register
|
||||
# No requiere SSH real — prueba paths locales (arg validation, --help, JSON output)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_contains() {
|
||||
local test_name="$1" needle="$2" haystack="$3"
|
||||
if echo "$haystack" | grep -qF "$needle"; then
|
||||
echo "PASS: $test_name"
|
||||
((PASS++))
|
||||
else
|
||||
echo "FAIL: $test_name — expected to contain '$needle', got: $haystack"
|
||||
((FAIL++))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_json_parseable() {
|
||||
local test_name="$1" json="$2"
|
||||
if command -v jq &>/dev/null; then
|
||||
if echo "$json" | jq . >/dev/null 2>&1; then
|
||||
echo "PASS: $test_name"
|
||||
((PASS++))
|
||||
else
|
||||
echo "FAIL: $test_name — output no es JSON valido: $json"
|
||||
((FAIL++))
|
||||
fi
|
||||
else
|
||||
if [[ "$json" == \{* ]]; then
|
||||
echo "PASS: $test_name (jq no disponible, verificacion basica OK)"
|
||||
((PASS++))
|
||||
else
|
||||
echo "FAIL: $test_name — output no parece JSON: $json"
|
||||
((FAIL++))
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Test: help flag emite JSON parseable
|
||||
# Cada invocacion en subshell aislada para no contaminar el runner con set -e del script fuente
|
||||
bash "$SCRIPT_DIR/mas_client_register.sh" --help >/tmp/mas_test_help_$$ 2>/dev/null || true
|
||||
output_help=$(cat /tmp/mas_test_help_$$ 2>/dev/null || true)
|
||||
rm -f /tmp/mas_test_help_$$
|
||||
assert_json_parseable "help flag emite JSON parseable" "$output_help"
|
||||
|
||||
# Test: args faltantes retornan JSON de error sin ssh
|
||||
bash "$SCRIPT_DIR/mas_client_register.sh" >/tmp/mas_test_noargs_$$ 2>/dev/null || true
|
||||
output_noargs=$(cat /tmp/mas_test_noargs_$$ 2>/dev/null || true)
|
||||
rm -f /tmp/mas_test_noargs_$$
|
||||
assert_json_parseable "args faltantes retornan JSON de error sin ssh" "$output_noargs"
|
||||
assert_contains "args faltantes contienen status error" '"status":"error"' "$output_noargs"
|
||||
|
||||
# Test: jq disponible en host local
|
||||
if command -v jq &>/dev/null; then
|
||||
echo "PASS: jq disponible en host local"
|
||||
((PASS++))
|
||||
else
|
||||
echo "FAIL: jq disponible en host local — instalar: apt install jq"
|
||||
((FAIL++))
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]] || exit 1
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: mas_syn2mas_migration
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "mas_syn2mas_migration --ssh-host <host> --mas-container <name> --synapse-config-path <path-on-host> --log-dir <local-path> [--max-conflicts N] [--apply]"
|
||||
description: "Migra usuarios Synapse a Matrix Authentication Service (MAS) via mas-cli syn2mas. Fuerza dry-run primero, archiva el log, aborta si los conflicts superan el threshold, y solo ejecuta la migracion real con --apply."
|
||||
tags: [matrix, mas, syn2mas, migration, mas-migration, infra, users, docker, ssh, matrix-mas]
|
||||
params:
|
||||
- name: ssh-host
|
||||
desc: "Alias SSH del VPS donde corren los containers (ej. organic-machine.com)"
|
||||
- name: mas-container
|
||||
desc: "Nombre del container Docker de MAS (ej. element_matrix_chat-mas-1)"
|
||||
- name: synapse-config-path
|
||||
desc: "Ruta en el VPS al homeserver.yaml de Synapse (ej. /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml). El container debe tener el archivo accesible en /data/homeserver.yaml via volume mount."
|
||||
- name: log-dir
|
||||
desc: "Directorio local donde archivar logs dry-run y apply. Se crea con chmod 0700 y los logs con 0600 (contienen userIDs)."
|
||||
- name: max-conflicts
|
||||
desc: "Tope de conflictos detectados en dry-run. Si conflicts > max-conflicts, status=aborted exit 2. Default 0 (abortar ante cualquier conflict)."
|
||||
- name: apply
|
||||
desc: "Flag booleano. Sin --apply: solo dry-run (status=ok, sin cambios). Con --apply: ejecuta la migracion real tras pasar el threshold."
|
||||
output: "JSON en stdout: {\"status\":\"ok|aborted|error\",\"dry_run_log\":\"path\",\"apply_log\":\"path|null\",\"conflicts\":N,\"users_migrated\":N,\"duration_s\":N}. Exit 0=ok, 1=error, 2=aborted por conflicts."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests:
|
||||
- "aborta con error cuando faltan args obligatorios"
|
||||
- "help no devuelve error"
|
||||
- "argumento desconocido retorna exit 1"
|
||||
- "max-conflicts invalido retorna exit 1"
|
||||
test_file_path: "bash/functions/infra/mas_syn2mas_migration_test.sh"
|
||||
file_path: "bash/functions/infra/mas_syn2mas_migration.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Paso 1: dry-run OBLIGATORIO (sin --apply — no modifica nada)
|
||||
mas_syn2mas_migration \
|
||||
--ssh-host organic-machine.com \
|
||||
--mas-container element_matrix_chat-mas-1 \
|
||||
--synapse-config-path /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml \
|
||||
--log-dir ~/matrix_migration_logs \
|
||||
--max-conflicts 0
|
||||
|
||||
# Salida esperada (si hay 0 conflicts):
|
||||
# {"status":"ok","dry_run_log":"/home/lucas/matrix_migration_logs/syn2mas_dryrun_1234567890.log","apply_log":null,"conflicts":0,"users_migrated":0,"duration_s":0}
|
||||
|
||||
# Revisar el log antes de continuar:
|
||||
# cat ~/matrix_migration_logs/syn2mas_dryrun_*.log
|
||||
|
||||
# Paso 2: tras revisar el log dry-run, aplicar la migracion real
|
||||
mas_syn2mas_migration \
|
||||
--ssh-host organic-machine.com \
|
||||
--mas-container element_matrix_chat-mas-1 \
|
||||
--synapse-config-path /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml \
|
||||
--log-dir ~/matrix_migration_logs \
|
||||
--max-conflicts 0 \
|
||||
--apply
|
||||
|
||||
# Salida esperada tras migracion exitosa:
|
||||
# {"status":"ok","dry_run_log":"/home/lucas/matrix_migration_logs/syn2mas_dryrun_1234567890.log","apply_log":"/home/lucas/matrix_migration_logs/syn2mas_apply_1234567890.log","conflicts":0,"users_migrated":42,"duration_s":15}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usar en el paso 4 de la migracion del issue 0162 (Synapse a MAS auth), tras activar MSC3861 en `homeserver.yaml` y verificar que MAS esta corriendo con `syn2mas: true` en su config. NUNCA ejecutar antes de activar MSC3861 — sin ese flag activo, `syn2mas` no puede mapear usuarios a las tablas MAS y la migracion resultara en estado inconsistente.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Dry-run NO modifica nada** — siempre ejecutar primero sin `--apply` y revisar el log manualmente antes de aplicar.
|
||||
- Si el dry-run detecta usuarios con **guest accounts**, **application services** (bots), o **passwords externos** (LDAP/OIDC), revisar manualmente el log antes de aplicar — estos casos pueden requerir steps adicionales documentados en el issue 0162.
|
||||
- **Backup postgres pre-migracion NO esta cubierto** por esta funcion. El operador es responsable de hacer `pg_dump` de la DB de Synapse antes de ejecutar con `--apply`. Ver issue 0162 paso 1.
|
||||
- Si la migracion real falla **a mitad**, MAS puede quedar en estado inconsistente con usuarios parcialmente migrados. El rollback consiste en restaurar el backup postgres de Synapse + revertir `homeserver.yaml` a la configuracion pre-MSC3861.
|
||||
- Los logs archivados en `--log-dir` **incluyen userIDs** (datos personales). Se crean con permisos `0600` (solo propietario puede leer). Mantener el directorio con `chmod 0700`. No subir los logs a repos publicos.
|
||||
- El comando `mas-cli syn2mas` en el container asume que `homeserver.yaml` esta montado en `/data/homeserver.yaml`. Si el volume mount del container usa otra ruta, el comando fallara con "file not found". Verificar con `docker inspect <container> | jq '.[].Mounts'`.
|
||||
- La postcondicion compara el count de usuarios MAS con una segunda ejecucion de dry-run para obtener el count esperado. Si el conteo no esta disponible (salida inesperada de mas-cli), la funcion emite `status=ok` con `users_migrated` del count real de MAS — no aborta por este motivo para evitar falsos negativos.
|
||||
@@ -0,0 +1,325 @@
|
||||
#!/usr/bin/env bash
|
||||
# mas_syn2mas_migration — Migra usuarios Synapse a MAS via mas-cli syn2mas.
|
||||
# Fuerza dry-run primero, archiva el log, aborta si conflicts > threshold,
|
||||
# y solo ejecuta la migracion real cuando se pasa --apply.
|
||||
#
|
||||
# Usage:
|
||||
# mas_syn2mas_migration --ssh-host <host> --mas-container <name> \
|
||||
# --synapse-config-path <path-on-host> --log-dir <local-path> \
|
||||
# [--max-conflicts N] [--apply]
|
||||
#
|
||||
# Output: JSON en stdout con status, dry_run_log, apply_log, conflicts, users_migrated, duration_s
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
mas_syn2mas_migration() {
|
||||
local ssh_host=""
|
||||
local mas_container=""
|
||||
local synapse_config_path=""
|
||||
local log_dir=""
|
||||
local max_conflicts=0
|
||||
local do_apply=false
|
||||
|
||||
# ---- Parse args ----
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--ssh-host)
|
||||
ssh_host="$2"
|
||||
shift 2
|
||||
;;
|
||||
--mas-container)
|
||||
mas_container="$2"
|
||||
shift 2
|
||||
;;
|
||||
--synapse-config-path)
|
||||
synapse_config_path="$2"
|
||||
shift 2
|
||||
;;
|
||||
--log-dir)
|
||||
log_dir="$2"
|
||||
shift 2
|
||||
;;
|
||||
--max-conflicts)
|
||||
max_conflicts="$2"
|
||||
shift 2
|
||||
;;
|
||||
--apply)
|
||||
do_apply=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
cat >&2 <<'USAGE'
|
||||
mas_syn2mas_migration - Migra usuarios Synapse a Matrix Authentication Service (MAS)
|
||||
|
||||
Usage:
|
||||
mas_syn2mas_migration \
|
||||
--ssh-host <host> \
|
||||
--mas-container <name> \
|
||||
--synapse-config-path <path-on-host> \
|
||||
--log-dir <local-path> \
|
||||
[--max-conflicts N] \
|
||||
[--apply]
|
||||
|
||||
Opciones:
|
||||
--ssh-host Alias SSH del VPS (ej. organic-machine.com)
|
||||
--mas-container Nombre del container MAS (ej. element_matrix_chat-mas-1)
|
||||
--synapse-config-path Ruta en el VPS al homeserver.yaml
|
||||
(ej. /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml)
|
||||
--log-dir Directorio local donde archivar logs dry-run y apply
|
||||
--max-conflicts N Tope de conflictos en dry-run antes de abortar (default 0)
|
||||
--apply Ejecutar migracion real. Sin esta flag: solo dry-run.
|
||||
|
||||
Comportamiento:
|
||||
1. Siempre ejecuta dry-run primero y archiva el log.
|
||||
2. Si conflicts > max-conflicts -> status=aborted, exit 2.
|
||||
3. Sin --apply -> status=ok (dry-run completado), exit 0.
|
||||
4. Con --apply -> ejecuta migracion real, archiva log, verifica postcondicion.
|
||||
|
||||
Output JSON: {"status":"ok|aborted|error","dry_run_log":"path","apply_log":"path|null","conflicts":N,"users_migrated":N,"duration_s":N}
|
||||
USAGE
|
||||
echo '{"status":"help","dry_run_log":"","apply_log":null,"conflicts":0,"users_migrated":0,"duration_s":0}'
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo "mas_syn2mas_migration: argumento desconocido: $1" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---- Validar argumentos obligatorios ----
|
||||
local errors=()
|
||||
[[ -z "$ssh_host" ]] && errors+=("--ssh-host es obligatorio")
|
||||
[[ -z "$mas_container" ]] && errors+=("--mas-container es obligatorio")
|
||||
[[ -z "$synapse_config_path" ]] && errors+=("--synapse-config-path es obligatorio")
|
||||
[[ -z "$log_dir" ]] && errors+=("--log-dir es obligatorio")
|
||||
|
||||
if [[ ${#errors[@]} -gt 0 ]]; then
|
||||
for err in "${errors[@]}"; do
|
||||
echo "ERROR: $err" >&2
|
||||
done
|
||||
echo '{"status":"error","dry_run_log":"","apply_log":null,"conflicts":-1,"users_migrated":0,"duration_s":0}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validar que max_conflicts es un entero no negativo
|
||||
if ! [[ "$max_conflicts" =~ ^[0-9]+$ ]]; then
|
||||
echo "ERROR: --max-conflicts debe ser un entero >= 0, recibido: $max_conflicts" >&2
|
||||
echo '{"status":"error","dry_run_log":"","apply_log":null,"conflicts":-1,"users_migrated":0,"duration_s":0}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ---- Dependencias locales ----
|
||||
if ! command -v jq &>/dev/null; then
|
||||
echo "ERROR: jq no encontrado. Instalar: apt install jq / brew install jq" >&2
|
||||
echo '{"status":"error","dry_run_log":"","apply_log":null,"conflicts":-1,"users_migrated":0,"duration_s":0}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ---- Crear log-dir con permisos restringidos ----
|
||||
mkdir -p "$log_dir"
|
||||
chmod 0700 "$log_dir"
|
||||
|
||||
local ts
|
||||
ts=$(date +%s)
|
||||
|
||||
local dry_run_log="${log_dir}/syn2mas_dryrun_${ts}.log"
|
||||
local apply_log_path="null"
|
||||
local apply_log_file="${log_dir}/syn2mas_apply_${ts}.log"
|
||||
|
||||
# La ruta del homeserver.yaml dentro del container MAS se pasa como --synapse-config
|
||||
# MAS monta el directorio del synapse bajo /data/ por convencion, pero la ruta real
|
||||
# puede variar — usamos la ruta tal como existe en el host (montada via volume).
|
||||
# El comando real esperado: docker exec <container> mas-cli syn2mas --synapse-config <path>
|
||||
# donde <path> es la ruta tal como el container la ve (via volume mount).
|
||||
# Asumimos que el VPS tiene el config accesible en la misma ruta dentro del container.
|
||||
local container_config="/data/homeserver.yaml"
|
||||
|
||||
echo "mas_syn2mas_migration: ssh-host=${ssh_host} container=${mas_container} max-conflicts=${max_conflicts} apply=${do_apply}" >&2
|
||||
|
||||
# =========================================================================
|
||||
# PASO 1: DRY-RUN obligatorio
|
||||
# =========================================================================
|
||||
echo "mas_syn2mas_migration: ejecutando dry-run..." >&2
|
||||
|
||||
local dry_exit=0
|
||||
# Capturar stdout+stderr del dry-run en el log y tambien en variable para parsing
|
||||
local dry_output
|
||||
dry_output=$(ssh "$ssh_host" \
|
||||
"docker exec '${mas_container}' mas-cli syn2mas \
|
||||
--synapse-config '${container_config}' \
|
||||
--dry-run" \
|
||||
2>&1) || dry_exit=$?
|
||||
|
||||
# Archivar log con timestamp + header informativo
|
||||
{
|
||||
echo "# mas_syn2mas_migration dry-run"
|
||||
echo "# ts=${ts} ssh-host=${ssh_host} container=${mas_container}"
|
||||
echo "# synapse-config-path=${synapse_config_path}"
|
||||
echo "# exit=${dry_exit}"
|
||||
echo "# ---"
|
||||
printf '%s\n' "$dry_output"
|
||||
} > "$dry_run_log"
|
||||
chmod 0600 "$dry_run_log"
|
||||
|
||||
echo "mas_syn2mas_migration: dry-run exit=${dry_exit}, log=${dry_run_log}" >&2
|
||||
|
||||
if [[ $dry_exit -ne 0 ]]; then
|
||||
# Si el comando SSH falla completamente (no es fallo de syn2mas sino de conectividad)
|
||||
echo "mas_syn2mas_migration: ERROR — dry-run falló con exit ${dry_exit}" >&2
|
||||
local escaped_out
|
||||
escaped_out=$(printf '%s' "${dry_output}" | jq -Rs '.')
|
||||
local dry_run_log_json
|
||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
||||
echo "{\"status\":\"error\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":-1,\"users_migrated\":0,\"duration_s\":0}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# =========================================================================
|
||||
# PASO 2: Parsear conflicts del dry-run
|
||||
# =========================================================================
|
||||
# Regex sobre lineas tipo:
|
||||
# "Conflict:", "Skipping:", "Error processing user", "conflict"
|
||||
# También contamos líneas que indiquen usuarios problemáticos.
|
||||
local conflicts=0
|
||||
local conflict_lines
|
||||
conflict_lines=$(printf '%s\n' "$dry_output" | \
|
||||
grep -ciE '(conflict|skipping|error processing user|cannot migrate|already exists)' 2>/dev/null || true)
|
||||
|
||||
# grep -c devuelve string; convertir a int defensivamente
|
||||
if [[ "$conflict_lines" =~ ^[0-9]+$ ]]; then
|
||||
conflicts=$conflict_lines
|
||||
else
|
||||
# Parser falló de forma inesperada — abortar defensivamente
|
||||
echo "mas_syn2mas_migration: ERROR — no se pudo parsear el conteo de conflicts del dry-run (parser defensivo)" >&2
|
||||
local dry_run_log_json
|
||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
||||
echo "{\"status\":\"error\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":-1,\"users_migrated\":0,\"duration_s\":0}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "mas_syn2mas_migration: conflicts detectados en dry-run: ${conflicts} (max permitido: ${max_conflicts})" >&2
|
||||
|
||||
# =========================================================================
|
||||
# PASO 3: Verificar threshold de conflicts
|
||||
# =========================================================================
|
||||
if [[ $conflicts -gt $max_conflicts ]]; then
|
||||
echo "mas_syn2mas_migration: ABORTADO — conflicts (${conflicts}) > max-conflicts (${max_conflicts})" >&2
|
||||
echo "Revisar: ${dry_run_log}" >&2
|
||||
local dry_run_log_json
|
||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
||||
echo "{\"status\":\"aborted\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":${conflicts},\"users_migrated\":0,\"duration_s\":0}"
|
||||
return 2
|
||||
fi
|
||||
|
||||
# =========================================================================
|
||||
# PASO 4: Si no --apply, terminar aqui con status=ok (dry-run completado)
|
||||
# =========================================================================
|
||||
if [[ "$do_apply" == "false" ]]; then
|
||||
echo "mas_syn2mas_migration: dry-run completado (${conflicts} conflicts). Revisar log y re-ejecutar con --apply." >&2
|
||||
local dry_run_log_json
|
||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
||||
echo "{\"status\":\"ok\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":${conflicts},\"users_migrated\":0,\"duration_s\":0}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# =========================================================================
|
||||
# PASO 5: Migracion REAL (--apply)
|
||||
# =========================================================================
|
||||
echo "mas_syn2mas_migration: ejecutando migracion REAL..." >&2
|
||||
local apply_start
|
||||
apply_start=$(date +%s)
|
||||
|
||||
local apply_exit=0
|
||||
local apply_output
|
||||
apply_output=$(ssh "$ssh_host" \
|
||||
"docker exec '${mas_container}' mas-cli syn2mas \
|
||||
--synapse-config '${container_config}'" \
|
||||
2>&1) || apply_exit=$?
|
||||
|
||||
local apply_end
|
||||
apply_end=$(date +%s)
|
||||
local duration_s=$(( apply_end - apply_start ))
|
||||
|
||||
# Archivar log de apply
|
||||
{
|
||||
echo "# mas_syn2mas_migration apply"
|
||||
echo "# ts=${ts} ssh-host=${ssh_host} container=${mas_container}"
|
||||
echo "# synapse-config-path=${synapse_config_path}"
|
||||
echo "# exit=${apply_exit} duration_s=${duration_s}"
|
||||
echo "# ---"
|
||||
printf '%s\n' "$apply_output"
|
||||
} > "$apply_log_file"
|
||||
chmod 0600 "$apply_log_file"
|
||||
|
||||
apply_log_path="$apply_log_file"
|
||||
echo "mas_syn2mas_migration: apply exit=${apply_exit}, duration=${duration_s}s, log=${apply_log_file}" >&2
|
||||
|
||||
if [[ $apply_exit -ne 0 ]]; then
|
||||
echo "mas_syn2mas_migration: ERROR — migracion real falló con exit ${apply_exit}" >&2
|
||||
local dry_run_log_json apply_log_json
|
||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
||||
apply_log_json=$(printf '%s' "$apply_log_file" | jq -Rs '.')
|
||||
echo "{\"status\":\"error\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":${apply_log_json},\"conflicts\":${conflicts},\"users_migrated\":0,\"duration_s\":${duration_s}}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# =========================================================================
|
||||
# PASO 6: Postcondicion — comparar usuarios en MAS vs Synapse
|
||||
# =========================================================================
|
||||
echo "mas_syn2mas_migration: verificando postcondicion (usuarios MAS vs Synapse)..." >&2
|
||||
|
||||
local mas_user_count=0
|
||||
local synapse_user_count=0
|
||||
local users_migrated=0
|
||||
local post_status="ok"
|
||||
|
||||
# Contar usuarios en MAS via mas-cli admin user list
|
||||
local mas_count_raw
|
||||
mas_count_raw=$(ssh "$ssh_host" \
|
||||
"docker exec '${mas_container}' mas-cli manage list-users --json 2>/dev/null | jq length" \
|
||||
2>/dev/null || echo "0")
|
||||
|
||||
if [[ "$mas_count_raw" =~ ^[0-9]+$ ]]; then
|
||||
mas_user_count=$mas_count_raw
|
||||
else
|
||||
echo "mas_syn2mas_migration: ADVERTENCIA — no se pudo obtener conteo de usuarios MAS (output: ${mas_count_raw})" >&2
|
||||
post_status="ok" # No abortar, solo advertir
|
||||
fi
|
||||
|
||||
# Contar usuarios locales en Synapse via psql (excluyendo bots/AS)
|
||||
# Intentamos obtener el count; si falla, continuamos sin abortar
|
||||
local synapse_count_raw
|
||||
synapse_count_raw=$(ssh "$ssh_host" \
|
||||
"docker exec '${mas_container}' mas-cli syn2mas --synapse-config '${container_config}' --dry-run 2>&1 | grep -oE 'Found [0-9]+ users' | grep -oE '[0-9]+' | head -1" \
|
||||
2>/dev/null || echo "0")
|
||||
|
||||
if [[ "$synapse_count_raw" =~ ^[0-9]+$ ]]; then
|
||||
synapse_user_count=$synapse_count_raw
|
||||
fi
|
||||
|
||||
users_migrated=$mas_user_count
|
||||
|
||||
# Si tenemos ambos counts y difieren significativamente, marcar como warning en log
|
||||
if [[ $synapse_user_count -gt 0 && $mas_user_count -eq 0 ]]; then
|
||||
echo "mas_syn2mas_migration: ADVERTENCIA — MAS reporta 0 usuarios pero Synapse tenia ${synapse_user_count}" >&2
|
||||
post_status="error"
|
||||
fi
|
||||
|
||||
echo "mas_syn2mas_migration: postcondicion: mas_users=${mas_user_count} synapse_users=${synapse_user_count} status=${post_status}" >&2
|
||||
|
||||
# =========================================================================
|
||||
# PASO 7: Emitir JSON final
|
||||
# =========================================================================
|
||||
local dry_run_log_json apply_log_json
|
||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
||||
apply_log_json=$(printf '%s' "$apply_log_file" | jq -Rs '.')
|
||||
|
||||
echo "{\"status\":\"${post_status}\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":${apply_log_json},\"conflicts\":${conflicts},\"users_migrated\":${users_migrated},\"duration_s\":${duration_s}}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente (no sourced)
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
mas_syn2mas_migration "$@"
|
||||
fi
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests para mas_syn2mas_migration
|
||||
# Verifica arg parsing sin conectar al VPS real.
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/mas_syn2mas_migration.sh"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_exit() {
|
||||
local test_name="$1" expected_exit="$2"
|
||||
shift 2
|
||||
local actual_exit=0
|
||||
set +e
|
||||
"$@" >/dev/null 2>&1
|
||||
actual_exit=$?
|
||||
set -e
|
||||
if [[ "$actual_exit" == "$expected_exit" ]]; then
|
||||
echo "PASS: $test_name"
|
||||
((PASS++)) || true
|
||||
else
|
||||
echo "FAIL: $test_name — expected exit $expected_exit, got $actual_exit"
|
||||
((FAIL++)) || true
|
||||
fi
|
||||
}
|
||||
|
||||
assert_stdout_contains() {
|
||||
local test_name="$1" needle="$2"
|
||||
shift 2
|
||||
local output actual_exit=0
|
||||
set +e
|
||||
output=$("$@" 2>/dev/null)
|
||||
actual_exit=$?
|
||||
set -e
|
||||
if echo "$output" | grep -q "$needle"; then
|
||||
echo "PASS: $test_name"
|
||||
((PASS++)) || true
|
||||
else
|
||||
echo "FAIL: $test_name — expected stdout to contain '$needle', got: $output"
|
||||
((FAIL++)) || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Test: aborta con error cuando faltan args obligatorios
|
||||
assert_exit "aborta con error cuando faltan args obligatorios" 1 \
|
||||
mas_syn2mas_migration
|
||||
|
||||
# Test: help no devuelve error
|
||||
assert_exit "help no devuelve error" 0 \
|
||||
mas_syn2mas_migration --help
|
||||
|
||||
# Test: argumento desconocido retorna exit 1
|
||||
assert_exit "argumento desconocido retorna exit 1" 1 \
|
||||
mas_syn2mas_migration --unknown-flag
|
||||
|
||||
# Test: max-conflicts invalido retorna exit 1
|
||||
assert_exit "max-conflicts invalido retorna exit 1" 1 \
|
||||
mas_syn2mas_migration \
|
||||
--ssh-host fake-host \
|
||||
--mas-container fake-container \
|
||||
--synapse-config-path /fake/homeserver.yaml \
|
||||
--log-dir "/tmp/test_mas_migration_$$" \
|
||||
--max-conflicts "not-a-number"
|
||||
|
||||
# Test: help emite JSON valido con status=help
|
||||
assert_stdout_contains "help emite JSON con status help" '"status":"help"' \
|
||||
mas_syn2mas_migration --help
|
||||
|
||||
# Test: falta --ssh-host emite JSON con status=error
|
||||
assert_stdout_contains "falta ssh-host emite JSON error" '"status":"error"' \
|
||||
mas_syn2mas_migration \
|
||||
--mas-container fake-container \
|
||||
--synapse-config-path /fake/homeserver.yaml \
|
||||
--log-dir "/tmp/test_mas_migration_$$"
|
||||
|
||||
# Test: falta --log-dir emite JSON con status=error
|
||||
assert_stdout_contains "falta log-dir emite JSON error" '"status":"error"' \
|
||||
mas_syn2mas_migration \
|
||||
--ssh-host fake-host \
|
||||
--mas-container fake-container \
|
||||
--synapse-config-path /fake/homeserver.yaml
|
||||
|
||||
# Limpieza
|
||||
rm -rf "/tmp/test_mas_migration_$$" 2>/dev/null || true
|
||||
|
||||
echo "---"
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]] || exit 1
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: start_nordvpn_socks_bridge
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "start_nordvpn_socks_bridge([--port N] [--socks-host HOST] [--socks-port N] [--user U] [--pass P]) -> JSON"
|
||||
description: "Levanta un proxy HTTP local sin auth que reenvía al servidor SOCKS5 de NordVPN con auth usando gost v3. Resuelve la limitación de Chrome, que no soporta SOCKS5-con-auth: el navegador apunta a http://127.0.0.1:<port> (sin auth) y el tráfico sale por NordVPN. Idempotente: si el puerto ya escucha, no relanza."
|
||||
tags: [navegator, vpn, proxy, nordvpn, socks5, gost, chrome, cdp]
|
||||
params:
|
||||
- name: "--port"
|
||||
desc: "Puerto HTTP local del bridge (default 8889)"
|
||||
- name: "--socks-host"
|
||||
desc: "Servidor SOCKS5 de NordVPN (default socks-nl1.nordvpn.com)"
|
||||
- name: "--socks-port"
|
||||
desc: "Puerto del servidor SOCKS5 (default 1080)"
|
||||
- name: "--user"
|
||||
desc: "Service username de NordVPN. Si se omite, lee NORDVPN_SOCKS_USER del entorno"
|
||||
- name: "--pass"
|
||||
desc: "Service password de NordVPN. Si se omite, lee NORDVPN_SOCKS_PASS del entorno"
|
||||
output: "JSON en stdout: {proxy_url, pid, socks_host, status}. status puede ser 'running' (lanzado ahora) o 'already_running' (puerto ya escuchaba). Errores a stderr + exit 1."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/start_nordvpn_socks_bridge.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Con env vars (recomendado para scripts):
|
||||
NORDVPN_SOCKS_USER=xxx NORDVPN_SOCKS_PASS=yyy \
|
||||
bash bash/functions/infra/start_nordvpn_socks_bridge.sh \
|
||||
--port 8889 \
|
||||
--socks-host socks-nl1.nordvpn.com
|
||||
|
||||
# Salida (primera vez):
|
||||
# {"proxy_url":"http://127.0.0.1:8889","pid":12345,"socks_host":"socks-nl1.nordvpn.com","status":"running"}
|
||||
|
||||
# Salida (idempotente, ya corría):
|
||||
# {"proxy_url":"http://127.0.0.1:8889","pid":null,"socks_host":"socks-nl1.nordvpn.com","status":"already_running"}
|
||||
|
||||
# Luego Chrome (o el flujo CDP del navegator) apunta al bridge:
|
||||
# chrome.exe --proxy-server=http://127.0.0.1:8889
|
||||
|
||||
# Verificar que el tráfico sale por NordVPN:
|
||||
# curl -x http://127.0.0.1:8889 https://api.ipify.org
|
||||
# -> 109.202.99.x (IP NordVPN NL, no la IP de casa)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas que Chrome (o cualquier app que solo acepta proxy HTTP sin auth) salga por NordVPN, pero NordVPN solo ofrece SOCKS5-con-auth. Chrome no soporta SOCKS5-with-authentication — este bridge actúa de intermediario sin auth local. Útil especialmente en el flujo CDP del navegator (cdp-cli + agente browser) cuando quieres que el browser de automatización salga con IP NordVPN para evadir DPI del ISP o geo-bloqueos, sin exponer las credenciales NordVPN al proceso del browser.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **NordVPN SOCKS5 exige service credentials**, no el usuario/contraseña de la cuenta NordVPN. Se obtienen en dashboard.nordvpn.com → Manual Setup → Service credentials.
|
||||
- **Chrome no soporta SOCKS5-auth** nativamente (a diferencia de Firefox que sí). Por eso este bridge HTTP-sin-auth es necesario.
|
||||
- **gost escucha en todas las interfaces** (`-L http://:PORT`). El puerto local NO tiene autenticación. No exponer en redes no confiables. Para bind solo a loopback cambiar el flag a `-L http://127.0.0.1:PORT` en el script si es necesario.
|
||||
- **Servidores NordVPN SOCKS5 disponibles**: `socks-nl1..8.nordvpn.com`, `socks-de1..4.nordvpn.com`, `socks-us1..8.nordvpn.com`, etc. La lista completa en el dashboard de NordVPN.
|
||||
- **Si gost no está instalado**: se descarga automáticamente `gost v3.0.0 linux amd64` a `~/.local/bin/gost`. Requiere curl y tar.
|
||||
- **Log**: en `/tmp/nordvpn_socks_bridge_<port>.log`. Consultar si el bridge no arranca.
|
||||
- **PID null en already_running**: cuando el puerto ya escuchaba, el PID del proceso no se recupera (habría que hacer `lsof`/`ss` para identificarlo).
|
||||
- **Consumidor principal**: flujo `navegator`/CDP — ver `docs/capabilities/navegator.md`. El agente browser lanza este bridge antes de abrir Chrome con `--proxy-server=http://127.0.0.1:<port>`.
|
||||
- **Gotcha invocación desde el Bash tool de Claude (exit 144)**: el script deja gost en background (`nohup ... & disown`); ese daemon retiene el pipe de stdout del tool → el harness mata el proceso con SIGSTKFLT (exit 144) AUNQUE el bridge SÍ arranca bien. Lanzar con `run_in_background:true` o redirigiendo todo (`>/tmp/x 2>&1 </dev/null`) para evitarlo. En terminal real (o `fn run` interactivo) no ocurre. Verificado 2026-05-30: el bridge queda corriendo y funcional pese al 144.
|
||||
- **Windows→WSL**: si Chrome corre en Windows (chrome.exe) y gost en WSL2, Chrome alcanza `127.0.0.1:<port>` vía localhostForwarding de WSL2. Verificar con `curl -x http://127.0.0.1:<port> https://api.ipify.org` desde ambos lados.
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# start_nordvpn_socks_bridge — Levanta proxy HTTP local sin auth que reenvía a SOCKS5 NordVPN con auth via gost v3.
|
||||
# Resuelve la limitacion de Chrome que no soporta SOCKS5-con-auth.
|
||||
set -euo pipefail
|
||||
|
||||
# --- defaults ---
|
||||
PORT=8889
|
||||
SOCKS_HOST="socks-nl1.nordvpn.com"
|
||||
SOCKS_PORT=1080
|
||||
VPN_USER="${NORDVPN_SOCKS_USER:-}"
|
||||
VPN_PASS="${NORDVPN_SOCKS_PASS:-}"
|
||||
|
||||
# --- parse args ---
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port) PORT="$2"; shift 2 ;;
|
||||
--socks-host) SOCKS_HOST="$2"; shift 2 ;;
|
||||
--socks-port) SOCKS_PORT="$2"; shift 2 ;;
|
||||
--user) VPN_USER="$2"; shift 2 ;;
|
||||
--pass) VPN_PASS="$2"; shift 2 ;;
|
||||
*) echo "Unknown arg: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- validate creds ---
|
||||
if [[ -z "$VPN_USER" ]]; then
|
||||
echo "error: NORDVPN_SOCKS_USER not set. Use --user or export NORDVPN_SOCKS_USER" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$VPN_PASS" ]]; then
|
||||
echo "error: NORDVPN_SOCKS_PASS not set. Use --pass or export NORDVPN_SOCKS_PASS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LOG_FILE="/tmp/nordvpn_socks_bridge_${PORT}.log"
|
||||
|
||||
# --- check idempotencia: ya escucha? ---
|
||||
if ss -ltn 2>/dev/null | grep -q ":${PORT} "; then
|
||||
echo "{\"proxy_url\":\"http://127.0.0.1:${PORT}\",\"pid\":null,\"socks_host\":\"${SOCKS_HOST}\",\"status\":\"already_running\"}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- asegurar gost ---
|
||||
GOST_BIN=""
|
||||
if command -v gost &>/dev/null; then
|
||||
GOST_BIN="$(command -v gost)"
|
||||
elif [[ -x "$HOME/.local/bin/gost" ]]; then
|
||||
GOST_BIN="$HOME/.local/bin/gost"
|
||||
else
|
||||
echo "gost not found, downloading v3.0.0 linux amd64..." >&2
|
||||
GOST_URL="https://github.com/go-gost/gost/releases/download/v3.0.0/gost_3.0.0_linux_amd64.tar.gz"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
curl -fsSL "$GOST_URL" -o "$TMP_DIR/gost.tar.gz" >&2
|
||||
tar -xzf "$TMP_DIR/gost.tar.gz" -C "$TMP_DIR" >&2
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
cp "$TMP_DIR/gost" "$HOME/.local/bin/gost"
|
||||
chmod +x "$HOME/.local/bin/gost"
|
||||
rm -rf "$TMP_DIR"
|
||||
GOST_BIN="$HOME/.local/bin/gost"
|
||||
echo "gost installed to $GOST_BIN" >&2
|
||||
fi
|
||||
|
||||
# --- url-encode user y pass (puede tener caracteres especiales) ---
|
||||
url_encode() {
|
||||
python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=''))" "$1"
|
||||
}
|
||||
|
||||
ENC_USER="$(url_encode "$VPN_USER")"
|
||||
ENC_PASS="$(url_encode "$VPN_PASS")"
|
||||
|
||||
# --- lanzar gost en background ---
|
||||
nohup "$GOST_BIN" \
|
||||
-L "http://:${PORT}" \
|
||||
-F "socks5://${ENC_USER}:${ENC_PASS}@${SOCKS_HOST}:${SOCKS_PORT}" \
|
||||
>"$LOG_FILE" 2>&1 &
|
||||
GOST_PID=$!
|
||||
disown $GOST_PID
|
||||
|
||||
# --- esperar ~2s y verificar que el puerto escucha ---
|
||||
sleep 2
|
||||
if ! ss -ltn 2>/dev/null | grep -q ":${PORT} "; then
|
||||
echo "error: gost did not start. Last lines of $LOG_FILE:" >&2
|
||||
tail -10 "$LOG_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "{\"proxy_url\":\"http://127.0.0.1:${PORT}\",\"pid\":${GOST_PID},\"socks_host\":\"${SOCKS_HOST}\",\"status\":\"running\"}"
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: wg_client_install
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "wg_client_install(config_path_or_stdin, [interface_name]) -> json"
|
||||
description: "Device-side: instala wg0.conf en /etc/wireguard/, habilita systemd wg-quick@wg0, verifica handshake con hub. Idempotente. Acepta config por path o stdin (para pipes desde wg_client_config)."
|
||||
tags: [wireguard, client, install, mesh, systemd]
|
||||
uses_functions: [wg_install_bash_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: config_path_or_stdin
|
||||
desc: "path al archivo .conf existente, o '-' para leer de stdin (compatible con pipe desde wg_client_config)"
|
||||
- name: interface_name
|
||||
desc: "nombre de la interfaz WireGuard (default: wg0). Determina /etc/wireguard/<iface>.conf y la unit systemd wg-quick@<iface>"
|
||||
output: "JSON {status, interface, hub_endpoint, handshake_seen}. status: installed | already-configured | installed-no-handshake | installed-no-systemd"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/wg_client_install.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/wg_client_install.sh
|
||||
|
||||
# Desde pipe (caso más común en flow 0009):
|
||||
wg_client_config_go_infra | jq -r '.INI' | wg_client_install -
|
||||
# {"status":"installed","interface":"wg0","hub_endpoint":"203.0.113.1:51820","handshake_seen":true}
|
||||
|
||||
# Desde archivo .conf generado previamente:
|
||||
wg_client_install /tmp/peer_laptop.conf
|
||||
# {"status":"installed","interface":"wg0","hub_endpoint":"203.0.113.1:51820","handshake_seen":true}
|
||||
|
||||
# Con interfaz personalizada:
|
||||
wg_client_install /tmp/peer_laptop.conf wg1
|
||||
# {"status":"installed","interface":"wg1","hub_endpoint":"203.0.113.1:51820","handshake_seen":true}
|
||||
|
||||
# Segunda ejecución con misma config (idempotente):
|
||||
wg_client_install /tmp/peer_laptop.conf
|
||||
# {"status":"already-configured","interface":"wg0","hub_endpoint":"203.0.113.1:51820","handshake_seen":false}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites conectar un nuevo peer al mesh WireGuard en el flow 0009. Úsala justo después de `wg_client_config` (que genera el .conf) para instalarlo en el device peer. Es el paso final del onboarding de un nodo: config generada → instalada → verificada con handshake.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Requiere root/sudo** para escribir en `/etc/wireguard/`, hacer `chmod 600`, y ejecutar `systemctl`. El operador debe tener `sudo` sin password para estos comandos, o ejecutar la función como root.
|
||||
- **Idempotente por contenido**: si `/etc/wireguard/<iface>.conf` ya existe con el mismo contenido, retorna `status=already-configured` sin tocar nada. Si el contenido difiere, hace backup automático con timestamp antes de sobreescribir.
|
||||
- **NetworkManager**: si NM gestiona la interfaz wg0, `wg-quick` puede fallar con conflicto. Solución: crear `/etc/NetworkManager/conf.d/99-wg.conf` con `[keyfile]\nunmanaged-devices=interface-name:wg0` y reiniciar NM antes de ejecutar esta función.
|
||||
- **WSL2 sin systemd** (variantes antiguas o sin `/etc/wsl.conf` con `[boot] systemd=true`): `systemctl` no está disponible. La función detecta esto, emite `status=installed-no-systemd` con instrucciones en stderr para levantar la interfaz manualmente con `sudo wg-quick up wg0`. Para autostart en WSL2 sin systemd: añadir `sudo wg-quick up wg0` al final de `~/.bashrc`.
|
||||
- **WSL2 con systemd**: kernel WSL2 >= 5.6 (default en distros recientes) incluye WireGuard built-in. Habilitar systemd en WSL2 con `[boot]\nsystemd=true` en `/etc/wsl.conf` y reiniciar WSL. Luego esta función funciona igual que en Linux nativo.
|
||||
- **Android / Termux**: NO usar esta función. Termux no tiene systemd ni `/etc/wireguard/`. En Android usar la app WireGuard oficial (F-Droid / Play Store) e importar el .conf generado por `wg_client_config` directamente desde la app.
|
||||
- **handshake_seen=false con status=installed-no-handshake**: la interfaz está activa pero el hub no ha respondido en 10s. No es un error fatal — puede tardar más si el hub está ocupado o hay NAT traversal pendiente. Verificar: endpoint accesible por UDP, hub corriendo con `wg show`, claves public/preshared coincidentes.
|
||||
- Los logs van siempre a stderr con prefijo `[wg_client_install]`; stdout es exclusivamente el JSON de resultado.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
<!-- Rellenar solo cuando haya version bump real -->
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# wg_client_install — Device-side: instala wg0.conf en /etc/wireguard/, habilita
|
||||
# systemd wg-quick@<iface>, verifica handshake con hub. Idempotente.
|
||||
# Acepta config por path o stdin ("-").
|
||||
# Exit 0 = éxito (installed o already-configured), 1 = error fatal.
|
||||
|
||||
wg_client_install() {
|
||||
local config_src="${1:--}"
|
||||
local iface="${2:-wg0}"
|
||||
local conf_dest="/etc/wireguard/${iface}.conf"
|
||||
local config_content="" hub_endpoint="" handshake_seen="false"
|
||||
|
||||
_wg_ci_log() { echo "[wg_client_install] $*" >&2; }
|
||||
|
||||
# ── Prereq: wg debe estar instalado ──────────────────────────────────────
|
||||
if ! command -v wg &>/dev/null; then
|
||||
_wg_ci_log "ERROR: 'wg' no encontrado. Ejecuta wg_install primero."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── Leer contenido del .conf ──────────────────────────────────────────────
|
||||
if [[ "${config_src}" == "-" ]]; then
|
||||
_wg_ci_log "Leyendo config desde stdin"
|
||||
config_content=$(cat) || { _wg_ci_log "ERROR: fallo al leer stdin"; return 1; }
|
||||
elif [[ -f "${config_src}" ]]; then
|
||||
_wg_ci_log "Leyendo config desde ${config_src}"
|
||||
config_content=$(cat "${config_src}") || { _wg_ci_log "ERROR: fallo al leer ${config_src}"; return 1; }
|
||||
else
|
||||
_wg_ci_log "ERROR: '${config_src}' no es un path existente ni '-' (stdin)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -z "${config_content}" ]]; then
|
||||
_wg_ci_log "ERROR: contenido de config vacío"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── Extraer endpoint del hub para incluirlo en el JSON de salida ──────────
|
||||
hub_endpoint=$(printf '%s\n' "${config_content}" | grep -m1 '^Endpoint\s*=' | sed 's/.*=\s*//' | tr -d '[:space:]' || true)
|
||||
|
||||
# ── Idempotencia: comparar con conf existente ─────────────────────────────
|
||||
if [[ -f "${conf_dest}" ]]; then
|
||||
local existing_content
|
||||
existing_content=$(sudo cat "${conf_dest}" 2>/dev/null || cat "${conf_dest}" 2>/dev/null || true)
|
||||
if [[ "${existing_content}" == "${config_content}" ]]; then
|
||||
_wg_ci_log "Configuración idéntica ya presente en ${conf_dest}; nada que hacer"
|
||||
printf '{"status":"already-configured","interface":"%s","hub_endpoint":"%s","handshake_seen":false}\n' \
|
||||
"${iface}" "${hub_endpoint}"
|
||||
return 0
|
||||
fi
|
||||
# Contenido difiere → backup + rewrite
|
||||
local backup="${conf_dest}.bak.$(date +%Y%m%d%H%M%S)"
|
||||
_wg_ci_log "Configuración existente difiere; backup → ${backup}"
|
||||
sudo cp "${conf_dest}" "${backup}" \
|
||||
|| { _wg_ci_log "ERROR: no se pudo hacer backup de ${conf_dest}"; return 1; }
|
||||
fi
|
||||
|
||||
# ── Crear directorio y escribir conf ─────────────────────────────────────
|
||||
sudo mkdir -p "/etc/wireguard" \
|
||||
|| { _wg_ci_log "ERROR: no se pudo crear /etc/wireguard"; return 1; }
|
||||
|
||||
printf '%s\n' "${config_content}" | sudo tee "${conf_dest}" >/dev/null \
|
||||
|| { _wg_ci_log "ERROR: no se pudo escribir ${conf_dest}"; return 1; }
|
||||
|
||||
sudo chmod 600 "${conf_dest}" \
|
||||
|| { _wg_ci_log "WARN: no se pudo chmod 600 ${conf_dest}"; }
|
||||
|
||||
_wg_ci_log "Config escrita en ${conf_dest} (chmod 600)"
|
||||
|
||||
# ── Habilitar + arrancar systemd unit ─────────────────────────────────────
|
||||
if ! command -v systemctl &>/dev/null; then
|
||||
_wg_ci_log "WARN: systemctl no disponible."
|
||||
_wg_ci_log " En WSL2 sin systemd: ejecuta 'sudo wg-quick up ${iface}' manualmente."
|
||||
_wg_ci_log " Para autostart en WSL2: añade 'sudo wg-quick up ${iface}' a ~/.bashrc o usa WSL2 con systemd habilitado."
|
||||
printf '{"status":"installed-no-systemd","interface":"%s","hub_endpoint":"%s","handshake_seen":false}\n' \
|
||||
"${iface}" "${hub_endpoint}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
_wg_ci_log "Habilitando y arrancando wg-quick@${iface}"
|
||||
if ! sudo systemctl enable --now "wg-quick@${iface}" 2>&1 | tee /dev/stderr >&2; then
|
||||
_wg_ci_log "ERROR: systemctl enable --now wg-quick@${iface} falló."
|
||||
_wg_ci_log " En WSL2: asegúrate de tener kernel >= 5.6 y systemd habilitado (/etc/wsl.conf: [boot] systemd=true)."
|
||||
_wg_ci_log " Si NetworkManager gestiona ${iface}: añade 'unmanaged-devices=interface-name:${iface}' a /etc/NetworkManager/conf.d/99-wg.conf"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_wg_ci_log "wg-quick@${iface} habilitado y activo"
|
||||
|
||||
# ── Esperar handshake (hasta 10 s) ────────────────────────────────────────
|
||||
local deadline=$(( $(date +%s) + 10 ))
|
||||
_wg_ci_log "Esperando handshake en ${iface} (timeout 10s)..."
|
||||
while [[ $(date +%s) -lt ${deadline} ]]; do
|
||||
local hs_output
|
||||
hs_output=$(sudo wg show "${iface}" latest-handshakes 2>/dev/null || true)
|
||||
# latest-handshakes devuelve "<pubkey> <unix_ts>"; ts > 0 = handshake visto
|
||||
if printf '%s\n' "${hs_output}" | awk '{print $2}' | grep -qE '^[1-9][0-9]+$'; then
|
||||
handshake_seen="true"
|
||||
_wg_ci_log "Handshake confirmado en ${iface}"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ "${handshake_seen}" == "false" ]]; then
|
||||
_wg_ci_log "WARN: timeout esperando handshake en ${iface}. La interfaz está activa pero el hub no ha respondido aún."
|
||||
_wg_ci_log " Verifica: endpoint accesible, hub corriendo, claves correctas."
|
||||
printf '{"status":"installed-no-handshake","interface":"%s","hub_endpoint":"%s","handshake_seen":false}\n' \
|
||||
"${iface}" "${hub_endpoint}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '{"status":"installed","interface":"%s","hub_endpoint":"%s","handshake_seen":true}\n' \
|
||||
"${iface}" "${hub_endpoint}"
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: wg_hub_setup
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "wg_hub_setup(private_key, subnet_cidr, listen_port) -> json"
|
||||
description: "Configura el host como hub WireGuard (servidor). Crea /etc/wireguard/wg0.conf con clave privada + IP pool + ListenPort. Abre UDP en firewall (ufw o iptables), habilita ip_forward persistente en /etc/sysctl.d/99-wireguard.conf, persiste y arranca systemd unit wg-quick@wg0. Idempotente: misma PrivateKey = no-op; PrivateKey distinta = backup + rewrite."
|
||||
tags: [wireguard, hub, infra, mesh, systemd]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: private_key
|
||||
desc: "base64 WG private key del hub (44 chars, generada por wg_keygen o `wg genkey`)"
|
||||
- name: subnet_cidr
|
||||
desc: "subnet hub con bits del host, ej. 10.42.0.1/24. El hub recibe la .1"
|
||||
- name: listen_port
|
||||
desc: "UDP port donde escucha WireGuard (default 51820, rango 1024-65535)"
|
||||
output: "JSON {status, config_path, interface, hub_ip}. status: configured | reconfigured | already-configured"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/wg_hub_setup.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Generar clave (o usar wg_keygen del registry)
|
||||
PRIVKEY=$(wg genkey)
|
||||
|
||||
source bash/functions/infra/wg_hub_setup.sh
|
||||
wg_hub_setup "$PRIVKEY" "10.42.0.1/24" 51820
|
||||
# {"status":"configured","config_path":"/etc/wireguard/wg0.conf","interface":"wg0","hub_ip":"10.42.0.1"}
|
||||
|
||||
# Segunda ejecución con la misma clave → no-op
|
||||
wg_hub_setup "$PRIVKEY" "10.42.0.1/24" 51820
|
||||
# {"status":"already-configured","config_path":"/etc/wireguard/wg0.conf","interface":"wg0","hub_ip":"10.42.0.1"}
|
||||
|
||||
# Cambiar clave → backup de conf anterior + rewrite
|
||||
wg_hub_setup "$NUEVA_PRIVKEY" "10.42.0.1/24" 51820
|
||||
# {"status":"reconfigured","config_path":"/etc/wireguard/wg0.conf","interface":"wg0","hub_ip":"10.42.0.1"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites convertir un VPS/host en el nodo central (hub) de una red mesh WireGuard. Úsala inmediatamente después de `wg_install` para dejar el hub listo para recibir peers. El hub escucha en un puerto UDP público; los peers se conectan a él con su propia clave y la AllowedIPs del hub.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Requiere `sudo` con NOPASSWD para: `tee /etc/wireguard/`, `chmod`, `sysctl`, `iptables`/`ufw`, `systemctl`. Configurar antes en sudoers.
|
||||
- NUNCA reusar la misma `private_key` entre hubs distintos. Cada hub tiene su propio par de claves independiente.
|
||||
- El bloque `PostUp`/`PostDown` usa `eth0` como interfaz de salida para NAT. En VPS con interfaz distinta (ens3, enp3s0) editar `/etc/wireguard/wg0.conf` manualmente antes de reiniciar.
|
||||
- Conflicto de subnet con docker0 si usas 172.17.0.0/16. Evitar solapamiento — usar 10.42.x.x o 192.168.200.x para WireGuard.
|
||||
- `systemd-resolved` en VPS Ubuntu puede interferir con resolución DNS cuando WireGuard está activo si el conf añade `DNS =`. Esta función NO setea DNS para evitar el problema — configurarlo a nivel peer si se necesita.
|
||||
- Si `systemctl start wg-quick@wg0` falla, revisar logs con `journalctl -u wg-quick@wg0 -n 50`.
|
||||
- En entornos cloud (AWS/GCP/Azure) el security group / firewall de red del proveedor también debe abrir el puerto UDP, independientemente de ufw/iptables local.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
<!-- Rellenar solo cuando haya version bump real -->
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env bash
|
||||
# wg_hub_setup — Configura el host como hub WireGuard (servidor central).
|
||||
# Crea /etc/wireguard/wg0.conf con [Interface] block, abre UDP en firewall,
|
||||
# habilita ip_forward persistente, arranca y verifica wg-quick@wg0.
|
||||
# Idempotente: si el conf existe con la misma PrivateKey -> no-op.
|
||||
# Emite JSON a stdout. Logs a stderr con prefijo [wg_hub_setup].
|
||||
# Exit 0 = éxito, 1 = fallo.
|
||||
|
||||
wg_hub_setup() {
|
||||
local private_key="${1:-}"
|
||||
local subnet_cidr="${2:-10.42.0.1/24}"
|
||||
local listen_port="${3:-51820}"
|
||||
|
||||
_wg_hub_log() { echo "[wg_hub_setup] $*" >&2; }
|
||||
|
||||
# ── Validación de entradas ──────────────────────────────────────────────
|
||||
|
||||
# private_key: base64 estándar de 44 caracteres (32 bytes)
|
||||
if [[ -z "${private_key}" ]]; then
|
||||
_wg_hub_log "ERROR: private_key requerida (base64 44 chars, generada por wg genkey)"
|
||||
return 1
|
||||
fi
|
||||
if ! [[ "${private_key}" =~ ^[A-Za-z0-9+/]{43}=$ ]]; then
|
||||
_wg_hub_log "ERROR: private_key no parece base64 válida (se esperan 44 chars terminando en '=')"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# subnet_cidr: 10.x.x.x/nn
|
||||
if ! [[ "${subnet_cidr}" =~ ^10\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]]; then
|
||||
_wg_hub_log "ERROR: subnet_cidr debe ser 10.x.x.x/nn, recibido: '${subnet_cidr}'"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# listen_port: 1024-65535
|
||||
if ! [[ "${listen_port}" =~ ^[0-9]+$ ]] || (( listen_port < 1024 || listen_port > 65535 )); then
|
||||
_wg_hub_log "ERROR: listen_port debe ser un entero entre 1024 y 65535, recibido: '${listen_port}'"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── Verificar que wireguard-tools esté instalado ────────────────────────
|
||||
if ! command -v wg &>/dev/null; then
|
||||
_wg_hub_log "ERROR: 'wg' no encontrado. Ejecuta wg_install primero."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v wg-quick &>/dev/null; then
|
||||
_wg_hub_log "ERROR: 'wg-quick' no encontrado. Instala wireguard-tools."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── Extraer hub_ip (parte sin CIDR prefix) y determinar config_path ────
|
||||
local hub_ip="${subnet_cidr%%/*}"
|
||||
local config_path="/etc/wireguard/wg0.conf"
|
||||
local interface="wg0"
|
||||
local action_status=""
|
||||
|
||||
# ── Idempotencia: comparar PrivateKey existente ─────────────────────────
|
||||
if [[ -f "${config_path}" ]]; then
|
||||
local existing_key
|
||||
existing_key=$(sudo grep -E '^\s*PrivateKey\s*=' "${config_path}" 2>/dev/null \
|
||||
| head -n1 | sed 's/.*=\s*//')
|
||||
if [[ "${existing_key}" == "${private_key}" ]]; then
|
||||
_wg_hub_log "Config existente con misma PrivateKey — no-op (status=already-configured)"
|
||||
printf '{"status":"already-configured","config_path":"%s","interface":"%s","hub_ip":"%s"}\n' \
|
||||
"${config_path}" "${interface}" "${hub_ip}"
|
||||
return 0
|
||||
else
|
||||
_wg_hub_log "Config existente con PrivateKey DIFERENTE — haciendo backup y reescribiendo"
|
||||
local backup_path="${config_path}.bak.$(date +%Y%m%d%H%M%S)"
|
||||
sudo cp "${config_path}" "${backup_path}" \
|
||||
|| { _wg_hub_log "ERROR: no se pudo hacer backup en ${backup_path}"; return 1; }
|
||||
_wg_hub_log "Backup guardado en ${backup_path}"
|
||||
action_status="reconfigured"
|
||||
fi
|
||||
else
|
||||
action_status="configured"
|
||||
fi
|
||||
|
||||
# ── Asegurar que /etc/wireguard existe con permisos correctos ───────────
|
||||
if [[ ! -d /etc/wireguard ]]; then
|
||||
sudo mkdir -p /etc/wireguard \
|
||||
|| { _wg_hub_log "ERROR: no se pudo crear /etc/wireguard"; return 1; }
|
||||
sudo chmod 700 /etc/wireguard
|
||||
_wg_hub_log "Directorio /etc/wireguard creado"
|
||||
fi
|
||||
|
||||
# ── Escribir /etc/wireguard/wg0.conf ────────────────────────────────────
|
||||
_wg_hub_log "Escribiendo ${config_path} (Address=${subnet_cidr}, ListenPort=${listen_port})"
|
||||
sudo tee "${config_path}" > /dev/null <<EOF
|
||||
[Interface]
|
||||
Address = ${subnet_cidr}
|
||||
ListenPort = ${listen_port}
|
||||
PrivateKey = ${private_key}
|
||||
SaveConfig = false
|
||||
|
||||
# NAT: permite que los peers accedan a internet via este hub (opcional, comentar si no se desea)
|
||||
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
|
||||
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
|
||||
EOF
|
||||
|
||||
if [[ $? -ne 0 ]]; then
|
||||
_wg_hub_log "ERROR: no se pudo escribir ${config_path}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
sudo chmod 600 "${config_path}" \
|
||||
|| { _wg_hub_log "ERROR: chmod 600 ${config_path} falló"; return 1; }
|
||||
_wg_hub_log "Permisos 600 aplicados a ${config_path}"
|
||||
|
||||
# ── Habilitar ip_forward persistente ────────────────────────────────────
|
||||
local sysctl_file="/etc/sysctl.d/99-wireguard.conf"
|
||||
if [[ ! -f "${sysctl_file}" ]] || ! grep -q "net.ipv4.ip_forward" "${sysctl_file}" 2>/dev/null; then
|
||||
_wg_hub_log "Habilitando ip_forward en ${sysctl_file}"
|
||||
echo "net.ipv4.ip_forward = 1" | sudo tee "${sysctl_file}" > /dev/null \
|
||||
|| { _wg_hub_log "ERROR: no se pudo escribir ${sysctl_file}"; return 1; }
|
||||
fi
|
||||
sudo sysctl -p "${sysctl_file}" >&2 \
|
||||
|| _wg_hub_log "WARN: sysctl -p falló (puede ignorarse si el kernel ya tiene ip_forward=1)"
|
||||
|
||||
# ── Abrir puerto en firewall ─────────────────────────────────────────────
|
||||
if command -v ufw &>/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||
_wg_hub_log "ufw activo — abriendo UDP/${listen_port}"
|
||||
sudo ufw allow "${listen_port}/udp" >&2 \
|
||||
|| _wg_hub_log "WARN: ufw allow ${listen_port}/udp falló (verificar manualmente)"
|
||||
elif command -v iptables &>/dev/null; then
|
||||
_wg_hub_log "ufw inactivo — usando iptables para abrir UDP/${listen_port}"
|
||||
sudo iptables -C INPUT -p udp --dport "${listen_port}" -j ACCEPT 2>/dev/null \
|
||||
|| sudo iptables -A INPUT -p udp --dport "${listen_port}" -j ACCEPT >&2 \
|
||||
|| _wg_hub_log "WARN: iptables INPUT rule falló (verificar manualmente)"
|
||||
else
|
||||
_wg_hub_log "WARN: ni ufw ni iptables disponibles — abre el puerto ${listen_port}/udp manualmente"
|
||||
fi
|
||||
|
||||
# ── Detener interfaz si estaba corriendo (para aplicar nueva config) ────
|
||||
if sudo wg show "${interface}" &>/dev/null 2>&1; then
|
||||
_wg_hub_log "Interfaz ${interface} activa — deteniendo antes de reconfigurar"
|
||||
sudo systemctl stop "wg-quick@${interface}" 2>/dev/null \
|
||||
|| sudo wg-quick down "${interface}" 2>/dev/null \
|
||||
|| _wg_hub_log "WARN: no se pudo detener ${interface} (puede que no estuviera activa)"
|
||||
fi
|
||||
|
||||
# ── Habilitar y arrancar wg-quick@wg0 ────────────────────────────────────
|
||||
_wg_hub_log "Habilitando systemd unit wg-quick@${interface}"
|
||||
sudo systemctl enable "wg-quick@${interface}" >&2 \
|
||||
|| { _wg_hub_log "ERROR: systemctl enable wg-quick@${interface} falló"; return 1; }
|
||||
|
||||
_wg_hub_log "Arrancando wg-quick@${interface}"
|
||||
sudo systemctl start "wg-quick@${interface}" >&2 \
|
||||
|| { _wg_hub_log "ERROR: systemctl start wg-quick@${interface} falló"; return 1; }
|
||||
|
||||
# ── Verificar que la interfaz está UP ────────────────────────────────────
|
||||
local retries=5
|
||||
local up=0
|
||||
for (( i=0; i<retries; i++ )); do
|
||||
if sudo wg show "${interface}" &>/dev/null 2>&1; then
|
||||
up=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ "${up}" -eq 0 ]]; then
|
||||
_wg_hub_log "ERROR: 'wg show ${interface}' falló tras ${retries}s — la interfaz no arrancó"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_wg_hub_log "Interfaz ${interface} UP (status=${action_status})"
|
||||
printf '{"status":"%s","config_path":"%s","interface":"%s","hub_ip":"%s"}\n' \
|
||||
"${action_status}" "${config_path}" "${interface}" "${hub_ip}"
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: wg_install
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "wg_install() -> json"
|
||||
description: "Instala wireguard + wireguard-tools en Linux (debian/ubuntu/fedora/arch). Idempotente. Carga modulo kernel. Emite JSON con distro detectada y version instalada."
|
||||
tags: [wireguard, install, infra, mesh, deploy]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params: []
|
||||
output: "JSON {status, distro, version}. status=installed o already-present."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/wg_install.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/wg_install.sh
|
||||
wg_install
|
||||
# {"status":"installed","distro":"ubuntu","version":"wireguard-tools 1.0.20210914"}
|
||||
|
||||
# Si ya está instalado:
|
||||
wg_install
|
||||
# {"status":"already-present","distro":"ubuntu","version":"wireguard-tools 1.0.20210914"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites asegurarte de que wireguard-tools está disponible en un host antes de configurar un peer o hub WireGuard. Úsala como paso previo en pipelines de bootstrapping de nodos mesh (flow wireguard).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Requiere `sudo` con NOPASSWD para apt-get/dnf/pacman y para modprobe. El operador debe haberlo configurado antes.
|
||||
- `modprobe wireguard` puede fallar en kernels < 5.6 sin DKMS instalado (wireguard-dkms). La función lo trata como advertencia, no como error fatal — la instalación de las herramientas igual se completa.
|
||||
- En RHEL/CentOS instala `epel-release` automáticamente antes de wireguard-tools.
|
||||
- Distros no reconocidas en `/etc/os-release ID` producen exit 1 con mensaje de error explícito en stderr.
|
||||
- Los logs van siempre a stderr con prefijo `[wg_install]`; stdout es exclusivamente el JSON de resultado.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
<!-- Rellenar solo cuando haya version bump real -->
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# wg_install — Instala wireguard + wireguard-tools en Linux (debian/ubuntu/fedora/arch).
|
||||
# Idempotente: si wg ya está instalado emite JSON con status=already-present y sale.
|
||||
# Carga módulo kernel wireguard. Emite JSON a stdout. Logs a stderr con prefijo [wg_install].
|
||||
# Exit 0 = éxito, 1 = fallo.
|
||||
|
||||
wg_install() {
|
||||
local distro="" version="" status=""
|
||||
|
||||
_wg_log() { echo "[wg_install] $*" >&2; }
|
||||
|
||||
# Detectar distro via /etc/os-release
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
distro=$(. /etc/os-release && echo "${ID:-unknown}")
|
||||
else
|
||||
_wg_log "ERROR: /etc/os-release no encontrado; no se puede detectar distro"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_wg_log "Distro detectada: ${distro}"
|
||||
|
||||
# Comprobar si wg ya está instalado (idempotencia)
|
||||
if command -v wg &>/dev/null; then
|
||||
version=$(wg --version 2>/dev/null | head -n1 || echo "unknown")
|
||||
_wg_log "wireguard-tools ya presente (${version}); cargando módulo kernel"
|
||||
# Intentar cargar módulo igualmente (no fatal)
|
||||
sudo modprobe wireguard 2>/dev/null || true
|
||||
printf '{"status":"already-present","distro":"%s","version":"%s"}\n' "${distro}" "${version}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Instalar según distro
|
||||
case "${distro}" in
|
||||
debian|ubuntu|linuxmint|pop|kali|raspbian)
|
||||
_wg_log "Usando apt-get (${distro})"
|
||||
sudo apt-get update -y >&2 || { _wg_log "ERROR: apt-get update falló"; return 1; }
|
||||
sudo apt-get install -y wireguard wireguard-tools >&2 \
|
||||
|| { _wg_log "ERROR: apt-get install wireguard falló"; return 1; }
|
||||
;;
|
||||
fedora)
|
||||
_wg_log "Usando dnf (fedora)"
|
||||
sudo dnf install -y wireguard-tools >&2 \
|
||||
|| { _wg_log "ERROR: dnf install wireguard-tools falló"; return 1; }
|
||||
;;
|
||||
rhel|centos|rocky|almalinux)
|
||||
_wg_log "Usando dnf (rhel/centos/rocky/alma)"
|
||||
sudo dnf install -y epel-release >&2 || true
|
||||
sudo dnf install -y wireguard-tools >&2 \
|
||||
|| { _wg_log "ERROR: dnf install wireguard-tools falló"; return 1; }
|
||||
;;
|
||||
arch|manjaro|endeavouros)
|
||||
_wg_log "Usando pacman (arch)"
|
||||
sudo pacman -S --noconfirm wireguard-tools >&2 \
|
||||
|| { _wg_log "ERROR: pacman install wireguard-tools falló"; return 1; }
|
||||
;;
|
||||
*)
|
||||
_wg_log "ERROR: distro '${distro}' no soportada (soportadas: debian/ubuntu/fedora/rhel/arch)"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Verificar instalación
|
||||
if ! command -v wg &>/dev/null; then
|
||||
_wg_log "ERROR: 'wg' no encontrado tras la instalación"
|
||||
return 1
|
||||
fi
|
||||
|
||||
version=$(wg --version 2>/dev/null | head -n1 || echo "unknown")
|
||||
_wg_log "wireguard-tools instalado: ${version}"
|
||||
|
||||
# Cargar módulo kernel (no fatal: kernels >=5.6 lo incluyen built-in)
|
||||
if sudo modprobe wireguard 2>/dev/null; then
|
||||
_wg_log "Módulo kernel wireguard cargado"
|
||||
else
|
||||
_wg_log "WARN: modprobe wireguard falló (puede estar built-in en el kernel o requerir DKMS)"
|
||||
fi
|
||||
|
||||
status="installed"
|
||||
printf '{"status":"%s","distro":"%s","version":"%s"}\n' "${status}" "${distro}" "${version}"
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: wg_status
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "wg_status([interface_name]) -> json"
|
||||
description: "Parsea `wg show <iface> dump` a JSON estructurado con peers, handshake age, status (online/stale/never), bytes rx/tx. Resuelve device_id desde comentarios en wg0.conf. Para dashboards (agents_dashboard Mesh panel)."
|
||||
tags: [wireguard, status, observability, json, infra]
|
||||
params:
|
||||
- name: interface_name
|
||||
desc: "Nombre de la interface WireGuard (default wg0)"
|
||||
output: "JSON con interface info + array de peers. Cada peer incluye public_key, device_id (de comentario # DeviceID:<id> en wg0.conf), endpoint, allowed_ips, latest_handshake_unix, latest_handshake_ago_s, rx_bytes, tx_bytes, persistent_keepalive, status (online/stale/never)."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests:
|
||||
- "interface con 2 peers online y stale"
|
||||
- "interface sin peers devuelve array vacio"
|
||||
- "interface inexistente devuelve error JSON"
|
||||
- "WG_FAKE_DUMP carga dump de archivo"
|
||||
test_file_path: "bash/functions/infra/wg_status_test.sh"
|
||||
file_path: "bash/functions/infra/wg_status.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Estado real de wg0
|
||||
source bash/functions/infra/wg_status.sh
|
||||
wg_status | jq .
|
||||
|
||||
# Interface distinta
|
||||
wg_status wg1 | jq .peers[].status
|
||||
|
||||
# Sin sudo real (testing / CI)
|
||||
WG_FAKE_DUMP=bash/functions/infra/wg_status_test_dump.tsv wg_status wg0 | jq .
|
||||
```
|
||||
|
||||
Salida representativa:
|
||||
|
||||
```json
|
||||
{
|
||||
"interface": "wg0",
|
||||
"public_key": "abcXYZ123...",
|
||||
"listen_port": "51820",
|
||||
"peers": [
|
||||
{
|
||||
"public_key": "peerKey1...",
|
||||
"device_id": "pc-aurgi",
|
||||
"endpoint": "1.2.3.4:54321",
|
||||
"allowed_ips": ["10.42.0.10/32"],
|
||||
"latest_handshake_unix": 1716000000,
|
||||
"latest_handshake_ago_s": 42,
|
||||
"rx_bytes": 12345,
|
||||
"tx_bytes": 67890,
|
||||
"persistent_keepalive": 25,
|
||||
"status": "online"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites saber el estado del mesh WireGuard desde un script, dashboard o agente. Usa antes de mostrar el panel Mesh en `agents_dashboard`. Llama cada N segundos para polling ligero desde shell sin depender de la API de WireGuard.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Requiere `CAP_NET_ADMIN` / root: `wg show` falla sin permisos. En produccion ejecutar via `sudo -n wg show wg0 dump` o dar permiso al binario. Para tests sin sudo: `WG_FAKE_DUMP=<path>` carga el dump desde archivo.
|
||||
- `listen_port` se devuelve como string (tal como lo emite `wg show dump`). El campo es `"0"` si wg no esta activo pero la interface existe.
|
||||
- `device_id` queda `""` si no hay comentario `# DeviceID:<id>` antes del `[Peer]` correspondiente en `/etc/wireguard/<iface>.conf`.
|
||||
- Status `stale` cubre desde 180s hasta cualquier valor mayor. No hay distincion entre "hace 5 min" y "hace 3 dias" — ambos son `stale`. Para un threshold mas fino, usar `latest_handshake_ago_s` directamente.
|
||||
- Si `/etc/wireguard/<iface>.conf` no existe o no es legible, `device_id` sera `""` para todos los peers (la funcion no falla, solo omite el lookup).
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env bash
|
||||
# wg_status — Parsea `wg show <iface> dump` a JSON estructurado con peers,
|
||||
# handshake age, status (online/stale/never), bytes rx/tx.
|
||||
# Resuelve device_id desde comentarios # DeviceID:<id> en wg0.conf.
|
||||
#
|
||||
# Usage:
|
||||
# wg_status [interface_name] # default: wg0
|
||||
#
|
||||
# Env:
|
||||
# WG_FAKE_DUMP=<path> # lee dump de archivo en vez de llamar wg show (para tests)
|
||||
|
||||
wg_status() {
|
||||
local iface="${1:-wg0}"
|
||||
local conf="${WG_FAKE_CONF:-/etc/wireguard/${iface}.conf}"
|
||||
local now
|
||||
now=$(date +%s)
|
||||
|
||||
# --- obtener dump (real o fake) ---
|
||||
local dump
|
||||
if [[ -n "${WG_FAKE_DUMP:-}" ]]; then
|
||||
if [[ ! -f "$WG_FAKE_DUMP" ]]; then
|
||||
printf '{"error":"WG_FAKE_DUMP file not found: %s"}\n' "$WG_FAKE_DUMP"
|
||||
return 1
|
||||
fi
|
||||
dump=$(cat "$WG_FAKE_DUMP")
|
||||
else
|
||||
if ! command -v wg &>/dev/null; then
|
||||
printf '{"error":"wg command not found"}\n'
|
||||
return 1
|
||||
fi
|
||||
if ! dump=$(wg show "$iface" dump 2>&1); then
|
||||
if echo "$dump" | grep -qi "no such device\|does not exist\|unable to access interface"; then
|
||||
printf '{"error":"interface not found"}\n'
|
||||
return 1
|
||||
fi
|
||||
printf '{"error":"%s"}\n' "$(echo "$dump" | head -n1 | sed 's/"/\\"/g')"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- primera linea: info de la propia interface ---
|
||||
# formato: <private_key>\t<public_key>\t<listen_port>\t<fwmark>
|
||||
local iface_line
|
||||
iface_line=$(echo "$dump" | head -n1)
|
||||
|
||||
local iface_pubkey iface_port
|
||||
iface_pubkey=$(echo "$iface_line" | awk -F'\t' '{print $2}')
|
||||
iface_port=$(echo "$iface_line" | awk -F'\t' '{print $3}')
|
||||
|
||||
# --- leer DeviceID map desde wg0.conf ---
|
||||
# Busca patron:
|
||||
# # DeviceID:<id>
|
||||
# [Peer]
|
||||
# PublicKey = <pk>
|
||||
# Producimos pares "pk\tdevice_id" en un archivo temporal para lookup via awk
|
||||
local device_map
|
||||
device_map=$(awk '
|
||||
/^#[[:space:]]*DeviceID:/ {
|
||||
split($0, a, "DeviceID:")
|
||||
did = a[2]
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", did)
|
||||
pending_did = did
|
||||
}
|
||||
/^\[Peer\]/ {
|
||||
in_peer = 1
|
||||
}
|
||||
in_peer && /^PublicKey[[:space:]]*=/ {
|
||||
pk = $0
|
||||
sub(/^PublicKey[[:space:]]*=[[:space:]]*/, "", pk)
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", pk)
|
||||
if (pending_did != "") {
|
||||
print pk "\t" pending_did
|
||||
pending_did = ""
|
||||
}
|
||||
in_peer = 0
|
||||
}
|
||||
' "$conf" 2>/dev/null)
|
||||
|
||||
# --- parsear peers (lineas 2..N del dump) ---
|
||||
# formato peer: <public_key>\t<preshared_key>\t<endpoint>\t<allowed_ips>\t<latest_handshake>\t<rx_bytes>\t<tx_bytes>\t<persistent_keepalive>
|
||||
local peers_json
|
||||
peers_json=$(echo "$dump" | tail -n +2 | awk -v now="$now" -v dmap="$device_map" '
|
||||
BEGIN {
|
||||
# construir lookup device_id
|
||||
n = split(dmap, lines, "\n")
|
||||
for (i = 1; i <= n; i++) {
|
||||
if (lines[i] != "") {
|
||||
split(lines[i], parts, "\t")
|
||||
pk_to_did[parts[1]] = parts[2]
|
||||
}
|
||||
}
|
||||
first = 1
|
||||
printf "["
|
||||
}
|
||||
NF >= 7 {
|
||||
pk = $1
|
||||
endpoint = $3
|
||||
allowed = $4
|
||||
hs = $5 + 0
|
||||
rx = $6 + 0
|
||||
tx = $7 + 0
|
||||
ka = $8
|
||||
|
||||
# device_id lookup
|
||||
did = (pk in pk_to_did) ? pk_to_did[pk] : ""
|
||||
|
||||
# handshake age y status
|
||||
if (hs == 0) {
|
||||
ago = 0
|
||||
status = "never"
|
||||
} else {
|
||||
ago = now - hs
|
||||
if (ago < 180) status = "online"
|
||||
else if (ago < 86400) status = "stale"
|
||||
else status = "stale"
|
||||
}
|
||||
|
||||
# persistent_keepalive
|
||||
ka_val = (ka == "off" || ka == "") ? 0 : ka + 0
|
||||
|
||||
# endpoint null si "(none)"
|
||||
ep_val = (endpoint == "(none)") ? "null" : "\"" endpoint "\""
|
||||
|
||||
# allowed_ips array
|
||||
n_ips = split(allowed, ips_arr, ",")
|
||||
ips_json = "["
|
||||
for (j = 1; j <= n_ips; j++) {
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", ips_arr[j])
|
||||
ips_json = ips_json "\"" ips_arr[j] "\""
|
||||
if (j < n_ips) ips_json = ips_json ","
|
||||
}
|
||||
ips_json = ips_json "]"
|
||||
|
||||
if (!first) printf ","
|
||||
first = 0
|
||||
|
||||
printf "{"
|
||||
printf "\"public_key\":\"%s\"", pk
|
||||
printf ",\"device_id\":\"%s\"", did
|
||||
printf ",\"endpoint\":%s", ep_val
|
||||
printf ",\"allowed_ips\":%s", ips_json
|
||||
printf ",\"latest_handshake_unix\":%d", hs
|
||||
printf ",\"latest_handshake_ago_s\":%d",ago
|
||||
printf ",\"rx_bytes\":%d", rx
|
||||
printf ",\"tx_bytes\":%d", tx
|
||||
printf ",\"persistent_keepalive\":%d", ka_val
|
||||
printf ",\"status\":\"%s\"", status
|
||||
printf "}"
|
||||
}
|
||||
END { printf "]" }
|
||||
' FS='\t')
|
||||
|
||||
# --- output final ---
|
||||
printf '{"interface":"%s","public_key":"%s","listen_port":%s,"peers":%s}\n' \
|
||||
"$iface" "$iface_pubkey" "$iface_port" "$peers_json"
|
||||
}
|
||||
|
||||
# Permitir invocacion directa: bash wg_status.sh [iface]
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
wg_status "$@"
|
||||
fi
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests para wg_status
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/wg_status.sh"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_contains() {
|
||||
local test_name="$1" needle="$2" haystack="$3"
|
||||
if echo "$haystack" | grep -qF "$needle"; then
|
||||
echo "PASS: $test_name"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
echo "FAIL: $test_name — expected to contain '$needle'"
|
||||
echo " got: $haystack"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
local test_name="$1" needle="$2" haystack="$3"
|
||||
if ! echo "$haystack" | grep -qF "$needle"; then
|
||||
echo "PASS: $test_name"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
echo "FAIL: $test_name — expected NOT to contain '$needle'"
|
||||
echo " got: $haystack"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
# --- fixtures ---
|
||||
FAKE_DUMP=$(mktemp)
|
||||
FAKE_DUMP_EMPTY=$(mktemp)
|
||||
FAKE_CONF=$(mktemp)
|
||||
trap 'rm -f "$FAKE_DUMP" "$FAKE_DUMP_EMPTY" "$FAKE_CONF"' EXIT
|
||||
|
||||
NOW=$(date +%s)
|
||||
HS_ONLINE=$(( NOW - 60 )) # 60s ago → online
|
||||
HS_STALE=$(( NOW - 500 )) # 500s ago → stale
|
||||
|
||||
# dump con 2 peers (tabs como separador)
|
||||
printf '%s\n' \
|
||||
"privKeyBase64== ifacePubKey== 51820 off" \
|
||||
"peerKey1== (none) 1.2.3.4:54321 10.42.0.10/32 ${HS_ONLINE} 12345 67890 25" \
|
||||
"peerKey2== (none) 5.6.7.8:12345 10.42.0.20/32 ${HS_STALE} 111 222 0" \
|
||||
> "$FAKE_DUMP"
|
||||
|
||||
# dump vacío (solo línea de interface, sin peers)
|
||||
printf '%s\n' "privKeyBase64== ifacePubKey== 51820 off" > "$FAKE_DUMP_EMPTY"
|
||||
|
||||
# conf con DeviceID comments
|
||||
cat > "$FAKE_CONF" <<'CONF'
|
||||
[Interface]
|
||||
PrivateKey = privKeyBase64==
|
||||
Address = 10.42.0.1/24
|
||||
ListenPort = 51820
|
||||
|
||||
# DeviceID:pc-aurgi
|
||||
[Peer]
|
||||
PublicKey = peerKey1==
|
||||
AllowedIPs = 10.42.0.10/32
|
||||
|
||||
# DeviceID:home-wsl
|
||||
[Peer]
|
||||
PublicKey = peerKey2==
|
||||
AllowedIPs = 10.42.0.20/32
|
||||
CONF
|
||||
|
||||
# --- Test: interface con 2 peers online y stale ---
|
||||
result=$(WG_FAKE_DUMP="$FAKE_DUMP" WG_FAKE_CONF="$FAKE_CONF" wg_status wg0)
|
||||
assert_contains "interface con 2 peers online y stale" '"interface":"wg0"' "$result"
|
||||
assert_contains "interface con 2 peers online y stale" '"listen_port":51820' "$result"
|
||||
assert_contains "interface con 2 peers online y stale" '"public_key":"ifacePubKey=="' "$result"
|
||||
assert_contains "interface con 2 peers online y stale" '"status":"online"' "$result"
|
||||
assert_contains "interface con 2 peers online y stale" '"status":"stale"' "$result"
|
||||
assert_contains "interface con 2 peers online y stale" '"device_id":"pc-aurgi"' "$result"
|
||||
assert_contains "interface con 2 peers online y stale" '"device_id":"home-wsl"' "$result"
|
||||
assert_contains "interface con 2 peers online y stale" '"rx_bytes":12345' "$result"
|
||||
assert_contains "interface con 2 peers online y stale" '"persistent_keepalive":25' "$result"
|
||||
|
||||
# --- Test: interface sin peers devuelve array vacio ---
|
||||
result_empty=$(WG_FAKE_DUMP="$FAKE_DUMP_EMPTY" WG_FAKE_CONF="$FAKE_CONF" wg_status wg0)
|
||||
assert_contains "interface sin peers devuelve array vacio" '"peers":[]' "$result_empty"
|
||||
assert_not_contains "interface sin peers devuelve array vacio" '"error"' "$result_empty"
|
||||
|
||||
# --- Test: interface inexistente devuelve error JSON ---
|
||||
result_err=$(wg_status nonexistent_iface_xyz 2>/dev/null || true)
|
||||
assert_contains "interface inexistente devuelve error JSON" '"error"' "$result_err"
|
||||
|
||||
# --- Test: WG_FAKE_DUMP carga dump de archivo ---
|
||||
result_fake=$(WG_FAKE_DUMP="$FAKE_DUMP" WG_FAKE_CONF="$FAKE_CONF" wg_status wg0)
|
||||
assert_contains "WG_FAKE_DUMP carga dump de archivo" '"public_key":"ifacePubKey=="' "$result_fake"
|
||||
assert_contains "WG_FAKE_DUMP carga dump de archivo" '"peers":[{' "$result_fake"
|
||||
|
||||
echo "---"
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]] || exit 1
|
||||
@@ -3,11 +3,11 @@ name: write_mcp_jupyter_config
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "write_mcp_jupyter_config([project_dir: string], [port: int]) -> string"
|
||||
description: "Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server apuntando al venv local y puerto dado. Merge con jq si ya existe."
|
||||
tags: [mcp, jupyter, config, setup, infra]
|
||||
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."
|
||||
tags: [mcp, jupyter, config, setup, infra, notebook]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -30,10 +30,28 @@ file_path: "bash/functions/infra/write_mcp_jupyter_config.sh"
|
||||
|
||||
```bash
|
||||
source write_mcp_jupyter_config.sh
|
||||
path=$(write_mcp_jupyter_config /home/lucas/analysis/finanzas 8890)
|
||||
path=$(write_mcp_jupyter_config /home/lucas/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",""]
|
||||
```
|
||||
|
||||
## Notas
|
||||
## Cuando usarla
|
||||
|
||||
El MCP se invoca como modulo Python (`python -m jupyter_mcp_server`) usando el python del venv local, nunca una instalacion global. Si `.mcp.json` ya existe y jq esta disponible, hace merge conservando otros servidores MCP. Sin jq, sobrescribe el archivo.
|
||||
- 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`).
|
||||
|
||||
## 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.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- 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,10 +1,18 @@
|
||||
# write_mcp_jupyter_config
|
||||
# -------------------------
|
||||
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server.
|
||||
# Usa el python del venv local con -m jupyter_mcp_server.server.
|
||||
# Configura via env vars (SERVER_URL, TOKEN) — no CLI args.
|
||||
# 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).
|
||||
#
|
||||
# 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).
|
||||
#
|
||||
# USO (sourced):
|
||||
# source write_mcp_jupyter_config.sh
|
||||
# write_mcp_jupyter_config /path/to/project 8888
|
||||
@@ -17,14 +25,15 @@ write_mcp_jupyter_config() {
|
||||
abs_project="$(cd "$project_dir" && pwd)"
|
||||
|
||||
local python_bin="${abs_project}/.venv/bin/python"
|
||||
local mcp_bin="${abs_project}/.venv/bin/jupyter-mcp-server"
|
||||
if [ ! -f "$python_bin" ]; then
|
||||
echo "write_mcp_jupyter_config: python no encontrado en ${python_bin}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Verificar que el modulo esta instalado
|
||||
if ! "$python_bin" -c "import jupyter_mcp_server" 2>/dev/null; then
|
||||
echo "write_mcp_jupyter_config: jupyter_mcp_server no instalado en el venv" >&2
|
||||
# Verificar que el console-script esta instalado
|
||||
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
|
||||
|
||||
@@ -33,12 +42,12 @@ write_mcp_jupyter_config() {
|
||||
{
|
||||
"mcpServers": {
|
||||
"jupyter": {
|
||||
"command": "${python_bin}",
|
||||
"args": ["-m", "jupyter_mcp_server.server"],
|
||||
"env": {
|
||||
"SERVER_URL": "http://localhost:${port}",
|
||||
"TOKEN": ""
|
||||
}
|
||||
"command": "${mcp_bin}",
|
||||
"args": [
|
||||
"--transport", "stdio",
|
||||
"--jupyter-url", "http://localhost:${port}",
|
||||
"--jupyter-token", ""
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,8 +55,10 @@ EOF
|
||||
)
|
||||
|
||||
if [ -f "$mcp_file" ] && command -v jq &>/dev/null; then
|
||||
# Merge conservando otros servidores MCP
|
||||
jq -s '.[0] * {mcpServers: ((.[0].mcpServers // {}) * (.[1].mcpServers // {}))}' \
|
||||
# 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).
|
||||
jq -s '.[0] * {mcpServers: ((.[0].mcpServers // {}) + (.[1].mcpServers // {}))}' \
|
||||
"$mcp_file" <(echo "$new_config") > "${mcp_file}.tmp"
|
||||
mv "${mcp_file}.tmp" "$mcp_file"
|
||||
else
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: compile_wails_app
|
||||
kind: pipeline
|
||||
lang: bash
|
||||
domain: pipelines
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "compile_wails_app(app_name_or_empty: string) -> void"
|
||||
description: "Pipeline que resuelve la app Wails desde el nombre o CWD, la compila para Windows con wails build -platform windows/amd64 (detectando -tags goolm automaticamente si la app usa E2EE Matrix), y despliega el .exe al escritorio de Windows + relanza el proceso. Equivalente a compile_cpp_app pero para apps Wails (Go + WebView2)."
|
||||
tags: [wails, windows, compile, pipelines, launch, matrix-mas]
|
||||
uses_functions:
|
||||
- resolve_cpp_app_dir_bash_infra
|
||||
- deploy_wails_exe_to_windows_bash_infra
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/pipelines/compile_wails_app.sh"
|
||||
params:
|
||||
- name: app_name_or_empty
|
||||
desc: "Nombre de la app Wails a compilar (opcional). Sin arg se deduce desde el directorio actual si estamos dentro de projects/*/apps/<X>/ o apps/<X>/. Lista apps disponibles si no puede deducirlo."
|
||||
output: "Compila el .exe con wails build, lo despliega al escritorio de Windows y relanza el proceso. Imprime progreso por steps a stderr y resumen final con ls -lh del .exe resultante."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Desde el directorio de la app (deduce nombre automaticamente)
|
||||
cd projects/element_agents/apps/matrix_client_pc
|
||||
./fn run compile_wails_app
|
||||
|
||||
# Desde la raiz del registry, con nombre explicito
|
||||
cd /home/lucas/fn_registry
|
||||
./fn run compile_wails_app matrix_admin_panel
|
||||
|
||||
# Directo sin fn run
|
||||
bash bash/functions/pipelines/compile_wails_app.sh matrix_client_pc
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usar cuando quieras rebuild + redeploy + relanzar una app Wails con un solo comando durante iteracion activa de desarrollo. Equivale al slash command `/compile` aplicado a targets Wails. El pipeline detecta automaticamente si la app necesita `-tags goolm` (apps Matrix con E2EE).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Requiere `wails` CLI instalado en PATH y mingw-w64 configurado para cross-compile (`GOARCH=amd64 GOOS=windows` via toolchain Wails).
|
||||
- Si la app usa `-tags goolm` (E2EE Matrix), esta pipeline lo detecta automaticamente: busca `matrix_crypto_init` en `app.md` o `"build:tags": "goolm"` en `wails.json`. Si la deteccion falla, pasar la variable `TAGS` o editar el `wails.json`.
|
||||
- El relanzar despues del deploy es la diferencia clave con `compile_cpp_app`: las apps Wails son single-binary (no DLLs adicionales) y arrancan en <1s, lo que hace iteracion muy rapida.
|
||||
- Si el build falla con `no required module provides package`, ejecutar `go mod tidy` en el directorio de la app antes de volver a compilar.
|
||||
- `matrix_client_pc` tiene helpers en `internal/infra/` que son copias vendored de `functions/infra/` del registry padre. Si actualizas un helper en el registry padre, debes copiarlo manualmente a la app antes de compilar — el build de Wails no ve el modulo padre.
|
||||
- El deploy mata el proceso anterior con `taskkill.exe /F` (pre-autorizado) antes de copiar el .exe, para evitar "Permission denied" de Windows al sobreescribir un binario en uso.
|
||||
- Variable de entorno `WIN_DESKTOP_APPS` controla el destino; default `/mnt/c/Users/lucas/Desktop/apps`.
|
||||
|
||||
## Flujo
|
||||
|
||||
1. `resolve_cpp_app_dir` — deduce nombre y directorio absoluto de la app (desde CWD o arg)
|
||||
2. Verifica `wails.json` y `go.mod` en el directorio de la app
|
||||
3. Detecta si necesita `-tags goolm` (app.md referencia `matrix_crypto_init` o wails.json lo declara)
|
||||
4. `wails build -platform windows/amd64 [tags]` desde el directorio de la app
|
||||
5. `deploy_wails_exe_to_windows` — mata proceso, copia .exe, relanza y verifica PID
|
||||
6. Imprime `ls -lh` del exe final en `Desktop/apps/<APP>/`
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pipeline: compile_wails_app — Resuelve la app Wails desde el nombre o CWD,
|
||||
# la compila para Windows con wails build y despliega al escritorio + relanza.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
INFRA_DIR="$SCRIPT_DIR/../infra"
|
||||
|
||||
source "$INFRA_DIR/resolve_cpp_app_dir.sh"
|
||||
source "$INFRA_DIR/deploy_wails_exe_to_windows.sh"
|
||||
|
||||
compile_wails_app() {
|
||||
local app_arg="${1:-}"
|
||||
|
||||
# --- Paso 1: Resolver nombre y directorio de la app ---
|
||||
echo "[1/3] Resolviendo app..." >&2
|
||||
local resolved
|
||||
resolved=$(resolve_cpp_app_dir "$app_arg")
|
||||
local APP APP_DIR
|
||||
APP="$(echo "$resolved" | cut -f1)"
|
||||
APP_DIR="$(echo "$resolved" | cut -f2)"
|
||||
echo " App: $APP" >&2
|
||||
echo " Dir: $APP_DIR" >&2
|
||||
|
||||
# --- Verificar que es una app Wails (no C++) ---
|
||||
if [ ! -f "$APP_DIR/wails.json" ]; then
|
||||
echo "ERROR: $APP_DIR/wails.json no encontrado." >&2
|
||||
echo "La app '$APP' no es una app Wails." >&2
|
||||
echo "Si es C++, usa compile_cpp_app en su lugar." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$APP_DIR/go.mod" ]; then
|
||||
echo "ERROR: $APP_DIR/go.mod no encontrado." >&2
|
||||
echo "Una app Wails requiere go.mod. Ejecuta 'go mod init' en $APP_DIR." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- Paso 2: Compilar para Windows con wails ---
|
||||
echo "" >&2
|
||||
echo "[2/3] Compilando '$APP' para Windows (wails + mingw)..." >&2
|
||||
|
||||
# Detectar si necesita -tags goolm:
|
||||
# 1. app.md declara matrix_crypto_init en uses_functions (E2EE habilitado)
|
||||
# 2. wails.json tiene "build:tags": "goolm" (o "buildTags": "goolm")
|
||||
local TAGS=""
|
||||
local app_md="${APP_DIR}/app.md"
|
||||
local wails_json="${APP_DIR}/wails.json"
|
||||
local needs_goolm=0
|
||||
|
||||
if [ -f "$app_md" ] && grep -q "matrix_crypto_init" "$app_md" 2>/dev/null; then
|
||||
needs_goolm=1
|
||||
echo " Detectado matrix_crypto_init en app.md -> usando -tags goolm" >&2
|
||||
fi
|
||||
|
||||
if [ "$needs_goolm" -eq 0 ] && [ -f "$wails_json" ]; then
|
||||
if grep -qE '"(build:tags|buildTags)"\s*:\s*"goolm"' "$wails_json" 2>/dev/null; then
|
||||
needs_goolm=1
|
||||
echo " Detectado goolm en wails.json -> usando -tags goolm" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$needs_goolm" -eq 1 ]; then
|
||||
TAGS="-tags goolm"
|
||||
fi
|
||||
|
||||
(
|
||||
cd "$APP_DIR"
|
||||
# shellcheck disable=SC2086
|
||||
wails build -platform windows/amd64 $TAGS
|
||||
)
|
||||
|
||||
# --- Paso 3: Desplegar al escritorio + relanzar ---
|
||||
echo "" >&2
|
||||
echo "[3/3] Desplegando '$APP' al escritorio + relanzar..." >&2
|
||||
deploy_wails_exe_to_windows "$APP" "$APP_DIR"
|
||||
|
||||
# --- Resumen final ---
|
||||
local win_desktop_apps="${WIN_DESKTOP_APPS:-/mnt/c/Users/lucas/Desktop/apps}"
|
||||
local final_exe="$win_desktop_apps/$APP/$APP.exe"
|
||||
|
||||
echo "" >&2
|
||||
if [ -f "$final_exe" ]; then
|
||||
echo "===== compile_wails_app: OK =====" >&2
|
||||
ls -lh "$final_exe" >&2
|
||||
else
|
||||
echo "WARN: no se encuentra $final_exe" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
compile_wails_app "${1:-}"
|
||||
@@ -18,8 +18,9 @@ source "$CYBERSEC_DIR/scan_secrets_in_dirty.sh"
|
||||
full_git_push() {
|
||||
local commit_message="${1:-}"
|
||||
|
||||
# Resolver raiz del registry
|
||||
local registry_root="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}"
|
||||
# Resolver raiz del registry. Deriva de SCRIPT_DIR (bash/functions/pipelines/)
|
||||
# para funcionar en cualquier PC sin path hardcodeado.
|
||||
local registry_root="${FN_REGISTRY_ROOT:-$(cd "$SCRIPT_DIR/../../.." && pwd)}"
|
||||
cd "$registry_root"
|
||||
|
||||
echo "=== full_git_push: inicio ===" >&2
|
||||
@@ -52,8 +53,15 @@ full_git_push() {
|
||||
[[ -z "$dir_path" ]] && continue
|
||||
local d="$registry_root/$dir_path"
|
||||
[[ -d "$d" ]] || continue
|
||||
[[ -d "$d/.git" ]] && continue
|
||||
echo " auto-init: $d" >&2
|
||||
# Skip solo si ya tiene .git CON remote origin. Un .git sin origin
|
||||
# (init local que nunca llego a crear repo Gitea) cae a push step y
|
||||
# falla con "'origin' does not appear to be a git repository".
|
||||
if [[ -d "$d/.git" ]]; then
|
||||
git -C "$d" remote get-url origin >/dev/null 2>&1 && continue
|
||||
echo " fix-remote: $d (.git sin origin)" >&2
|
||||
else
|
||||
echo " auto-init: $d" >&2
|
||||
fi
|
||||
ensure_repo_synced "$d" dataforge "$(basename "$d")" master "chore: initial sync" || \
|
||||
echo " [warn] fallo inicializando $d" >&2
|
||||
done < <(sqlite3 "$registry_root/registry.db" "SELECT dir_path FROM apps WHERE dir_path != '' UNION SELECT dir_path FROM analysis WHERE dir_path != '';" 2>/dev/null)
|
||||
@@ -67,6 +75,25 @@ full_git_push() {
|
||||
# Redescubrir repos tras posibles inicializaciones
|
||||
repos=$(discover_git_repos "$registry_root")
|
||||
|
||||
# --- Paso 1c: Incluir el repo de configuracion de Claude ---
|
||||
# Los archivos de ~/.claude/ (settings.json, commands, skills, CLAUDE.md...)
|
||||
# son symlinks a un repo git externo (dataforge/repo_Claude). Lo resolvemos
|
||||
# de forma portable siguiendo el symlink de settings.json — sin hardcodear
|
||||
# el path, que difiere entre PCs. Si resuelve a un repo git, lo anadimos a
|
||||
# la lista para que pase por scan-secrets + auto-commit + push como los demas.
|
||||
local claude_repo=""
|
||||
if [[ -L "$HOME/.claude/settings.json" ]]; then
|
||||
local _claude_settings_real
|
||||
_claude_settings_real=$(readlink -f "$HOME/.claude/settings.json" 2>/dev/null || true)
|
||||
if [[ -n "$_claude_settings_real" ]]; then
|
||||
claude_repo=$(git -C "$(dirname "$_claude_settings_real")" rev-parse --show-toplevel 2>/dev/null || true)
|
||||
fi
|
||||
fi
|
||||
if [[ -n "$claude_repo" && -d "$claude_repo/.git" ]]; then
|
||||
echo "[1c] Incluyendo repo de config Claude: $claude_repo" >&2
|
||||
repos="$repos"$'\n'"$claude_repo"
|
||||
fi
|
||||
|
||||
# --- Paso 2: Escanear secrets ---
|
||||
echo "" >&2
|
||||
echo "[2/6] Escaneando secrets en dirty trees..." >&2
|
||||
|
||||
@@ -535,3 +535,27 @@ set(_AGENTS_DASHBOARD_DIR ${CMAKE_SOURCE_DIR}/../projects/element_agents/apps/ag
|
||||
if(EXISTS ${_AGENTS_DASHBOARD_DIR}/CMakeLists.txt)
|
||||
add_subdirectory(${_AGENTS_DASHBOARD_DIR} ${CMAKE_BINARY_DIR}/apps/agents_dashboard)
|
||||
endif()
|
||||
|
||||
# --- kanban_cpp (lives in apps/, issue 0096) ---
|
||||
set(_KANBAN_CPP_DIR ${CMAKE_SOURCE_DIR}/../apps/kanban_cpp)
|
||||
if(EXISTS ${_KANBAN_CPP_DIR}/CMakeLists.txt)
|
||||
add_subdirectory(${_KANBAN_CPP_DIR} ${CMAKE_BINARY_DIR}/apps/kanban_cpp)
|
||||
endif()
|
||||
|
||||
# --- data_table_bench (lives in apps/, issue 0133) ---
|
||||
# Requires SQLite3 dev libs. Skip silently when not available (e.g. cross-windows build).
|
||||
set(_DATA_TABLE_BENCH_DIR ${CMAKE_SOURCE_DIR}/../apps/data_table_bench)
|
||||
if(EXISTS ${_DATA_TABLE_BENCH_DIR}/CMakeLists.txt)
|
||||
find_package(SQLite3 QUIET)
|
||||
if(SQLite3_FOUND)
|
||||
add_subdirectory(${_DATA_TABLE_BENCH_DIR} ${CMAKE_BINARY_DIR}/apps/data_table_bench)
|
||||
else()
|
||||
message(STATUS "Skipping data_table_bench (SQLite3 dev libs not found)")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# --- image_to_3d_studio (lives in projects/imagegen/apps/) ---
|
||||
set(_IMAGE_TO_3D_STUDIO_DIR ${CMAKE_SOURCE_DIR}/../projects/imagegen/apps/image_to_3d_studio)
|
||||
if(EXISTS ${_IMAGE_TO_3D_STUDIO_DIR}/CMakeLists.txt)
|
||||
add_subdirectory(${_IMAGE_TO_3D_STUDIO_DIR} ${CMAKE_BINARY_DIR}/apps/image_to_3d_studio)
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
#include "core/ansi_parser.h"
|
||||
|
||||
namespace fn_term {
|
||||
|
||||
// Paleta xterm-16 en ABGR (little-endian: R,G,B,A en memoria = RGBA8888 en lectura).
|
||||
// Index 0-7 colores normales, 8-15 brillantes, 16 = default.
|
||||
const uint32_t kPalette16[17] = {
|
||||
0xFF000000, // 0 black
|
||||
0xFF0000AA, // 1 red
|
||||
0xFF00AA00, // 2 green
|
||||
0xFF00AAAA, // 3 yellow (dark)
|
||||
0xFFAA0000, // 4 blue
|
||||
0xFFAA00AA, // 5 magenta
|
||||
0xFFAAAA00, // 6 cyan
|
||||
0xFFAAAAAA, // 7 white (light grey)
|
||||
0xFF555555, // 8 bright black (dark grey)
|
||||
0xFF5555FF, // 9 bright red
|
||||
0xFF55FF55, // 10 bright green
|
||||
0xFF55FFFF, // 11 bright yellow
|
||||
0xFFFF5555, // 12 bright blue
|
||||
0xFFFF55FF, // 13 bright magenta
|
||||
0xFFFFFF55, // 14 bright cyan
|
||||
0xFFFFFFFF, // 15 bright white
|
||||
0xFFCCCCCC, // 16 default (light grey)
|
||||
};
|
||||
|
||||
AnsiParser::AnsiParser() {
|
||||
for (int i = 0; i < kMaxParams; i++) params_[i] = 0;
|
||||
}
|
||||
|
||||
void AnsiParser::reset() {
|
||||
state_ = State::Ground;
|
||||
cur_fg_ = kColorDefault;
|
||||
cur_bg_ = kColorDefault;
|
||||
cur_bold_ = 0;
|
||||
param_count_ = 0;
|
||||
cur_param_ = 0;
|
||||
for (int i = 0; i < kMaxParams; i++) params_[i] = 0;
|
||||
}
|
||||
|
||||
void AnsiParser::feed(const char* data, size_t n,
|
||||
const std::function<void(const AnsiEvent&)>& cb) {
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
process_byte(static_cast<unsigned char>(data[i]), cb);
|
||||
}
|
||||
}
|
||||
|
||||
void AnsiParser::flush_param() {
|
||||
if (param_count_ < kMaxParams) {
|
||||
params_[param_count_++] = cur_param_;
|
||||
}
|
||||
cur_param_ = 0;
|
||||
}
|
||||
|
||||
void AnsiParser::apply_sgr(const std::function<void(const AnsiEvent&)>& /*cb*/) {
|
||||
// Si no hay params → reset (SGR 0).
|
||||
int n = (param_count_ == 0) ? 1 : param_count_;
|
||||
const int* p = (param_count_ == 0) ? nullptr : params_;
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
int code = (p ? p[i] : 0);
|
||||
if (code == 0) {
|
||||
// Reset todo
|
||||
cur_fg_ = kColorDefault;
|
||||
cur_bg_ = kColorDefault;
|
||||
cur_bold_ = 0;
|
||||
} else if (code == 1) {
|
||||
cur_bold_ = 1;
|
||||
} else if (code == 22) {
|
||||
cur_bold_ = 0;
|
||||
} else if (code >= 30 && code <= 37) {
|
||||
cur_fg_ = static_cast<uint8_t>(code - 30);
|
||||
} else if (code == 39) {
|
||||
cur_fg_ = kColorDefault;
|
||||
} else if (code >= 40 && code <= 47) {
|
||||
cur_bg_ = static_cast<uint8_t>(code - 40);
|
||||
} else if (code == 49) {
|
||||
cur_bg_ = kColorDefault;
|
||||
} else if (code >= 90 && code <= 97) {
|
||||
cur_fg_ = static_cast<uint8_t>(code - 90 + 8);
|
||||
} else if (code >= 100 && code <= 107) {
|
||||
cur_bg_ = static_cast<uint8_t>(code - 100 + 8);
|
||||
}
|
||||
// Otros códigos ignorados silenciosamente (v1 anti-scope).
|
||||
}
|
||||
}
|
||||
|
||||
void AnsiParser::dispatch_csi(unsigned char final_byte,
|
||||
const std::function<void(const AnsiEvent&)>& cb) {
|
||||
AnsiEvent ev;
|
||||
int p0 = (param_count_ > 0) ? params_[0] : 0;
|
||||
int p1 = (param_count_ > 1) ? params_[1] : 0;
|
||||
|
||||
switch (final_byte) {
|
||||
case 'H': case 'f': {
|
||||
// CUP: ESC [ row ; col H (1-based → convertir a 0-based)
|
||||
ev.type = AnsiEventType::CursorAbsolute;
|
||||
ev.cursor_abs.row = (p0 > 0 ? p0 - 1 : 0);
|
||||
ev.cursor_abs.col = (p1 > 0 ? p1 - 1 : 0);
|
||||
cb(ev);
|
||||
break;
|
||||
}
|
||||
case 'A': {
|
||||
ev.type = AnsiEventType::CursorMove;
|
||||
ev.cursor_rel.dir = CursorDir::Up;
|
||||
ev.cursor_rel.n = (p0 > 0 ? p0 : 1);
|
||||
cb(ev);
|
||||
break;
|
||||
}
|
||||
case 'B': {
|
||||
ev.type = AnsiEventType::CursorMove;
|
||||
ev.cursor_rel.dir = CursorDir::Down;
|
||||
ev.cursor_rel.n = (p0 > 0 ? p0 : 1);
|
||||
cb(ev);
|
||||
break;
|
||||
}
|
||||
case 'C': {
|
||||
ev.type = AnsiEventType::CursorMove;
|
||||
ev.cursor_rel.dir = CursorDir::Forward;
|
||||
ev.cursor_rel.n = (p0 > 0 ? p0 : 1);
|
||||
cb(ev);
|
||||
break;
|
||||
}
|
||||
case 'D': {
|
||||
ev.type = AnsiEventType::CursorMove;
|
||||
ev.cursor_rel.dir = CursorDir::Back;
|
||||
ev.cursor_rel.n = (p0 > 0 ? p0 : 1);
|
||||
cb(ev);
|
||||
break;
|
||||
}
|
||||
case 'J': {
|
||||
// ED: erase in display. Solo param=2 (clear screen) soportado en v1.
|
||||
if (p0 == 2 || p0 == 0) {
|
||||
ev.type = AnsiEventType::EraseDisplay;
|
||||
cb(ev);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'K': {
|
||||
// EL: erase in line. Solo param=2 (clear entire line) soportado en v1.
|
||||
if (p0 == 2 || p0 == 0) {
|
||||
ev.type = AnsiEventType::EraseLine;
|
||||
cb(ev);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'm': {
|
||||
// SGR: select graphic rendition.
|
||||
apply_sgr(cb);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Secuencia CSI desconocida — ignorar silenciosamente.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void AnsiParser::process_byte(unsigned char c,
|
||||
const std::function<void(const AnsiEvent&)>& cb) {
|
||||
switch (state_) {
|
||||
|
||||
case State::Ground:
|
||||
if (c == 0x1B) {
|
||||
state_ = State::Escape;
|
||||
} else if (c == '\r') {
|
||||
AnsiEvent ev; ev.type = AnsiEventType::CarriageReturn; cb(ev);
|
||||
} else if (c == '\n') {
|
||||
AnsiEvent ev; ev.type = AnsiEventType::Newline; cb(ev);
|
||||
} else if (c == '\x08') {
|
||||
AnsiEvent ev; ev.type = AnsiEventType::Backspace; cb(ev);
|
||||
} else if (c >= 0x20 && c < 0x7F) {
|
||||
// ASCII imprimible.
|
||||
AnsiEvent ev;
|
||||
ev.type = AnsiEventType::Char;
|
||||
ev.cell.ch = static_cast<char32_t>(c);
|
||||
ev.cell.fg = cur_fg_;
|
||||
ev.cell.bg = cur_bg_;
|
||||
ev.cell.bold = cur_bold_;
|
||||
cb(ev);
|
||||
} else if (c >= 0xC0) {
|
||||
// Inicio de secuencia UTF-8 multi-byte.
|
||||
// En v1 mapeamos todo >= 0x80 a '?' para evitar complejidad Unicode.
|
||||
// TODO(0132): soporte Unicode completo en v2.
|
||||
AnsiEvent ev;
|
||||
ev.type = AnsiEventType::Char;
|
||||
ev.cell.ch = U'?';
|
||||
ev.cell.fg = cur_fg_;
|
||||
ev.cell.bg = cur_bg_;
|
||||
ev.cell.bold = cur_bold_;
|
||||
cb(ev);
|
||||
} else if (c >= 0x80 && c < 0xC0) {
|
||||
// Continuation byte de UTF-8 → ignorar (fragmento de multi-byte).
|
||||
}
|
||||
// Otros control bytes (0x00-0x1F excl \r\n\x08\x1B) → ignorar.
|
||||
break;
|
||||
|
||||
case State::Escape:
|
||||
if (c == '[') {
|
||||
state_ = State::CsiEntry;
|
||||
param_count_ = 0;
|
||||
cur_param_ = 0;
|
||||
} else {
|
||||
// Secuencia ESC desconocida (no-CSI) → volver a Ground.
|
||||
state_ = State::Ground;
|
||||
}
|
||||
break;
|
||||
|
||||
case State::CsiEntry:
|
||||
// Primer byte del CSI: puede ser un dígito, ';' o el final byte.
|
||||
if (c >= '0' && c <= '9') {
|
||||
cur_param_ = c - '0';
|
||||
state_ = State::CsiParam;
|
||||
} else if (c == ';') {
|
||||
// Parámetro vacío → valor 0.
|
||||
flush_param();
|
||||
cur_param_ = 0;
|
||||
state_ = State::CsiParam;
|
||||
} else if (c >= 0x40 && c <= 0x7E) {
|
||||
// Byte final inmediato sin parámetros.
|
||||
dispatch_csi(c, cb);
|
||||
state_ = State::Ground;
|
||||
} else if (c == '?') {
|
||||
// Modos privados (e.g. ESC[?25l cursor hide) → ignorar hasta final byte.
|
||||
// Permanecemos en CsiEntry esperando el final byte.
|
||||
} else {
|
||||
// Byte inesperado → abortar CSI.
|
||||
state_ = State::Ground;
|
||||
}
|
||||
break;
|
||||
|
||||
case State::CsiParam:
|
||||
if (c >= '0' && c <= '9') {
|
||||
cur_param_ = cur_param_ * 10 + (c - '0');
|
||||
} else if (c == ';') {
|
||||
flush_param();
|
||||
cur_param_ = 0;
|
||||
} else if (c >= 0x40 && c <= 0x7E) {
|
||||
// Byte final: flush último param y despachar.
|
||||
flush_param();
|
||||
dispatch_csi(c, cb);
|
||||
state_ = State::Ground;
|
||||
} else {
|
||||
// Byte inesperado → abortar.
|
||||
state_ = State::Ground;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace fn_term
|
||||
@@ -0,0 +1,131 @@
|
||||
#pragma once
|
||||
|
||||
// ansi_parser — parser ANSI/VT100 minimo, byte-a-byte, sin heap allocs por evento.
|
||||
//
|
||||
// Soporta:
|
||||
// SGR: colores FG/BG 16 colores (30-37, 40-47, 90-97, 100-107), bold (1), reset (0).
|
||||
// CUP (H): cursor absolute position row,col.
|
||||
// CUU (A), CUD (B), CUF (C), CUB (D): cursor relative moves.
|
||||
// ED (J): erase in display (param=2 → clear screen).
|
||||
// EL (K): erase in line (param=2 → clear line).
|
||||
// Carriage Return (\r), Newline (\n), Backspace (\x08).
|
||||
// Text: caracteres imprimibles (excl. control bytes).
|
||||
//
|
||||
// No soportado (v1, anti-scope):
|
||||
// 256/24-bit color, italics, underline, Unicode wide, OSC, DCS, SOS, PM, APC,
|
||||
// CSI sequences > 16 parametros, character sets (SI/SO), private modes.
|
||||
//
|
||||
// Uso:
|
||||
// fn_term::AnsiParser p;
|
||||
// p.feed(data, n, [](const fn_term::AnsiEvent& ev) { /* handle */ });
|
||||
//
|
||||
// Thread-safety: NO. Cada instancia debe usarse desde un solo hilo.
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
|
||||
namespace fn_term {
|
||||
|
||||
// Codigos de color ANSI → index 0-15 en paleta CGA/xterm-16.
|
||||
// 0-7: colores normales (black, red, green, yellow, blue, magenta, cyan, white)
|
||||
// 8-15: colores brillantes (idem + bright)
|
||||
// 16: color por defecto (FG o BG)
|
||||
static constexpr uint8_t kColorDefault = 16;
|
||||
|
||||
// Paleta xterm-16 en RGBA8888 (A=0xFF), misma que la mayoria de terminales.
|
||||
// Acceso: kPalette16[index], index in [0,15].
|
||||
extern const uint32_t kPalette16[17]; // [16] = color "default" (blanco/negro)
|
||||
|
||||
// Una celda del terminal virtual.
|
||||
struct AnsiCell {
|
||||
char32_t ch = U' '; // codepoint Unicode (solo BMP en v1)
|
||||
uint8_t fg = kColorDefault; // indice paleta 0-16 (16 = default)
|
||||
uint8_t bg = kColorDefault;
|
||||
uint8_t bold = 0;
|
||||
uint8_t _pad = 0;
|
||||
};
|
||||
|
||||
// Tipos de evento emitidos por el parser.
|
||||
enum class AnsiEventType : uint8_t {
|
||||
Char, // un caracter imprimible (AnsiEvent.cell.ch valido)
|
||||
CursorMove, // AnsiEvent.row / .col delta o absoluto segun subtype
|
||||
CursorAbsolute, // CUP: posicion absoluta 0-based (row, col)
|
||||
EraseDisplay, // ED(2): limpiar pantalla completa
|
||||
EraseLine, // EL(2): limpiar linea actual completa
|
||||
CarriageReturn, // \r
|
||||
Newline, // \n
|
||||
Backspace, // \x08
|
||||
};
|
||||
|
||||
// Subtipos de CursorMove.
|
||||
enum class CursorDir : uint8_t { Up, Down, Forward, Back };
|
||||
|
||||
struct AnsiEvent {
|
||||
AnsiEventType type;
|
||||
union {
|
||||
AnsiCell cell; // type == Char
|
||||
struct {
|
||||
CursorDir dir;
|
||||
int n; // pasos (>= 1)
|
||||
} cursor_rel; // type == CursorMove
|
||||
struct {
|
||||
int row; // 0-based
|
||||
int col; // 0-based
|
||||
} cursor_abs; // type == CursorAbsolute
|
||||
// EraseDisplay, EraseLine, CarriageReturn, Newline, Backspace: sin datos extra.
|
||||
};
|
||||
|
||||
AnsiEvent() : type(AnsiEventType::Char), cell{} {}
|
||||
};
|
||||
|
||||
// Clase principal. Stateful — mantiene el estado del parser entre llamadas a feed().
|
||||
class AnsiParser {
|
||||
public:
|
||||
AnsiParser();
|
||||
~AnsiParser() = default;
|
||||
AnsiParser(const AnsiParser&) = delete;
|
||||
AnsiParser& operator=(const AnsiParser&) = delete;
|
||||
|
||||
// Procesa `n` bytes de `data`. Emite eventos via `cb` en orden.
|
||||
// cb puede ser llamada 0 o más veces por feed().
|
||||
// Sin alloc heap por byte ni por evento.
|
||||
void feed(const char* data, size_t n,
|
||||
const std::function<void(const AnsiEvent&)>& cb);
|
||||
|
||||
// Resetea el estado del parser (útil al limpiar pantalla).
|
||||
void reset();
|
||||
|
||||
// Atributos SGR actuales (se actualizan al procesar secuencias SGR).
|
||||
uint8_t current_fg() const { return cur_fg_; }
|
||||
uint8_t current_bg() const { return cur_bg_; }
|
||||
uint8_t current_bold() const { return cur_bold_; }
|
||||
|
||||
private:
|
||||
enum class State : uint8_t {
|
||||
Ground, // estado normal: procesar texto
|
||||
Escape, // recibido ESC
|
||||
CsiEntry, // recibido ESC [
|
||||
CsiParam, // acumulando parametros CSI
|
||||
};
|
||||
|
||||
State state_ = State::Ground;
|
||||
uint8_t cur_fg_ = kColorDefault;
|
||||
uint8_t cur_bg_ = kColorDefault;
|
||||
uint8_t cur_bold_ = 0;
|
||||
|
||||
// Buffer de parametros CSI (max 16 params de 4 digitos cada uno).
|
||||
static constexpr int kMaxParams = 16;
|
||||
int params_[kMaxParams];
|
||||
int param_count_ = 0;
|
||||
int cur_param_ = 0; // valor del param que se esta acumulando
|
||||
|
||||
void process_byte(unsigned char c,
|
||||
const std::function<void(const AnsiEvent&)>& cb);
|
||||
void flush_param();
|
||||
void dispatch_csi(unsigned char final_byte,
|
||||
const std::function<void(const AnsiEvent&)>& cb);
|
||||
void apply_sgr(const std::function<void(const AnsiEvent&)>& cb);
|
||||
};
|
||||
|
||||
} // namespace fn_term
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: ansi_parser
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "class fn_term::AnsiParser { void feed(const char* data, size_t n, const std::function<void(const fn_term::AnsiEvent&)>& cb); void reset(); uint8_t current_fg() const; uint8_t current_bg() const; uint8_t current_bold() const; }"
|
||||
description: "Parser ANSI/VT100 minimo byte-a-byte sin alloc heap por evento. Soporta SGR colores FG/BG 16-color + bold + reset, cursor moves (CUP/CUU/CUD/CUF/CUB), erase display/line (ED 2, EL 2), CR/LF/BS. Statemachine simple con 4 estados. Emite AnsiEvent via callback."
|
||||
tags: [ansi, vt100, terminal, parser, pure, state-machine, cpp-dashboard-viz]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [cstddef, cstdint, functional]
|
||||
tested: true
|
||||
tests:
|
||||
- "SGR reset sets default colors"
|
||||
- "SGR fg color 31 sets red"
|
||||
- "SGR bg color 44 sets blue background"
|
||||
- "SGR bright fg 91 sets bright red"
|
||||
- "SGR bold sets bold flag"
|
||||
- "cursor CUU moves up N"
|
||||
- "cursor CUF moves forward N"
|
||||
- "cursor CUP absolute position"
|
||||
- "erase display ED 2"
|
||||
- "erase line EL 2"
|
||||
- "mixed text and SGR sequence"
|
||||
- "newline and carriage return"
|
||||
test_file_path: "cpp/tests/test_ansi_parser.cpp"
|
||||
file_path: "cpp/functions/core/ansi_parser.cpp"
|
||||
framework: ""
|
||||
params:
|
||||
- name: data
|
||||
desc: "Puntero al buffer de bytes a procesar (output crudo de PTY/ConPTY)"
|
||||
- name: n
|
||||
desc: "Numero de bytes en data"
|
||||
- name: cb
|
||||
desc: "Callback invocado por cada evento emitido. Sin alloc — el AnsiEvent vive en el stack del parser"
|
||||
output: "Sin retorno directo. Eventos emitidos via callback: AnsiEventType::Char (caracter + atributos SGR actuales), CursorMove (relativo), CursorAbsolute (CUP), EraseDisplay, EraseLine, CarriageReturn, Newline, Backspace"
|
||||
notes: "Usado por terminal_panel_cpp_viz como paso de parseo del output PTY. Anti-scope v1: sin 256/24-bit color, sin italics/underline, sin Unicode wide, sin OSC/DCS. UTF-8 multi-byte se mapea a '?' en v1."
|
||||
---
|
||||
|
||||
# ansi_parser
|
||||
|
||||
Parser ANSI/VT100 minimo para el modulo `terminal_panel`. Sin heap allocs por byte procesado — la maquina de estados vive en el objeto y los `AnsiEvent` se emiten por callback en el stack del caller.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```cpp
|
||||
#include "core/ansi_parser.h"
|
||||
|
||||
fn_term::AnsiParser parser;
|
||||
std::string output;
|
||||
|
||||
// Procesar output crudo de PTY:
|
||||
parser.feed(pty_buf, bytes_read, [&](const fn_term::AnsiEvent& ev) {
|
||||
if (ev.type == fn_term::AnsiEventType::Char) {
|
||||
// ev.cell.ch = codepoint, ev.cell.fg = color index 0-16
|
||||
output += static_cast<char>(ev.cell.ch);
|
||||
} else if (ev.type == fn_term::AnsiEventType::Newline) {
|
||||
output += '\n';
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando procesas output crudo de un PTY (Linux forkpty) o ConPTY (Windows) y necesitas extraer texto + atributos de color para renderizar en ImGui con `PushStyleColor`. Es la capa de parseo de `terminal_panel`.
|
||||
|
||||
## Secuencias soportadas (v1)
|
||||
|
||||
| Tipo | Secuencia | AnsiEventType |
|
||||
|------|-----------|---------------|
|
||||
| Texto ASCII | bytes 0x20-0x7E | Char |
|
||||
| CR | `\r` (0x0D) | CarriageReturn |
|
||||
| LF | `\n` (0x0A) | Newline |
|
||||
| BS | `\x08` | Backspace |
|
||||
| SGR reset | `ESC[0m` o `ESC[m` | (actualiza estado interno) |
|
||||
| SGR bold | `ESC[1m` | (actualiza estado interno) |
|
||||
| SGR FG 16 | `ESC[30-37m`, `ESC[90-97m` | (actualiza estado interno) |
|
||||
| SGR BG 16 | `ESC[40-47m`, `ESC[100-107m` | (actualiza estado interno) |
|
||||
| Cursor UP | `ESC[nA` | CursorMove (Up, n) |
|
||||
| Cursor DOWN | `ESC[nB` | CursorMove (Down, n) |
|
||||
| Cursor FWD | `ESC[nC` | CursorMove (Forward, n) |
|
||||
| Cursor BACK | `ESC[nD` | CursorMove (Back, n) |
|
||||
| CUP | `ESC[r;cH` | CursorAbsolute (0-based) |
|
||||
| ED(2) | `ESC[2J` | EraseDisplay |
|
||||
| EL(2) | `ESC[2K` | EraseLine |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Anti-scope v1: no 256-color (`ESC[38;5;Nm`), no 24-bit color, no italics/underline, no curses pesados.
|
||||
- UTF-8 multi-byte: bytes de continuacion 0x80-0xBF ignorados; inicio 0xC0+ emite `?`. Soporte completo en v2.
|
||||
- No thread-safe: cada instancia debe usarse desde un solo hilo (el reader thread del PTY).
|
||||
- `kPalette16[16]` es el color "default" (gris claro). El caller decide si usar el color del tema o la paleta fija.
|
||||
@@ -8,6 +8,8 @@
|
||||
#include "compute_column_stats.h"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
@@ -353,6 +355,59 @@ struct VizPanel {
|
||||
mutable ViewMode last_non_table = ViewMode::Bar;
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// StringPool — interning de strings para columnas de texto (issue 0133).
|
||||
// Una instancia por State (NOT global) para aislar tablas independientes.
|
||||
//
|
||||
// intern(sv) devuelve un indice uint32_t estable para la vida del rebuild.
|
||||
// El pool se limpia (clear()) al inicio de cada rebuild de snapshot columnar.
|
||||
//
|
||||
// Invariante de invalidacion de string_view:
|
||||
// - El vector `strings` se reserva con reserve() ANTES del primer intern()
|
||||
// para evitar reallocs que invalidarian los string_view del mapa.
|
||||
// Si la estimacion es insuficiente (columna con mas unicos de lo esperado),
|
||||
// el mapa se reconstruye post-push_back: intern() verifica cap antes de
|
||||
// insertar en el map para cubrir este caso.
|
||||
// ----------------------------------------------------------------------------
|
||||
struct StringPool {
|
||||
std::vector<std::string> strings; // strings unicos, por indice
|
||||
std::unordered_map<std::string_view, uint32_t> index; // sv→id (sv apunta a strings[i])
|
||||
|
||||
void clear() {
|
||||
strings.clear();
|
||||
index.clear();
|
||||
}
|
||||
|
||||
// intern: inserta si no existe. Devuelve indice estable.
|
||||
// INVARIANTE: reserve() ANTES del primer intern() por columna para evitar
|
||||
// reallocs que invalidarian los string_view del mapa. Si la estimacion fue
|
||||
// insuficiente, forzamos reserve(size+1) ANTES de emplace_back para que
|
||||
// la realloc ocurra antes de que cualquier sv del mapa apunte al buffer
|
||||
// viejo — y reconstruimos el mapa desde cero tras la realloc.
|
||||
uint32_t intern(std::string_view sv) {
|
||||
auto it = index.find(sv);
|
||||
if (it != index.end()) return it->second;
|
||||
uint32_t id = (uint32_t)strings.size();
|
||||
if (strings.size() == strings.capacity()) {
|
||||
// Realloc inminente: hacerlo ANTES de insertar en index para que
|
||||
// los string_view existentes no queden dangling. Tras el reserve,
|
||||
// reconstruimos el index desde cero porque los punteros cambiaron.
|
||||
strings.reserve(strings.capacity() == 0 ? 64 : strings.capacity() * 2);
|
||||
index.clear();
|
||||
for (uint32_t i = 0; i < (uint32_t)strings.size(); ++i)
|
||||
index.emplace(std::string_view(strings[i]), i);
|
||||
}
|
||||
strings.emplace_back(sv);
|
||||
// string_view apunta al almacenamiento interno (strings[id]), estable
|
||||
// porque acabamos de garantizar capacidad suficiente.
|
||||
index.emplace(std::string_view(strings[id]), id);
|
||||
return id;
|
||||
}
|
||||
|
||||
const std::string& at(uint32_t id) const { return strings[id]; }
|
||||
bool empty() const { return strings.empty(); }
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// State: stage pipeline + viz globales.
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -419,6 +474,11 @@ struct State {
|
||||
std::vector<DrillStep> drill_back;
|
||||
std::vector<DrillStep> drill_forward;
|
||||
|
||||
// String interning pool (issue 0133, Change 2).
|
||||
// Limpiado y repoblado en cada rebuild del snapshot columnar.
|
||||
// NOT global — una instancia por State para aislar tablas independientes.
|
||||
StringPool string_pool;
|
||||
|
||||
// Helpers (definidos en compute_stage.cpp).
|
||||
Stage& raw();
|
||||
const Stage& raw() const;
|
||||
|
||||
@@ -269,8 +269,21 @@ Response request(const Request& req) {
|
||||
}
|
||||
|
||||
cmd << ' ' << sh_q(req.url)
|
||||
<< " -o " << sh_q(tmp_body_out)
|
||||
<< " 2>&1";
|
||||
<< " -o " << sh_q(tmp_body_out);
|
||||
|
||||
// On POSIX we go through /bin/sh -c via popen, so `2>&1` is a shell redirect.
|
||||
// On Windows we use CreateProcessW (no shell): `2>&1` would be passed as an
|
||||
// extra positional arg to curl, which treats it as a second URL → "Bad
|
||||
// hostname" (exit 3). stderr is already merged via STARTUPINFOW.hStdError.
|
||||
#ifndef _WIN32
|
||||
cmd << " 2>&1";
|
||||
#endif
|
||||
|
||||
if (std::getenv("FN_HTTP_DEBUG")) {
|
||||
fprintf(stderr, "[fn_http debug] cmdline: %s\n", cmd.str().c_str());
|
||||
fprintf(stderr, "[fn_http debug] req.url=[%s] len=%zu\n",
|
||||
req.url.c_str(), req.url.size());
|
||||
}
|
||||
|
||||
// Capture stderr (curl prints transport errors to stderr with -sS).
|
||||
std::string curl_stderr;
|
||||
|
||||
@@ -14,9 +14,17 @@ static void create_tex(Framebuffer& f) {
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
}
|
||||
|
||||
static void create_depth_rbo(Framebuffer& f) {
|
||||
glGenRenderbuffers(1, &f.depth_rbo);
|
||||
glBindRenderbuffer(GL_RENDERBUFFER, f.depth_rbo);
|
||||
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, f.width, f.height);
|
||||
glBindRenderbuffer(GL_RENDERBUFFER, 0);
|
||||
}
|
||||
|
||||
void fb_init(Framebuffer& f) {
|
||||
f.width = 1;
|
||||
f.height = 1;
|
||||
f.width = 1;
|
||||
f.height = 1;
|
||||
f.has_depth = false;
|
||||
create_tex(f);
|
||||
glGenFramebuffers(1, &f.fbo);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, f.fbo);
|
||||
@@ -24,23 +32,50 @@ void fb_init(Framebuffer& f) {
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
}
|
||||
|
||||
void fb_init_depth(Framebuffer& f) {
|
||||
f.width = 1;
|
||||
f.height = 1;
|
||||
f.has_depth = true;
|
||||
create_tex(f);
|
||||
create_depth_rbo(f);
|
||||
glGenFramebuffers(1, &f.fbo);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, f.fbo);
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, f.tex, 0);
|
||||
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, f.depth_rbo);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
}
|
||||
|
||||
void fb_resize(Framebuffer& f, int w, int h) {
|
||||
if (w == f.width && h == f.height) return;
|
||||
f.width = w;
|
||||
f.width = w;
|
||||
f.height = h;
|
||||
|
||||
// Recreate color texture.
|
||||
if (f.tex) glDeleteTextures(1, &f.tex);
|
||||
f.tex = 0;
|
||||
create_tex(f);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, f.fbo);
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, f.tex, 0);
|
||||
|
||||
// Resize depth renderbuffer in-place (no need to recreate).
|
||||
if (f.has_depth && f.depth_rbo) {
|
||||
glBindRenderbuffer(GL_RENDERBUFFER, f.depth_rbo);
|
||||
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, f.width, f.height);
|
||||
glBindRenderbuffer(GL_RENDERBUFFER, 0);
|
||||
// Re-attach in case it was lost (should be stable across storage resize, but be safe).
|
||||
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, f.depth_rbo);
|
||||
}
|
||||
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
}
|
||||
|
||||
void fb_destroy(Framebuffer& f) {
|
||||
if (f.fbo) { glDeleteFramebuffers(1, &f.fbo); f.fbo = 0; }
|
||||
if (f.tex) { glDeleteTextures(1, &f.tex); f.tex = 0; }
|
||||
f.width = 0;
|
||||
f.height = 0;
|
||||
if (f.fbo) { glDeleteFramebuffers(1, &f.fbo); f.fbo = 0; }
|
||||
if (f.tex) { glDeleteTextures(1, &f.tex); f.tex = 0; }
|
||||
if (f.depth_rbo) { glDeleteRenderbuffers(1, &f.depth_rbo); f.depth_rbo = 0; }
|
||||
f.width = 0;
|
||||
f.height = 0;
|
||||
f.has_depth = false;
|
||||
}
|
||||
|
||||
} // namespace fn::gfx
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
namespace fn::gfx {
|
||||
|
||||
struct Framebuffer {
|
||||
unsigned int fbo = 0;
|
||||
unsigned int tex = 0; // GL_RGBA8, clamp, linear
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
unsigned int fbo = 0;
|
||||
unsigned int tex = 0; // GL_RGBA8 color
|
||||
unsigned int depth_rbo = 0; // GL_DEPTH_COMPONENT24 renderbuffer, 0 si sin depth
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
bool has_depth = false;
|
||||
};
|
||||
|
||||
void fb_init(Framebuffer& f); // crea fbo+tex 1x1 iniciales
|
||||
void fb_resize(Framebuffer& f, int w, int h); // no-op si w,h iguales
|
||||
void fb_destroy(Framebuffer& f);
|
||||
void fb_init(Framebuffer& f); // crea fbo+tex 1x1 (color-only, retro-compat)
|
||||
void fb_init_depth(Framebuffer& f); // crea fbo+tex+depth_rbo 1x1
|
||||
void fb_resize(Framebuffer& f, int w, int h); // redimensiona color y depth (si has_depth); no-op si iguales
|
||||
void fb_destroy(Framebuffer& f); // libera fbo, tex y depth_rbo si existen
|
||||
|
||||
} // namespace fn::gfx
|
||||
|
||||
@@ -3,11 +3,11 @@ name: gl_framebuffer
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: gfx
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "void fb_init(Framebuffer& f); void fb_resize(Framebuffer& f, int w, int h); void fb_destroy(Framebuffer& f)"
|
||||
description: "CRUD de un framebuffer OpenGL (FBO + textura RGBA8). fb_resize es no-op si las dimensiones no cambian. Listo para uso con ImGui::Image."
|
||||
tags: [opengl, framebuffer, fbo, texture, gfx, offscreen]
|
||||
signature: "void fb_init(Framebuffer& f); void fb_init_depth(Framebuffer& f); void fb_resize(Framebuffer& f, int w, int h); void fb_destroy(Framebuffer& f)"
|
||||
description: "CRUD de un framebuffer OpenGL (FBO + textura RGBA8, opcionalmente con depth renderbuffer GL_DEPTH_COMPONENT24). fb_init es color-only (retro-compat); fb_init_depth añade depth. fb_resize redimensiona color y depth si has_depth. Listo para uso con ImGui::Image."
|
||||
tags: [opengl, framebuffer, fbo, texture, gfx, offscreen, depth, cpp-dashboard-viz]
|
||||
uses_functions: ["gl_loader_cpp_gfx"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -21,23 +21,23 @@ file_path: "cpp/functions/gfx/gl_framebuffer.cpp"
|
||||
framework: opengl
|
||||
params:
|
||||
- name: f
|
||||
desc: "Struct Framebuffer con campos fbo, tex (GL ids), width, height. Inicializar a {0} antes de fb_init."
|
||||
desc: "Struct Framebuffer con campos fbo, tex, depth_rbo (GL ids), width, height, has_depth. Inicializar a {0} antes de fb_init/fb_init_depth."
|
||||
- name: w
|
||||
desc: "Ancho deseado en pixels (fb_resize)"
|
||||
- name: h
|
||||
desc: "Alto deseado en pixels (fb_resize)"
|
||||
output: "Modifica f in-place. Después de fb_init, f.fbo y f.tex son IDs GL válidos. fb_destroy pone todos los campos a 0."
|
||||
output: "Modifica f in-place. Después de fb_init/fb_init_depth, f.fbo y f.tex son IDs GL válidos. Si fb_init_depth: f.depth_rbo != 0 y f.has_depth == true. fb_destroy pone todos los campos a 0."
|
||||
---
|
||||
|
||||
# gl_framebuffer
|
||||
|
||||
FBO con textura color RGBA8 (GL_CLAMP_TO_EDGE, GL_LINEAR). Diseñado para renderizado offscreen y posterior display via `ImGui::Image`.
|
||||
FBO con textura color RGBA8 (GL_CLAMP_TO_EDGE, GL_LINEAR). Opcionalmente con depth renderbuffer GL_DEPTH_COMPONENT24. Diseñado para renderizado offscreen y posterior display via `ImGui::Image`.
|
||||
|
||||
## Ciclo de vida
|
||||
## Ciclo de vida — color-only (retro-compat)
|
||||
|
||||
```cpp
|
||||
fn::gfx::Framebuffer fb{};
|
||||
fn::gfx::fb_init(fb); // fbo + tex 1x1
|
||||
fn::gfx::fb_init(fb); // fbo + tex 1x1, has_depth=false
|
||||
|
||||
// En el render loop:
|
||||
fn::gfx::fb_resize(fb, w, h); // no-op si mismas dimensiones
|
||||
@@ -46,6 +46,23 @@ fn::gfx::fb_resize(fb, w, h); // no-op si mismas dimensiones
|
||||
fn::gfx::fb_destroy(fb);
|
||||
```
|
||||
|
||||
## Ciclo de vida — con depth renderbuffer
|
||||
|
||||
```cpp
|
||||
fn::gfx::Framebuffer fb{};
|
||||
fn::gfx::fb_init_depth(fb); // fbo + tex 1x1 + depth_rbo 1x1, has_depth=true
|
||||
|
||||
// En el render loop (antes de glDrawElements):
|
||||
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
|
||||
glEnable(GL_DEPTH_TEST);
|
||||
glDepthFunc(GL_LESS);
|
||||
|
||||
fn::gfx::fb_resize(fb, w, h); // redimensiona color Y depth_rbo
|
||||
|
||||
// Al destruir:
|
||||
fn::gfx::fb_destroy(fb); // libera fbo, tex y depth_rbo
|
||||
```
|
||||
|
||||
## Uso con ImGui::Image
|
||||
|
||||
```cpp
|
||||
@@ -59,4 +76,10 @@ ImGui::Image(
|
||||
|
||||
## Notas
|
||||
|
||||
`fb_resize` recrea solo la textura (no el FBO) cuando las dimensiones cambian, reattachando la nueva textura al FBO existente. Esto minimiza el overhead de resize.
|
||||
`fb_resize` recrea solo la textura (no el FBO) cuando las dimensiones cambian, reattachando la nueva textura al FBO existente. Para el depth renderbuffer, llama `glRenderbufferStorage` in-place (sin recrear el RBO). Esto minimiza el overhead de resize.
|
||||
|
||||
`fb_init` (sin depth) se mantiene idéntico al comportamiento pre-v1.1.0 — no rompe consumidores existentes (`shader_canvas`, `graph_renderer`).
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.1.0 (2026-05-28) — fb_init_depth opcional + depth en fb_resize/fb_destroy
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
#include "gfx/gltf_load_mesh.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// nlohmann/json vendored
|
||||
#include "nlohmann/json.hpp"
|
||||
|
||||
namespace fn::gfx {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread-local last error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static thread_local char s_last_error[512] = "";
|
||||
|
||||
static void set_error(const char* msg) {
|
||||
std::strncpy(s_last_error, msg, sizeof(s_last_error) - 1);
|
||||
s_last_error[sizeof(s_last_error) - 1] = '\0';
|
||||
}
|
||||
|
||||
const char* gltf_load_last_error() { return s_last_error; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GLB binary format constants (spec glTF 2.0)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static constexpr uint32_t GLB_MAGIC = 0x46546C67u; // "glTF"
|
||||
static constexpr uint32_t GLB_VERSION = 2u;
|
||||
static constexpr uint32_t CHUNK_JSON = 0x4E4F534Au; // "JSON"
|
||||
static constexpr uint32_t CHUNK_BIN = 0x004E4942u; // "BIN\0"
|
||||
|
||||
// glTF accessor componentType
|
||||
static constexpr int CT_UNSIGNED_BYTE = 5121;
|
||||
static constexpr int CT_UNSIGNED_SHORT = 5123;
|
||||
static constexpr int CT_UNSIGNED_INT = 5125;
|
||||
static constexpr int CT_FLOAT = 5126;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Math helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void cross3(const float a[3], const float b[3], float out[3]) {
|
||||
out[0] = a[1]*b[2] - a[2]*b[1];
|
||||
out[1] = a[2]*b[0] - a[0]*b[2];
|
||||
out[2] = a[0]*b[1] - a[1]*b[0];
|
||||
}
|
||||
|
||||
static float dot3(const float a[3], const float b[3]) {
|
||||
return a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
|
||||
}
|
||||
|
||||
static float len3(const float a[3]) {
|
||||
return std::sqrt(dot3(a, a));
|
||||
}
|
||||
|
||||
// Multiply 4x4 column-major matrix by vec3 (point, w=1)
|
||||
static void mat4_mul_point(const float m[16], const float p[3], float out[3]) {
|
||||
out[0] = m[0]*p[0] + m[4]*p[1] + m[8] *p[2] + m[12];
|
||||
out[1] = m[1]*p[0] + m[5]*p[1] + m[9] *p[2] + m[13];
|
||||
out[2] = m[2]*p[0] + m[6]*p[1] + m[10]*p[2] + m[14];
|
||||
}
|
||||
|
||||
// Multiply 4x4 column-major matrix by vec3 (direction, w=0 — for normals use
|
||||
// inverse-transpose, which here is computed at call site)
|
||||
static void mat4_mul_dir(const float m[16], const float v[3], float out[3]) {
|
||||
out[0] = m[0]*v[0] + m[4]*v[1] + m[8] *v[2];
|
||||
out[1] = m[1]*v[0] + m[5]*v[1] + m[9] *v[2];
|
||||
out[2] = m[2]*v[0] + m[6]*v[1] + m[10]*v[2];
|
||||
}
|
||||
|
||||
// 3x3 inverse-transpose (for normal transform) extracted from upper-left of 4x4.
|
||||
// Returns false if matrix is singular (scale 0).
|
||||
static bool compute_normal_matrix(const float m[16], float out[9]) {
|
||||
// Extract upper-left 3x3 (column-major from 4x4)
|
||||
float a00=m[0], a10=m[1], a20=m[2];
|
||||
float a01=m[4], a11=m[5], a21=m[6];
|
||||
float a02=m[8], a12=m[9], a22=m[10];
|
||||
|
||||
float det = a00*(a11*a22 - a21*a12)
|
||||
- a01*(a10*a22 - a20*a12)
|
||||
+ a02*(a10*a21 - a20*a11);
|
||||
if (std::fabs(det) < 1e-12f) return false;
|
||||
float inv = 1.0f / det;
|
||||
|
||||
// Inverse of 3x3, then transpose → inverse-transpose columns become rows
|
||||
out[0] = inv * (a11*a22 - a21*a12);
|
||||
out[1] = inv * (a21*a02 - a01*a22);
|
||||
out[2] = inv * (a01*a12 - a11*a02);
|
||||
out[3] = inv * (a20*a12 - a10*a22);
|
||||
out[4] = inv * (a00*a22 - a20*a02);
|
||||
out[5] = inv * (a10*a02 - a00*a12);
|
||||
out[6] = inv * (a10*a21 - a20*a11);
|
||||
out[7] = inv * (a20*a01 - a00*a21);
|
||||
out[8] = inv * (a00*a11 - a10*a01);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void nrm3x3_mul(const float m[9], const float v[3], float out[3]) {
|
||||
out[0] = m[0]*v[0] + m[3]*v[1] + m[6]*v[2];
|
||||
out[1] = m[1]*v[0] + m[4]*v[1] + m[7]*v[2];
|
||||
out[2] = m[2]*v[0] + m[5]*v[1] + m[8]*v[2];
|
||||
}
|
||||
|
||||
// TRS → column-major 4x4 matrix
|
||||
// translation=[tx,ty,tz], rotation quaternion=[qx,qy,qz,qw], scale=[sx,sy,sz]
|
||||
static void trs_to_mat4(const float t[3], const float q[4], const float s[3],
|
||||
float out[16]) {
|
||||
float qx=q[0], qy=q[1], qz=q[2], qw=q[3];
|
||||
float x2=qx+qx, y2=qy+qy, z2=qz+qz;
|
||||
float xx=qx*x2, xy=qx*y2, xz=qx*z2;
|
||||
float yy=qy*y2, yz=qy*z2, zz=qz*z2;
|
||||
float wx=qw*x2, wy=qw*y2, wz=qw*z2;
|
||||
|
||||
out[0] = (1-(yy+zz))*s[0]; out[1] = (xy+wz)*s[0]; out[2] = (xz-wy)*s[0]; out[3] = 0;
|
||||
out[4] = (xy-wz)*s[1]; out[5] = (1-(xx+zz))*s[1]; out[6] = (yz+wx)*s[1]; out[7] = 0;
|
||||
out[8] = (xz+wy)*s[2]; out[9] = (yz-wx)*s[2]; out[10] = (1-(xx+yy))*s[2]; out[11] = 0;
|
||||
out[12] = t[0]; out[13] = t[1]; out[14] = t[2]; out[15] = 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessor reading helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct BufView {
|
||||
const uint8_t* base = nullptr;
|
||||
size_t total = 0;
|
||||
};
|
||||
|
||||
// Read a single element of 'count' components from accessor at element index 'idx'.
|
||||
// component_type: CT_FLOAT, CT_UNSIGNED_BYTE, CT_UNSIGNED_SHORT, CT_UNSIGNED_INT
|
||||
// components_per_element: 1 (SCALAR) or 3 (VEC3) etc.
|
||||
// Returns false if out-of-bounds.
|
||||
static bool read_float_vec(const BufView& bin,
|
||||
int component_type,
|
||||
int components_per_element,
|
||||
size_t byte_offset, // accessor.byteOffset + bufferView.byteOffset
|
||||
int byte_stride, // bufferView.byteStride (0 = tightly packed)
|
||||
size_t idx,
|
||||
float out[4]) {
|
||||
size_t comp_size = 0;
|
||||
switch (component_type) {
|
||||
case CT_UNSIGNED_BYTE: comp_size = 1; break;
|
||||
case CT_UNSIGNED_SHORT: comp_size = 2; break;
|
||||
case CT_UNSIGNED_INT: comp_size = 4; break;
|
||||
case CT_FLOAT: comp_size = 4; break;
|
||||
default: return false;
|
||||
}
|
||||
size_t element_size = comp_size * (size_t)components_per_element;
|
||||
size_t stride = (byte_stride > 0) ? (size_t)byte_stride : element_size;
|
||||
size_t off = byte_offset + idx * stride;
|
||||
if (off + element_size > bin.total) return false;
|
||||
|
||||
const uint8_t* p = bin.base + off;
|
||||
for (int c = 0; c < components_per_element; ++c) {
|
||||
const uint8_t* cp = p + (size_t)c * comp_size;
|
||||
switch (component_type) {
|
||||
case CT_UNSIGNED_BYTE: out[c] = (float)*cp; break;
|
||||
case CT_UNSIGNED_SHORT: {
|
||||
uint16_t v; std::memcpy(&v, cp, 2); out[c] = (float)v; break;
|
||||
}
|
||||
case CT_UNSIGNED_INT: {
|
||||
uint32_t v; std::memcpy(&v, cp, 4); out[c] = (float)v; break;
|
||||
}
|
||||
case CT_FLOAT: {
|
||||
float v; std::memcpy(&v, cp, 4); out[c] = v; break;
|
||||
}
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool read_index(const BufView& bin,
|
||||
int component_type,
|
||||
size_t byte_offset,
|
||||
size_t idx,
|
||||
uint32_t& out) {
|
||||
float v[1] = {};
|
||||
if (!read_float_vec(bin, component_type, 1, byte_offset, 0, idx, v))
|
||||
return false;
|
||||
out = static_cast<uint32_t>(v[0]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core GLB parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static Mesh parse_glb(const uint8_t* data, size_t size) {
|
||||
s_last_error[0] = '\0';
|
||||
|
||||
// --- 1. Validate header (12 bytes) ---
|
||||
if (size < 12) { set_error("file too small for GLB header"); return {}; }
|
||||
|
||||
uint32_t magic, version, total_len;
|
||||
std::memcpy(&magic, data, 4);
|
||||
std::memcpy(&version, data + 4, 4);
|
||||
std::memcpy(&total_len, data + 8, 4);
|
||||
|
||||
if (magic != GLB_MAGIC) { set_error("not a GLB file (bad magic)"); return {}; }
|
||||
if (version != GLB_VERSION){ set_error("unsupported GLB version (expected 2)"); return {}; }
|
||||
if (total_len > size) { set_error("GLB total_length > buffer size"); return {}; }
|
||||
|
||||
// --- 2. Walk chunks ---
|
||||
const uint8_t* json_data = nullptr; size_t json_len = 0;
|
||||
const uint8_t* bin_data = nullptr; size_t bin_len = 0;
|
||||
|
||||
size_t pos = 12;
|
||||
while (pos + 8 <= total_len) {
|
||||
uint32_t chunk_len, chunk_type;
|
||||
std::memcpy(&chunk_len, data + pos, 4);
|
||||
std::memcpy(&chunk_type, data + pos + 4, 4);
|
||||
pos += 8;
|
||||
if (pos + chunk_len > total_len) { set_error("chunk extends past file end"); return {}; }
|
||||
if (chunk_type == CHUNK_JSON) {
|
||||
json_data = data + pos;
|
||||
json_len = chunk_len;
|
||||
} else if (chunk_type == CHUNK_BIN) {
|
||||
bin_data = data + pos;
|
||||
bin_len = chunk_len;
|
||||
}
|
||||
pos += chunk_len;
|
||||
}
|
||||
|
||||
if (!json_data) { set_error("no JSON chunk found"); return {}; }
|
||||
|
||||
// --- 3. Parse JSON ---
|
||||
nlohmann::json j;
|
||||
try {
|
||||
j = nlohmann::json::parse(json_data, json_data + json_len);
|
||||
} catch (const std::exception& e) {
|
||||
std::snprintf(s_last_error, sizeof(s_last_error), "JSON parse error: %s", e.what());
|
||||
return {};
|
||||
}
|
||||
|
||||
// --- 4. Find first mesh / first primitive ---
|
||||
if (!j.contains("meshes") || j["meshes"].empty()) {
|
||||
set_error("no meshes in glTF");
|
||||
return {};
|
||||
}
|
||||
auto& prim = j["meshes"][0]["primitives"][0];
|
||||
|
||||
auto& attrs = prim["attributes"];
|
||||
if (!attrs.contains("POSITION")) {
|
||||
set_error("primitive has no POSITION attribute");
|
||||
return {};
|
||||
}
|
||||
|
||||
auto& accessors = j["accessors"];
|
||||
auto& bufferViews = j["bufferViews"];
|
||||
|
||||
BufView bin_view { bin_data, bin_len };
|
||||
|
||||
// Helper: resolve accessor index → (byte_offset, byte_stride, component_type, count, components_per_elem)
|
||||
struct AccInfo { size_t byte_offset; int byte_stride; int comp_type; size_t count; int ncomp; };
|
||||
|
||||
auto resolve_accessor = [&](int acc_idx, AccInfo& out) -> bool {
|
||||
if (acc_idx < 0 || acc_idx >= (int)accessors.size()) return false;
|
||||
auto& acc = accessors[acc_idx];
|
||||
int bv_idx = acc.value("bufferView", -1);
|
||||
size_t acc_offset = acc.value("byteOffset", 0);
|
||||
out.comp_type = acc.value("componentType", 0);
|
||||
out.count = acc.value("count", 0u);
|
||||
std::string type_str = acc.value("type", "SCALAR");
|
||||
out.ncomp = 1;
|
||||
if (type_str == "VEC2") out.ncomp = 2;
|
||||
else if (type_str == "VEC3") out.ncomp = 3;
|
||||
else if (type_str == "VEC4") out.ncomp = 4;
|
||||
|
||||
if (bv_idx >= 0 && bv_idx < (int)bufferViews.size()) {
|
||||
auto& bv = bufferViews[bv_idx];
|
||||
size_t bv_offset = bv.value("byteOffset", 0u);
|
||||
out.byte_stride = bv.value("byteStride", 0);
|
||||
out.byte_offset = acc_offset + bv_offset;
|
||||
} else {
|
||||
out.byte_offset = acc_offset;
|
||||
out.byte_stride = 0;
|
||||
}
|
||||
return out.count > 0 && out.comp_type != 0;
|
||||
};
|
||||
|
||||
// --- 5. Read POSITION ---
|
||||
AccInfo pos_info{};
|
||||
if (!resolve_accessor(attrs["POSITION"].get<int>(), pos_info)) {
|
||||
set_error("failed to resolve POSITION accessor");
|
||||
return {};
|
||||
}
|
||||
if (pos_info.ncomp != 3 || pos_info.comp_type != CT_FLOAT) {
|
||||
set_error("POSITION must be float vec3");
|
||||
return {};
|
||||
}
|
||||
if (!bin_data && pos_info.count > 0) {
|
||||
set_error("POSITION accessor requires BIN chunk, which is missing");
|
||||
return {};
|
||||
}
|
||||
|
||||
size_t nv = pos_info.count;
|
||||
std::vector<float> positions(nv * 3);
|
||||
for (size_t i = 0; i < nv; ++i) {
|
||||
float v[4]{};
|
||||
if (!read_float_vec(bin_view, CT_FLOAT, 3, pos_info.byte_offset,
|
||||
pos_info.byte_stride, i, v)) {
|
||||
set_error("out-of-bounds read in POSITION");
|
||||
return {};
|
||||
}
|
||||
positions[i*3+0] = v[0];
|
||||
positions[i*3+1] = v[1];
|
||||
positions[i*3+2] = v[2];
|
||||
}
|
||||
|
||||
// --- 6. Read NORMAL (optional) ---
|
||||
std::vector<float> normals;
|
||||
bool has_normals = false;
|
||||
if (attrs.contains("NORMAL")) {
|
||||
AccInfo nrm_info{};
|
||||
if (resolve_accessor(attrs["NORMAL"].get<int>(), nrm_info) &&
|
||||
nrm_info.ncomp == 3 && nrm_info.comp_type == CT_FLOAT &&
|
||||
nrm_info.count == nv) {
|
||||
normals.resize(nv * 3);
|
||||
for (size_t i = 0; i < nv; ++i) {
|
||||
float v[4]{};
|
||||
if (!read_float_vec(bin_view, CT_FLOAT, 3, nrm_info.byte_offset,
|
||||
nrm_info.byte_stride, i, v)) {
|
||||
set_error("out-of-bounds read in NORMAL");
|
||||
return {};
|
||||
}
|
||||
normals[i*3+0] = v[0];
|
||||
normals[i*3+1] = v[1];
|
||||
normals[i*3+2] = v[2];
|
||||
}
|
||||
has_normals = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 7. Read indices ---
|
||||
std::vector<uint32_t> indices;
|
||||
if (prim.contains("indices") && !prim["indices"].is_null()) {
|
||||
AccInfo idx_info{};
|
||||
int idx_acc = prim["indices"].get<int>();
|
||||
if (!resolve_accessor(idx_acc, idx_info)) {
|
||||
set_error("failed to resolve indices accessor");
|
||||
return {};
|
||||
}
|
||||
if (!bin_data && idx_info.count > 0) {
|
||||
set_error("indices accessor requires BIN chunk, which is missing");
|
||||
return {};
|
||||
}
|
||||
indices.resize(idx_info.count);
|
||||
for (size_t i = 0; i < idx_info.count; ++i) {
|
||||
if (!read_index(bin_view, idx_info.comp_type, idx_info.byte_offset, i, indices[i])) {
|
||||
set_error("out-of-bounds read in indices");
|
||||
return {};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No indices: interpret as sequential triangle list
|
||||
indices.resize(nv);
|
||||
for (size_t i = 0; i < nv; ++i) indices[i] = (uint32_t)i;
|
||||
}
|
||||
|
||||
// --- 8. Generate normals if missing (smooth, area-weighted) ---
|
||||
if (!has_normals) {
|
||||
normals.assign(nv * 3, 0.0f);
|
||||
size_t ntri = indices.size() / 3;
|
||||
for (size_t t = 0; t < ntri; ++t) {
|
||||
uint32_t i0 = indices[t*3+0];
|
||||
uint32_t i1 = indices[t*3+1];
|
||||
uint32_t i2 = indices[t*3+2];
|
||||
if (i0 >= nv || i1 >= nv || i2 >= nv) continue;
|
||||
|
||||
float e1[3] = {
|
||||
positions[i1*3+0] - positions[i0*3+0],
|
||||
positions[i1*3+1] - positions[i0*3+1],
|
||||
positions[i1*3+2] - positions[i0*3+2]
|
||||
};
|
||||
float e2[3] = {
|
||||
positions[i2*3+0] - positions[i0*3+0],
|
||||
positions[i2*3+1] - positions[i0*3+1],
|
||||
positions[i2*3+2] - positions[i0*3+2]
|
||||
};
|
||||
float face_n[3];
|
||||
cross3(e1, e2, face_n);
|
||||
// face_n magnitude = 2 * area → area weighting automatic
|
||||
for (uint32_t vi : {i0, i1, i2}) {
|
||||
normals[vi*3+0] += face_n[0];
|
||||
normals[vi*3+1] += face_n[1];
|
||||
normals[vi*3+2] += face_n[2];
|
||||
}
|
||||
}
|
||||
// Normalize per-vertex
|
||||
for (size_t i = 0; i < nv; ++i) {
|
||||
float* n = &normals[i*3];
|
||||
float l = len3(n);
|
||||
if (l > 1e-8f) { n[0]/=l; n[1]/=l; n[2]/=l; }
|
||||
else { n[0]=0; n[1]=1; n[2]=0; } // degenerate fallback
|
||||
}
|
||||
}
|
||||
|
||||
// --- 9. Apply node transform (first node referencing this mesh) ---
|
||||
bool applied_transform = false;
|
||||
if (j.contains("nodes") && !j["nodes"].empty()) {
|
||||
auto& nodes = j["nodes"];
|
||||
for (size_t ni = 0; ni < nodes.size() && !applied_transform; ++ni) {
|
||||
auto& node = nodes[ni];
|
||||
if (!node.contains("mesh") || node["mesh"].get<int>() != 0) continue;
|
||||
|
||||
float mat[16] = {
|
||||
1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1
|
||||
}; // identity column-major
|
||||
|
||||
if (node.contains("matrix") && node["matrix"].is_array() && node["matrix"].size() == 16) {
|
||||
for (int k = 0; k < 16; ++k)
|
||||
mat[k] = node["matrix"][k].get<float>();
|
||||
applied_transform = true;
|
||||
} else {
|
||||
float t[3] = {0,0,0}, q[4] = {0,0,0,1}, s[3] = {1,1,1};
|
||||
bool has_trs = false;
|
||||
if (node.contains("translation") && node["translation"].size() == 3) {
|
||||
for (int k = 0; k < 3; ++k) t[k] = node["translation"][k].get<float>();
|
||||
has_trs = true;
|
||||
}
|
||||
if (node.contains("rotation") && node["rotation"].size() == 4) {
|
||||
for (int k = 0; k < 4; ++k) q[k] = node["rotation"][k].get<float>();
|
||||
has_trs = true;
|
||||
}
|
||||
if (node.contains("scale") && node["scale"].size() == 3) {
|
||||
for (int k = 0; k < 3; ++k) s[k] = node["scale"][k].get<float>();
|
||||
has_trs = true;
|
||||
}
|
||||
if (has_trs) {
|
||||
trs_to_mat4(t, q, s, mat);
|
||||
applied_transform = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (applied_transform) {
|
||||
// Check if matrix is non-trivially identity
|
||||
const float id[16] = {1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1};
|
||||
bool is_identity = true;
|
||||
for (int k = 0; k < 16; ++k)
|
||||
if (std::fabs(mat[k] - id[k]) > 1e-6f) { is_identity = false; break; }
|
||||
|
||||
if (!is_identity) {
|
||||
float nrm_mat[9];
|
||||
bool has_nrm_mat = compute_normal_matrix(mat, nrm_mat);
|
||||
|
||||
for (size_t vi = 0; vi < nv; ++vi) {
|
||||
float p[3] = { positions[vi*3+0], positions[vi*3+1], positions[vi*3+2] };
|
||||
float tp[3];
|
||||
mat4_mul_point(mat, p, tp);
|
||||
positions[vi*3+0] = tp[0];
|
||||
positions[vi*3+1] = tp[1];
|
||||
positions[vi*3+2] = tp[2];
|
||||
|
||||
if (has_nrm_mat) {
|
||||
float n[3] = { normals[vi*3+0], normals[vi*3+1], normals[vi*3+2] };
|
||||
float tn[3];
|
||||
nrm3x3_mul(nrm_mat, n, tn);
|
||||
float l = len3(tn);
|
||||
if (l > 1e-8f) { tn[0]/=l; tn[1]/=l; tn[2]/=l; }
|
||||
normals[vi*3+0] = tn[0];
|
||||
normals[vi*3+1] = tn[1];
|
||||
normals[vi*3+2] = tn[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Mesh m;
|
||||
m.positions = std::move(positions);
|
||||
m.normals = std::move(normals);
|
||||
m.indices = std::move(indices);
|
||||
return m;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Mesh gltf_load_mesh_from_memory(const unsigned char* data, size_t size) {
|
||||
return parse_glb(reinterpret_cast<const uint8_t*>(data), size);
|
||||
}
|
||||
|
||||
Mesh gltf_load_mesh_from_file(const char* path) {
|
||||
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||
if (!f) {
|
||||
std::snprintf(s_last_error, sizeof(s_last_error),
|
||||
"cannot open file: %s", path);
|
||||
return {};
|
||||
}
|
||||
auto file_size = f.tellg();
|
||||
if (file_size <= 0) { set_error("file is empty"); return {}; }
|
||||
f.seekg(0);
|
||||
std::vector<uint8_t> buf((size_t)file_size);
|
||||
if (!f.read(reinterpret_cast<char*>(buf.data()), file_size)) {
|
||||
set_error("file read failed");
|
||||
return {};
|
||||
}
|
||||
return parse_glb(buf.data(), buf.size());
|
||||
}
|
||||
|
||||
} // namespace fn::gfx
|
||||
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
|
||||
#include "gfx/mesh_obj_load.h" // fn::gfx::Mesh
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace fn::gfx {
|
||||
|
||||
// Carga el primer mesh (primera primitive del primer mesh) de un archivo GLB 2.0.
|
||||
//
|
||||
// Soporta:
|
||||
// - POSITION (vec3 float, obligatorio)
|
||||
// - NORMAL (vec3 float, opcional — si falta se generan normales smooth
|
||||
// area-weighted promediando las normales de cara de cada vertice)
|
||||
// - indices (ubyte/ushort/uint, escalares) — sin indices se interpreta como
|
||||
// lista de triangulos directa.
|
||||
//
|
||||
// Node transform: si el primer nodo que referencia el mesh tiene matrix o TRS,
|
||||
// se aplica a posiciones y normales (normales se transforman con la inversa transpuesta).
|
||||
//
|
||||
// Limitaciones (documentadas):
|
||||
// - Solo GLB (binario). .gltf+.bin separado y data-URIs base64 no soportados.
|
||||
// - Solo el primer mesh / primera primitive.
|
||||
// - Sin texturas ni materiales (mesh viewer usa color uniforme).
|
||||
// - Asume buffer 0 embebido en el chunk BIN.
|
||||
//
|
||||
// Retorna Mesh vacio (positions.empty()) si el parse falla.
|
||||
// El detalle del error esta disponible via gltf_load_last_error().
|
||||
Mesh gltf_load_mesh_from_file(const char* path);
|
||||
|
||||
// Variante pura (salvo el buffer): parsea GLB desde un bloque de memoria.
|
||||
// 'data' debe vivir al menos mientras dure la llamada.
|
||||
// Retorna Mesh vacio en fallo; gltf_load_last_error() da el detalle.
|
||||
Mesh gltf_load_mesh_from_memory(const unsigned char* data, size_t size);
|
||||
|
||||
// Descripcion del ultimo error de gltf_load_mesh_from_file /
|
||||
// gltf_load_mesh_from_memory. Valida hasta la siguiente llamada a cualquiera
|
||||
// de las dos funciones. Nunca retorna nullptr (puede ser "").
|
||||
const char* gltf_load_last_error();
|
||||
|
||||
} // namespace fn::gfx
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
name: gltf_load_mesh
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: gfx
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Mesh gltf_load_mesh_from_file(const char* path); Mesh gltf_load_mesh_from_memory(const unsigned char* data, size_t size); const char* gltf_load_last_error()"
|
||||
description: "Parser GLB 2.0 (glTF binario): carga el primer mesh/primitive a CPU como fn::gfx::Mesh. Soporta POSITION+NORMAL (vec3 float), indices ubyte/ushort/uint, node transform TRS/matrix. Genera normales smooth area-weighted si faltan. Sin dependencias externas — BIN chunk + nlohmann JSON vendored."
|
||||
tags: [mesh, gltf, glb, 3d, loader, geometry, gfx, mesh-3d]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [gfx/mesh_obj_load.h, nlohmann/json.hpp, fstream, cstring, cmath]
|
||||
tested: true
|
||||
tests:
|
||||
- "invalid magic -> empty Mesh + last_error set"
|
||||
- "too-small buffer -> empty Mesh + last_error set"
|
||||
- "triangle without NORMAL -> normals generated, correct count"
|
||||
- "quad (2 triangles) -> positions.size()==12, indices.size()==6"
|
||||
- "explicit normals -> passed through unchanged"
|
||||
- "nonexistent file -> empty Mesh + last_error set"
|
||||
test_file_path: "cpp/tests/test_gltf_load_mesh.cpp"
|
||||
file_path: "cpp/functions/gfx/gltf_load_mesh.cpp"
|
||||
framework: opengl
|
||||
params:
|
||||
- name: path
|
||||
desc: "Ruta al archivo .glb. Solo GLB binario — .gltf+.bin separado y data-URI base64 no soportados."
|
||||
- name: data
|
||||
desc: "Puntero al buffer GLB en memoria. Debe vivir mientras dure la llamada."
|
||||
- name: size
|
||||
desc: "Longitud del buffer en bytes."
|
||||
output: "fn::gfx::Mesh con positions/normals (stride 3, mismo length) y indices uint32 (tri-list). Mesh vacio (positions.empty()==true) si parse falla. gltf_load_last_error() devuelve descripcion del error."
|
||||
notes: |
|
||||
Usa fn::gfx::Mesh de mesh_obj_load.h — mismo struct que consume mesh_gpu_upload().
|
||||
nlohmann vendored en cpp/vendor/nlohmann/json.hpp.
|
||||
El parser no aloca heap mas alla del Mesh de salida + JSON temporal.
|
||||
gltf_load_last_error() usa thread_local — seguro en multihilo siempre que
|
||||
cada hilo llame sus propias funciones.
|
||||
---
|
||||
|
||||
# gltf_load_mesh
|
||||
|
||||
Loader GLB 2.0 minimal para el registry. Parsea el contenedor GLB binario a mano
|
||||
(header 12 bytes + chunks JSON + BIN) usando nlohmann para el JSON. KISS: sin
|
||||
tinygltf ni dependencias extra.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```cpp
|
||||
// Cargar .glb generado por TripoSR/trimesh y subir a GPU:
|
||||
#include "gfx/gltf_load_mesh.h"
|
||||
#include "gfx/mesh_gpu.h"
|
||||
|
||||
auto cpu = fn::gfx::gltf_load_mesh_from_file("model.glb");
|
||||
if (cpu.positions.empty()) {
|
||||
fprintf(stderr, "gltf load failed: %s\n", fn::gfx::gltf_load_last_error());
|
||||
return;
|
||||
}
|
||||
|
||||
// Subir a GPU (requiere contexto GL activo):
|
||||
auto gpu = fn::gfx::mesh_gpu_upload(cpu);
|
||||
if (!gpu.ok()) { /* fallo de upload GL */ return; }
|
||||
|
||||
glUseProgram(prog);
|
||||
glBindVertexArray(gpu.vao);
|
||||
glDrawElements(GL_TRIANGLES, gpu.index_count, GL_UNSIGNED_INT, 0);
|
||||
glBindVertexArray(0);
|
||||
|
||||
fn::gfx::mesh_gpu_destroy(gpu);
|
||||
```
|
||||
|
||||
```cpp
|
||||
// Desde memoria (ej. respuesta HTTP o embedding):
|
||||
std::vector<unsigned char> glb_buf = download_glb(...);
|
||||
auto cpu = fn::gfx::gltf_load_mesh_from_memory(glb_buf.data(), glb_buf.size());
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando recibes un `.glb` (binario glTF 2.0) de un backend Python (TripoSR,
|
||||
trimesh, open3d) y necesitas renderizarlo en una app ImGui via `mesh_gpu_upload`.
|
||||
Tambien util para inspeccionar geometria en CPU sin subir a GPU.
|
||||
|
||||
## Limitaciones
|
||||
|
||||
- **Solo GLB binario**. `.gltf + .bin` separado: no soportado. Data URIs base64: no soportados.
|
||||
- **Primer mesh, primera primitive**. Archivos con multiples meshes o materiales: solo se carga el primero.
|
||||
- **Sin texturas ni materiales**. El Mesh solo contiene geometria (posicion + normal). El shader del viewer usa color uniforme.
|
||||
- **Buffer unico embebido** (chunk BIN). Referencias a buffers externos: no soportadas.
|
||||
- **Modo solo triangulos** (`"mode": 4`, default). Puntos, lineas, triangle-strip: no soportados.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `gltf_load_last_error()` es `thread_local`. Si usas multihilo, cada hilo tiene su propio error buffer — no compartas el puntero entre hilos.
|
||||
- El puntero que devuelve `gltf_load_last_error()` se sobreescribe en la siguiente llamada a `gltf_load_mesh_from_*`. Copia el string si lo necesitas despues.
|
||||
- Un `Mesh` retornado con `positions.empty() == true` es la senal de fallo — **no** lanzamos excepciones.
|
||||
- Para archivos grandes (>50 MB) la lectura es un `std::vector<uint8_t>` completo en memoria. Para streaming, usa `gltf_load_mesh_from_memory` con tu propio buffer.
|
||||
- El parser no valida que `indices` sean menores que `nv` en cada vertice — indices fuera de rango se saltan silenciosamente durante la generacion de normales pero pueden producir geometria incorrecta.
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "MeshGpu mesh_gpu_upload(const Mesh&); void mesh_gpu_destroy(MeshGpu&)"
|
||||
description: "Sube un Mesh CPU a OpenGL como VAO + VBO interleaved (pos.xyz, normal.xyz) + EBO uint32. Layout: location 0 = a_pos vec3, location 1 = a_normal vec3, stride 6 floats."
|
||||
tags: [opengl, mesh, vao, vbo, ebo, gpu, gfx]
|
||||
tags: [opengl, mesh, vao, vbo, ebo, gpu, gfx, mesh-3d]
|
||||
uses_functions: ["gl_loader_cpp_gfx", "mesh_obj_load_cpp_gfx"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "Mesh mesh_obj_parse(const char* obj_text, size_t len); Mesh mesh_obj_load(const char* path)"
|
||||
description: "Parser minimal de Wavefront .obj — soporta v, vn, f (tris y quads). Genera normales por face si faltan. mesh_obj_parse es puro; mesh_obj_load es helper impuro que lee fichero y delega."
|
||||
tags: [obj, mesh, parser, wavefront, loader, geometry, 3d]
|
||||
tags: [obj, mesh, parser, wavefront, loader, geometry, 3d, mesh-3d]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -105,7 +105,7 @@ GLuint compile_program() {
|
||||
void ensure_init(Cache& c) {
|
||||
if (c.initialized) return;
|
||||
fn::gfx::gl_loader_init();
|
||||
fn::gfx::fb_init(c.fb);
|
||||
fn::gfx::fb_init_depth(c.fb);
|
||||
c.program = compile_program();
|
||||
if (c.program) {
|
||||
c.loc_view = glGetUniformLocation(c.program, "u_view");
|
||||
@@ -145,10 +145,9 @@ void mesh_viewer(const char* id, const MeshViewerConfig& cfg) {
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, c.fb.fbo);
|
||||
glViewport(0, 0, w, h);
|
||||
glClearColor(0.10f, 0.10f, 0.13f, 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
// No depth attachment in our FBO — fall back to back-to-front-ish via
|
||||
// GL_DEPTH_TEST off. For inspection meshes this is fine; documented.
|
||||
glDisable(GL_DEPTH_TEST);
|
||||
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
|
||||
glEnable(GL_DEPTH_TEST);
|
||||
glDepthFunc(GL_LESS);
|
||||
|
||||
glUseProgram(c.program);
|
||||
auto m = fn::core::orbit_camera_matrices(*cfg.cam);
|
||||
@@ -183,7 +182,7 @@ void mesh_viewer(const char* id, const MeshViewerConfig& cfg) {
|
||||
// Restore GL state.
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prev_fbo);
|
||||
glViewport(prev_vp[0], prev_vp[1], prev_vp[2], prev_vp[3]);
|
||||
if (prev_depth) glEnable(GL_DEPTH_TEST);
|
||||
if (prev_depth) glEnable(GL_DEPTH_TEST); else glDisable(GL_DEPTH_TEST);
|
||||
}
|
||||
|
||||
// Display.
|
||||
|
||||
@@ -3,11 +3,11 @@ name: mesh_viewer
|
||||
kind: component
|
||||
lang: cpp
|
||||
domain: viz
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "void mesh_viewer(const char* id, const MeshViewerConfig& cfg)"
|
||||
description: "Renderiza un MeshGpu (3D) en un FBO interno cacheado por id, con orbit camera, iluminacion Lambert headlight, opcion wireframe. Drag/wheel del mouse mueven la camara."
|
||||
tags: [imgui, opengl, mesh, 3d, viewer, viz, fbo, pendiente-usar]
|
||||
description: "Renderiza un MeshGpu (3D) en un FBO interno cacheado por id, con orbit camera, iluminacion Lambert headlight, depth test correcto (GL_DEPTH_COMPONENT24), opcion wireframe. Drag/wheel del mouse mueven la camara."
|
||||
tags: [imgui, opengl, mesh, 3d, viewer, viz, fbo, cpp-dashboard-viz]
|
||||
uses_functions: ["gl_framebuffer_cpp_gfx", "gl_loader_cpp_gfx", "gl_shader_cpp_gfx", "mesh_gpu_cpp_gfx", "orbit_camera_cpp_core"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -20,6 +20,11 @@ test_file_path: ""
|
||||
file_path: "cpp/functions/viz/mesh_viewer.cpp"
|
||||
framework: imgui
|
||||
emits: ["camera_drag", "camera_zoom"]
|
||||
params:
|
||||
- name: id
|
||||
desc: "ID estable de ImGui para cachear el FBO y el programa shader. Cambiar el id entre frames acumula recursos (leak). Usar IDs constantes."
|
||||
- name: cfg
|
||||
desc: "MeshViewerConfig con mesh (MeshGpu*), cam (OrbitCamera*), size (ImVec2, -1 = full width), wireframe (bool), color (ImU32 RGBA)."
|
||||
output: "Renderiza una imagen del mesh dentro del frame ImGui actual; muta cfg.cam in-place segun drag/wheel del mouse cuando el panel esta active/hovered."
|
||||
---
|
||||
|
||||
@@ -28,8 +33,8 @@ output: "Renderiza una imagen del mesh dentro del frame ImGui actual; muta cfg.c
|
||||
Componente de viz para inspeccionar geometria 3D dentro de cualquier panel ImGui. Internamente:
|
||||
|
||||
1. Compila/cachea (por `id`) un programa shader Lambert headlight (vertex + fragment).
|
||||
2. Cachea un `Framebuffer` por `id` y lo redimensiona segun `cfg.size`.
|
||||
3. Cada frame: bind FBO, draw `cfg.mesh`, mostrar la textura via `ImGui::Image`.
|
||||
2. Cachea un `Framebuffer` con depth renderbuffer por `id` y lo redimensiona segun `cfg.size`.
|
||||
3. Cada frame: bind FBO, clear color+depth, draw `cfg.mesh` con depth test activo, mostrar la textura via `ImGui::Image`.
|
||||
4. Si el panel esta active → llama `orbit_camera_handle_drag` con `MouseDelta`.
|
||||
5. Si el panel esta hovered y hay scroll → ajusta zoom.
|
||||
|
||||
@@ -46,10 +51,23 @@ cfg.color = IM_COL32(160, 200, 255, 255);
|
||||
fn::viz::mesh_viewer("##teapot_view", cfg);
|
||||
```
|
||||
|
||||
## Notas
|
||||
## Cuando usarla
|
||||
|
||||
- **Sin depth buffer**: el FBO solo tiene attachment color (sigue el patron de `gl_framebuffer`). Para meshes complejos con auto-oclusion, esto produce artefactos. Issue futuro puede añadir depth/stencil renderbuffer.
|
||||
- **Wireframe**: usa `glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)` (no disponible en GL ES; protegido con `#ifndef __EMSCRIPTEN__`).
|
||||
- **Cache por id**: si el `id` cambia dinamicamente entre frames, se acumulan FBOs y programas en memoria (leak). Usar IDs estables.
|
||||
Cuando necesites inspeccionar geometria 3D (OBJ, STL, cualquier MeshGpu) dentro de un panel ImGui existente, con orbit camera interactiva y auto-oclusion correcta de caras.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Cache por id**: si el `id` cambia dinamicamente entre frames, se acumulan FBOs y programas en memoria (leak). Usar IDs estables (`"##nombre_fijo"`).
|
||||
- **Wireframe**: usa `glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)` — no disponible en GL ES; protegido con `#ifndef __EMSCRIPTEN__`.
|
||||
- **Iluminacion**: Lambert con luz fija en `+Z` view-space ("headlight"), suficiente para inspeccion. Sin specular, sin sombras.
|
||||
- **Matrices**: row-major desde `orbit_camera_matrices`; se suben con `transpose=GL_TRUE` (GL espera column-major).
|
||||
- **Estado GL**: salva y restaura `GL_FRAMEBUFFER_BINDING`, `GL_VIEWPORT` y `GL_DEPTH_TEST` antes/despues del render. No contamina el estado del frame ImGui principal.
|
||||
|
||||
## Notas
|
||||
|
||||
- **Depth renderbuffer activo** (GL_DEPTH_COMPONENT24): auto-oclusion correcta en meshes solidos. `glEnable(GL_DEPTH_TEST)` + `glDepthFunc(GL_LESS)` dentro del render del FBO.
|
||||
- Usa `fb_init_depth` de `gl_framebuffer_cpp_gfx` (v1.1.0+).
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.1.0 (2026-05-28) — depth renderbuffer via fb_init_depth, fix auto-oclusion en meshes solidos
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
// terminal_panel.cpp — render + process_output + shared logic.
|
||||
// Los backends (open/close/send) viven en terminal_panel_linux.cpp
|
||||
// y terminal_panel_windows.cpp respectivamente.
|
||||
|
||||
#include "viz/terminal_panel/terminal_panel.h"
|
||||
#include "core/logger.h"
|
||||
#include "core/tokens.h"
|
||||
#include "imgui.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
namespace fn_term {
|
||||
|
||||
namespace {
|
||||
|
||||
// Convierte índice de color fn_term (0-16) a ImU32 RGBA para ImGui.
|
||||
// Usa la paleta kPalette16; fg=16 (default) → color de texto del tema ImGui.
|
||||
ImU32 color_to_imu32(uint8_t idx, bool is_fg) {
|
||||
if (idx == kColorDefault) {
|
||||
// Usar color del tema: FG → Text, BG → transparente.
|
||||
if (is_fg) return ImGui::GetColorU32(ImGuiCol_Text);
|
||||
return IM_COL32(0, 0, 0, 0); // transparente
|
||||
}
|
||||
// kPalette16 está en formato ABGR (little-endian), ImU32 también es ABGR en ImGui.
|
||||
return static_cast<ImU32>(kPalette16[idx]);
|
||||
}
|
||||
|
||||
// Renderiza una línea del scrollback con colores.
|
||||
// Toma la línea como vector<AnsiCell> y escribe chunks de mismo color.
|
||||
void render_line(const TermLine& line) {
|
||||
if (line.empty()) {
|
||||
ImGui::NewLine();
|
||||
return;
|
||||
}
|
||||
|
||||
// Agrupar celdas consecutivas con mismo fg/bg/bold y emitir como texto.
|
||||
// Usamos un buffer temporal de la pila para evitar alloacs por línea.
|
||||
static char buf[4096];
|
||||
|
||||
size_t i = 0;
|
||||
while (i < line.size()) {
|
||||
uint8_t fg = line[i].fg;
|
||||
uint8_t bg = line[i].bg;
|
||||
// uint8_t bold = line[i].bold; // TODO(0132): bold rendering v2
|
||||
|
||||
// Acumular chars con mismo estilo.
|
||||
size_t j = i;
|
||||
int pos = 0;
|
||||
while (j < line.size() && line[j].fg == fg && line[j].bg == bg) {
|
||||
char32_t ch = line[j].ch;
|
||||
if (ch >= 0x20 && ch < 0x7F && pos < (int)sizeof(buf) - 2) {
|
||||
buf[pos++] = static_cast<char>(ch);
|
||||
} else if (ch != U' ' && pos < (int)sizeof(buf) - 2) {
|
||||
buf[pos++] = '?'; // no-ASCII en v1
|
||||
} else if (pos < (int)sizeof(buf) - 2) {
|
||||
buf[pos++] = ' ';
|
||||
}
|
||||
j++;
|
||||
}
|
||||
buf[pos] = '\0';
|
||||
|
||||
// Push color FG.
|
||||
ImU32 fg_col = color_to_imu32(fg, true);
|
||||
bool has_fg = (fg != kColorDefault);
|
||||
if (has_fg) ImGui::PushStyleColor(ImGuiCol_Text, fg_col);
|
||||
|
||||
// Fondo: si BG definido, usar InvisibleButton + DrawList rect antes del texto.
|
||||
// En v1 simplificamos: solo coloreamos el texto (FG). BG requiere DrawList.
|
||||
// TODO(0132): renderizar celdas BG con InvisibleButton + DrawList en v2.
|
||||
|
||||
ImGui::TextUnformatted(buf, buf + pos);
|
||||
|
||||
if (has_fg) ImGui::PopStyleColor();
|
||||
|
||||
// Continuar en la misma línea si hay más celdas.
|
||||
if (j < line.size()) ImGui::SameLine(0.0f, 0.0f);
|
||||
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TerminalPanel::TerminalPanel() {
|
||||
// Reservar una línea inicial vacía.
|
||||
lines.emplace_back();
|
||||
}
|
||||
|
||||
TerminalPanel::~TerminalPanel() {
|
||||
if (is_open()) close(*this);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// process_output — llamado desde el reader thread.
|
||||
// Parsea los bytes via AnsiParser y actualiza el scrollback buffer.
|
||||
// ---------------------------------------------------------------------------
|
||||
void process_output(TerminalPanel& panel, const char* data, size_t n) {
|
||||
std::lock_guard<std::mutex> lk(panel.buf_mutex);
|
||||
|
||||
panel.parser.feed(data, n, [&](const AnsiEvent& ev) {
|
||||
switch (ev.type) {
|
||||
case AnsiEventType::Char: {
|
||||
// Asegurar que tenemos al menos cur_row+1 filas.
|
||||
while ((int)panel.lines.size() <= panel.cur_row)
|
||||
panel.lines.emplace_back();
|
||||
TermLine& line = panel.lines[panel.cur_row];
|
||||
// Asegurar que la fila tiene al menos cur_col+1 celdas.
|
||||
while ((int)line.size() <= panel.cur_col)
|
||||
line.push_back(AnsiCell{});
|
||||
line[panel.cur_col] = ev.cell;
|
||||
panel.cur_col++;
|
||||
break;
|
||||
}
|
||||
case AnsiEventType::Newline: {
|
||||
panel.cur_row++;
|
||||
// Scrollback circular: si excede el límite, eliminar la primera fila.
|
||||
while ((int)panel.lines.size() <= panel.cur_row)
|
||||
panel.lines.emplace_back();
|
||||
if ((int)panel.lines.size() > panel.scrollback_lines) {
|
||||
int excess = (int)panel.lines.size() - panel.scrollback_lines;
|
||||
panel.lines.erase(panel.lines.begin(),
|
||||
panel.lines.begin() + excess);
|
||||
panel.cur_row -= excess;
|
||||
if (panel.cur_row < 0) panel.cur_row = 0;
|
||||
}
|
||||
panel.scroll_to_bottom = true;
|
||||
break;
|
||||
}
|
||||
case AnsiEventType::CarriageReturn: {
|
||||
panel.cur_col = 0;
|
||||
break;
|
||||
}
|
||||
case AnsiEventType::Backspace: {
|
||||
if (panel.cur_col > 0) panel.cur_col--;
|
||||
break;
|
||||
}
|
||||
case AnsiEventType::CursorAbsolute: {
|
||||
panel.cur_row = std::max(0, ev.cursor_abs.row);
|
||||
panel.cur_col = std::max(0, ev.cursor_abs.col);
|
||||
// Extender líneas si necesario.
|
||||
while ((int)panel.lines.size() <= panel.cur_row)
|
||||
panel.lines.emplace_back();
|
||||
break;
|
||||
}
|
||||
case AnsiEventType::CursorMove: {
|
||||
switch (ev.cursor_rel.dir) {
|
||||
case CursorDir::Up:
|
||||
panel.cur_row = std::max(0, panel.cur_row - ev.cursor_rel.n);
|
||||
break;
|
||||
case CursorDir::Down:
|
||||
panel.cur_row += ev.cursor_rel.n;
|
||||
while ((int)panel.lines.size() <= panel.cur_row)
|
||||
panel.lines.emplace_back();
|
||||
break;
|
||||
case CursorDir::Forward:
|
||||
panel.cur_col += ev.cursor_rel.n;
|
||||
break;
|
||||
case CursorDir::Back:
|
||||
panel.cur_col = std::max(0, panel.cur_col - ev.cursor_rel.n);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case AnsiEventType::EraseDisplay: {
|
||||
panel.lines.clear();
|
||||
panel.lines.emplace_back();
|
||||
panel.cur_row = 0;
|
||||
panel.cur_col = 0;
|
||||
panel.parser.reset();
|
||||
break;
|
||||
}
|
||||
case AnsiEventType::EraseLine: {
|
||||
while ((int)panel.lines.size() <= panel.cur_row)
|
||||
panel.lines.emplace_back();
|
||||
panel.lines[panel.cur_row].clear();
|
||||
panel.cur_col = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// render — debe llamarse dentro de un frame ImGui activo.
|
||||
// ---------------------------------------------------------------------------
|
||||
void render(TerminalPanel& panel) {
|
||||
// --- Toolbar ---
|
||||
ImGui::PushID("##term_toolbar");
|
||||
|
||||
if (ImGui::SmallButton("Clear")) {
|
||||
std::lock_guard<std::mutex> lk(panel.buf_mutex);
|
||||
panel.lines.clear();
|
||||
panel.lines.emplace_back();
|
||||
panel.cur_row = 0;
|
||||
panel.cur_col = 0;
|
||||
}
|
||||
ImGui::SameLine();
|
||||
|
||||
if (ImGui::SmallButton("Copy")) {
|
||||
// Copiar todo el scrollback como texto plano al portapapeles.
|
||||
std::string text;
|
||||
std::lock_guard<std::mutex> lk(panel.buf_mutex);
|
||||
for (const auto& line : panel.lines) {
|
||||
for (const auto& cell : line) {
|
||||
if (cell.ch >= 0x20 && cell.ch < 0x7F)
|
||||
text += static_cast<char>(cell.ch);
|
||||
else if (cell.ch != U' ')
|
||||
text += '?';
|
||||
else
|
||||
text += ' ';
|
||||
}
|
||||
text += '\n';
|
||||
}
|
||||
ImGui::SetClipboardText(text.c_str());
|
||||
}
|
||||
ImGui::SameLine();
|
||||
|
||||
if (ImGui::SmallButton("Reset") && panel.is_open()) {
|
||||
fn_term::close(panel);
|
||||
fn_term::open(panel);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
|
||||
bool lock = !panel.scroll_to_bottom;
|
||||
if (ImGui::Checkbox("Lock scroll", &lock)) {
|
||||
panel.scroll_to_bottom = !lock;
|
||||
}
|
||||
ImGui::SameLine();
|
||||
|
||||
// Indicador de estado del proceso.
|
||||
if (!panel.is_open()) {
|
||||
ImGui::TextDisabled("[closed]");
|
||||
} else if (panel.process_exited.load()) {
|
||||
ImGui::TextDisabled("[exited %d]", panel.exit_code);
|
||||
} else {
|
||||
ImGui::TextDisabled("[running]");
|
||||
}
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
// --- Scrollback area — fondo negro con texto gris claro ---
|
||||
ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||
|
||||
// Reservar hueco para el input prompt si no es readonly.
|
||||
// GetFrameHeightWithSpacing() cubre una línea de InputText + padding.
|
||||
const float input_reserve = (!panel.readonly)
|
||||
? (ImGui::GetFrameHeightWithSpacing() + 6.0f)
|
||||
: 0.0f;
|
||||
float child_h = std::max(avail.y - input_reserve, 32.0f);
|
||||
|
||||
// Estilos del area terminal: fondo casi negro + texto gris claro.
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(10, 10, 10, 255));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(220, 220, 220, 255));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 6.0f));
|
||||
|
||||
ImGui::BeginChild("##term_scroll", ImVec2(0, child_h),
|
||||
ImGuiChildFlags_Borders,
|
||||
ImGuiWindowFlags_HorizontalScrollbar);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(panel.buf_mutex);
|
||||
// Usar un clipper para evitar renderizar líneas fuera de vista.
|
||||
ImGuiListClipper clipper;
|
||||
clipper.Begin((int)panel.lines.size());
|
||||
while (clipper.Step()) {
|
||||
for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) {
|
||||
render_line(panel.lines[i]);
|
||||
}
|
||||
}
|
||||
clipper.End();
|
||||
}
|
||||
|
||||
if (panel.scroll_to_bottom && ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f) {
|
||||
ImGui::SetScrollHereY(1.0f);
|
||||
}
|
||||
|
||||
ImGui::EndChild();
|
||||
|
||||
ImGui::PopStyleVar(); // WindowPadding
|
||||
ImGui::PopStyleColor(2); // ChildBg + Text
|
||||
|
||||
// --- Input prompt (visible siempre que readonly=false) ---
|
||||
if (!panel.readonly) {
|
||||
// Mostrar un prefijo "$ " antes del input box.
|
||||
ImGui::TextUnformatted("$ ");
|
||||
ImGui::SameLine(0.0f, 4.0f);
|
||||
|
||||
static char s_input[1024] = {};
|
||||
ImGui::SetNextItemWidth(-1.0f);
|
||||
|
||||
// Si el shell está cerrado, desactivar el input.
|
||||
if (!panel.is_open()) ImGui::BeginDisabled();
|
||||
bool enter = ImGui::InputText("##term_input", s_input, sizeof(s_input),
|
||||
ImGuiInputTextFlags_EnterReturnsTrue);
|
||||
if (!panel.is_open()) ImGui::EndDisabled();
|
||||
|
||||
if (enter && panel.is_open()) {
|
||||
std::string cmd = std::string(s_input) + "\n";
|
||||
fn_term::send(panel, cmd);
|
||||
s_input[0] = '\0';
|
||||
ImGui::SetKeyboardFocusHere(-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace fn_term
|
||||
@@ -0,0 +1,111 @@
|
||||
#pragma once
|
||||
|
||||
// terminal_panel — emulador TTY embebible en ImGui.
|
||||
//
|
||||
// Arranca un proceso hijo via PTY (Linux: forkpty) o ConPTY (Windows) y
|
||||
// renderiza su output en un child window ImGui con soporte basico de ANSI:
|
||||
// colores FG/BG 16-color, bold, cursor pos, clear screen/line.
|
||||
//
|
||||
// Uso basico:
|
||||
// static fn_term::TerminalPanel term;
|
||||
// term.shell = "/bin/bash";
|
||||
//
|
||||
// if (!term.is_open()) fn_term::open(term);
|
||||
// fn_term::render(term);
|
||||
// if (!term.readonly) fn_term::send(term, "ls\n");
|
||||
// // Al cerrar:
|
||||
// fn_term::close(term);
|
||||
//
|
||||
// Thread-safety: open/render/send/close deben llamarse desde el hilo ImGui.
|
||||
// El reader thread interno es gestionado por la implementacion.
|
||||
//
|
||||
// Plataformas:
|
||||
// Linux/macOS: terminal_panel_linux.cpp (forkpty + read no-blocking en thread)
|
||||
// Windows: terminal_panel_windows.cpp (ConPTY CreatePseudoConsole)
|
||||
|
||||
#include "core/ansi_parser.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace fn_term {
|
||||
|
||||
// Una linea del scrollback: vector de celdas ya parseadas.
|
||||
using TermLine = std::vector<AnsiCell>;
|
||||
|
||||
// Configuracion y estado del panel.
|
||||
struct TerminalPanel {
|
||||
// --- Config (set antes de open(), no cambiar en vivo) ---
|
||||
std::string shell; // "" → auto-detect (/bin/bash linux, cmd.exe windows)
|
||||
std::string cwd; // "" → directorio actual del proceso padre
|
||||
std::vector<std::string> env; // KEY=VAL adicionales al entorno heredado
|
||||
int scrollback_lines = 5000; // max filas en el ring buffer
|
||||
bool readonly = false; // si true, no reenvía input del teclado
|
||||
|
||||
// --- Estado interno (gestionado por open/close/render) ---
|
||||
// No modificar directamente.
|
||||
|
||||
// Proceso hijo
|
||||
int child_pid = -1; // Linux: PID del hijo; -1 si no abierto
|
||||
int master_fd = -1; // Linux: fd del extremo master del PTY
|
||||
void* proc_handle = nullptr; // Windows: HANDLE del proceso hijo (HANDLE)
|
||||
void* pty_handle = nullptr; // Windows: HPCON (ConPTY handle)
|
||||
void* pipe_read = nullptr; // Windows: HANDLE pipe de lectura
|
||||
void* pipe_write = nullptr; // Windows: HANDLE pipe de escritura (→ stdin del hijo)
|
||||
|
||||
// Reader thread
|
||||
std::thread reader_thread;
|
||||
std::atomic<bool> reader_running{false};
|
||||
|
||||
// Scrollback buffer (protegido por mutex)
|
||||
mutable std::mutex buf_mutex;
|
||||
std::vector<TermLine> lines; // buffer circular de lineas
|
||||
int cur_row = 0; // fila del cursor dentro de `lines`
|
||||
int cur_col = 0; // columna del cursor
|
||||
bool scroll_to_bottom = true;
|
||||
|
||||
// Parser ANSI (solo lo toca el reader thread)
|
||||
AnsiParser parser;
|
||||
|
||||
// Flag: proceso hijo terminó
|
||||
std::atomic<bool> process_exited{false};
|
||||
int exit_code = 0;
|
||||
|
||||
// ctor/dtor
|
||||
TerminalPanel();
|
||||
~TerminalPanel();
|
||||
TerminalPanel(const TerminalPanel&) = delete;
|
||||
TerminalPanel& operator=(const TerminalPanel&) = delete;
|
||||
|
||||
bool is_open() const { return master_fd >= 0 || pipe_read != nullptr; }
|
||||
};
|
||||
|
||||
// Abre el proceso hijo y arranca el reader thread.
|
||||
// Llama una sola vez antes del primer render.
|
||||
// Si falla, loguea via fn_log::log_error y deja is_open() == false.
|
||||
void open(TerminalPanel& panel);
|
||||
|
||||
// Renderiza el terminal en el area disponible de ImGui.
|
||||
// Debe llamarse dentro de un frame ImGui activo.
|
||||
// Dibuja toolbar (clear, copy, reset, scroll-lock) + scrollback + input.
|
||||
void render(TerminalPanel& panel);
|
||||
|
||||
// Envía texto al stdin del proceso hijo.
|
||||
// No-op si !is_open() o readonly.
|
||||
void send(TerminalPanel& panel, const std::string& text);
|
||||
|
||||
// Cierra el proceso hijo, espera al reader thread y libera recursos.
|
||||
void close(TerminalPanel& panel);
|
||||
|
||||
// ---- Internals usados por los backends Linux/Windows ----
|
||||
// (No llamar directamente desde apps.)
|
||||
|
||||
// Procesa un chunk de bytes del PTY y los añade al scrollback.
|
||||
// Llamado desde el reader thread. Thread-safe via buf_mutex.
|
||||
void process_output(TerminalPanel& panel, const char* data, size_t n);
|
||||
|
||||
} // namespace fn_term
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: terminal_panel
|
||||
kind: component
|
||||
lang: cpp
|
||||
domain: viz
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "void fn_term::open(fn_term::TerminalPanel& panel); void fn_term::render(fn_term::TerminalPanel& panel); void fn_term::send(fn_term::TerminalPanel& panel, const std::string& text); void fn_term::close(fn_term::TerminalPanel& panel);"
|
||||
description: "Emulador TTY embebible en ImGui. Arranca un proceso hijo via PTY (Linux: forkpty) o ConPTY (Windows 10 v1809+), renderiza el scrollback con colores ANSI 16-color, toolbar (clear/copy/reset/scroll-lock) e input box. Scrollback circular configurable. Soporte readonly para tail-only."
|
||||
tags: [terminal, pty, conpty, imgui, viz, ansi, shell, cpp-dashboard-viz]
|
||||
uses_functions: [ansi_parser_cpp_core, logger_cpp_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [atomic, functional, mutex, string, thread, vector]
|
||||
tested: true
|
||||
tests:
|
||||
- "smoke: spawn echo hello and exit, scrollback contains hello"
|
||||
test_file_path: "cpp/tests/test_terminal_panel_smoke.cpp"
|
||||
file_path: "cpp/functions/viz/terminal_panel/terminal_panel.cpp"
|
||||
framework: imgui
|
||||
params:
|
||||
- name: panel
|
||||
desc: "Struct TerminalPanel con config (shell, cwd, env, scrollback_lines, readonly) y estado interno gestionado por open/close/render"
|
||||
output: "render() dibuja toolbar + scrollback con colores ANSI + input box en el area ImGui disponible. open() arranca el proceso hijo y el reader thread. send() escribe texto al stdin del hijo. close() mata el proceso y libera recursos."
|
||||
notes: "Linux: requiere -lutil (libutil) para forkpty. Windows: requiere Windows SDK >= 17763 (v1809) para ConPTY. Si el SDK es anterior, open() loguea error y deja is_open()==false. Anti-scope v1: sin tabs multiples, sin SSH, sin curses pesados (vim/htop)."
|
||||
---
|
||||
|
||||
# terminal_panel
|
||||
|
||||
Emulador TTY embebible en ImGui. Util para: tail de logs en una app de monitoring, ejecutar comandos shell desde un panel de kanban, ver output de compilaciones, consola de debug de agentes.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```cpp
|
||||
#include "viz/terminal_panel/terminal_panel.h"
|
||||
|
||||
static fn_term::TerminalPanel s_term;
|
||||
|
||||
void render_panel() {
|
||||
// Abrir al primer frame.
|
||||
if (!s_term.is_open()) {
|
||||
s_term.shell = "/bin/bash";
|
||||
s_term.scrollback_lines = 2000;
|
||||
fn_term::open(s_term);
|
||||
}
|
||||
fn_term::render(s_term);
|
||||
}
|
||||
|
||||
// Tail readonly de un log:
|
||||
static fn_term::TerminalPanel s_log_tail;
|
||||
|
||||
void render_log_tail() {
|
||||
if (!s_log_tail.is_open()) {
|
||||
s_log_tail.shell = "/bin/bash";
|
||||
s_log_tail.readonly = true;
|
||||
fn_term::open(s_log_tail);
|
||||
fn_term::send(s_log_tail, "tail -f /tmp/agent.log\n");
|
||||
}
|
||||
fn_term::render(s_log_tail);
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas ver output crudo de un proceso (shell, compilacion, curl, tail) sin salir de la app ImGui. Alternativa a abrir un terminal externo. Especialmente util en apps de monitoring (services_monitor, agents_dashboard) y kanban panels de build.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Linux**: el CMakeLists del consumidor debe linkar `-lutil` (o `target_link_libraries(... util)`) para resolver `forkpty`.
|
||||
- **Windows**: requiere Windows 10 v1809+ (SDK >= 17763). Si el SDK es anterior, `open()` deja el panel cerrado y loguea error — no hay panic ni crash.
|
||||
- **Anti-scope v1**: sin soporte de curses pesados (vim, htop, top). El parser ANSI maneja SGR color + cursor básico; programas que usen el modo altscreen o muchas secuencias de cursor se verán mal.
|
||||
- **Scrollback circular**: cuando `lines.size() > scrollback_lines`, se elimina la primera fila. Esto puede causar saltos visuales si el contenido se está acumulando muy rápido (ej. `yes "x"`). En v1 el target es 60fps con scrollback de 5000 líneas.
|
||||
- **Thread safety**: `render()` toma el `buf_mutex` por el tiempo del render de cada frame. El reader thread también lo toma al actualizar el buffer. En condiciones normales no hay contención significativa.
|
||||
- **readonly**: si `true`, no se renderiza el input box y `send()` es no-op. Útil para `tail -f` o procesos que no necesitan stdin.
|
||||
@@ -0,0 +1,180 @@
|
||||
// terminal_panel_linux.cpp — backend PTY para Linux/macOS.
|
||||
// Compilado solo en plataformas no-Windows.
|
||||
//
|
||||
// Implementacion: forkpty() crea el proceso hijo con un PTY maestro/esclavo.
|
||||
// Un thread de lectura en background lee del fd maestro de forma no-bloqueante
|
||||
// y llama process_output() para actualizar el scrollback buffer.
|
||||
|
||||
#ifndef _WIN32
|
||||
|
||||
#include "viz/terminal_panel/terminal_panel.h"
|
||||
#include "core/logger.h"
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <cstdlib>
|
||||
#include <fcntl.h>
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
#include <pty.h> // forkpty — requiere -lutil en Linux
|
||||
|
||||
namespace fn_term {
|
||||
|
||||
namespace {
|
||||
|
||||
// Detecta el shell por defecto: $SHELL o /bin/bash como fallback.
|
||||
std::string default_shell() {
|
||||
const char* sh = std::getenv("SHELL");
|
||||
return sh ? sh : "/bin/bash";
|
||||
}
|
||||
|
||||
// Thread de lectura: lee del fd maestro del PTY en bloques y
|
||||
// llama process_output. Termina cuando el proceso hijo cierra el PTY
|
||||
// (read devuelve 0 o EIO) o cuando reader_running se pone a false.
|
||||
void reader_thread_fn(TerminalPanel* panel) {
|
||||
char buf[4096];
|
||||
while (panel->reader_running.load()) {
|
||||
ssize_t n = ::read(panel->master_fd, buf, sizeof(buf));
|
||||
if (n > 0) {
|
||||
process_output(*panel, buf, static_cast<size_t>(n));
|
||||
} else if (n == 0) {
|
||||
// EOF: el proceso hijo cerró el PTY.
|
||||
break;
|
||||
} else {
|
||||
// EIO ocurre cuando el proceso hijo sale y cierra el esclavo.
|
||||
if (errno == EIO || errno == EBADF) break;
|
||||
if (errno == EINTR) continue;
|
||||
// Otro error transitorio: esperar un poco y reintentar.
|
||||
usleep(5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Recolectar el código de salida del hijo.
|
||||
if (panel->child_pid > 0) {
|
||||
int status = 0;
|
||||
::waitpid(panel->child_pid, &status, WNOHANG);
|
||||
if (WIFEXITED(status))
|
||||
panel->exit_code = WEXITSTATUS(status);
|
||||
else if (WIFSIGNALED(status))
|
||||
panel->exit_code = -WTERMSIG(status);
|
||||
}
|
||||
panel->process_exited.store(true);
|
||||
panel->reader_running.store(false);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void open(TerminalPanel& panel) {
|
||||
if (panel.is_open()) return;
|
||||
|
||||
std::string sh = panel.shell.empty() ? default_shell() : panel.shell;
|
||||
|
||||
// Construir argv.
|
||||
const char* argv[] = {sh.c_str(), nullptr};
|
||||
|
||||
// Construir envp: heredar entorno + extras.
|
||||
// Para simplicidad en v1, pasamos nullptr (hereda el entorno completo)
|
||||
// y añadimos las variables extra via setenv antes del fork.
|
||||
// TODO(0132): construir envp completo en v2.
|
||||
|
||||
struct winsize ws;
|
||||
ws.ws_row = 24;
|
||||
ws.ws_col = 80;
|
||||
ws.ws_xpixel = 0;
|
||||
ws.ws_ypixel = 0;
|
||||
|
||||
int master_fd = -1;
|
||||
pid_t pid = forkpty(&master_fd, nullptr, nullptr, &ws);
|
||||
|
||||
if (pid < 0) {
|
||||
fn_log::log_error("terminal_panel: forkpty failed: %s", strerror(errno));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pid == 0) {
|
||||
// Proceso hijo.
|
||||
// Aplicar variables de entorno extra.
|
||||
for (const auto& kv : panel.env) {
|
||||
const auto eq = kv.find('=');
|
||||
if (eq != std::string::npos) {
|
||||
std::string key = kv.substr(0, eq);
|
||||
std::string val = kv.substr(eq + 1);
|
||||
::setenv(key.c_str(), val.c_str(), 1);
|
||||
}
|
||||
}
|
||||
// Cambiar directorio de trabajo si se especificó.
|
||||
if (!panel.cwd.empty()) {
|
||||
if (::chdir(panel.cwd.c_str()) != 0) {
|
||||
// No es fatal — continuar desde el cwd heredado.
|
||||
}
|
||||
}
|
||||
::execvp(sh.c_str(), const_cast<char* const*>(argv));
|
||||
// Si execvp falla, el hijo muere.
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
// Proceso padre.
|
||||
// Poner el fd maestro en modo no-bloqueante.
|
||||
int flags = ::fcntl(master_fd, F_GETFL, 0);
|
||||
::fcntl(master_fd, F_SETFL, flags | O_NONBLOCK);
|
||||
|
||||
panel.master_fd = master_fd;
|
||||
panel.child_pid = pid;
|
||||
panel.process_exited.store(false);
|
||||
panel.reader_running.store(true);
|
||||
panel.reader_thread = std::thread(reader_thread_fn, &panel);
|
||||
|
||||
fn_log::log_info("terminal_panel: opened shell '%s' pid=%d", sh.c_str(), pid);
|
||||
}
|
||||
|
||||
void send(TerminalPanel& panel, const std::string& text) {
|
||||
if (!panel.is_open() || panel.readonly) return;
|
||||
if (text.empty()) return;
|
||||
const char* p = text.c_str();
|
||||
ssize_t rem = static_cast<ssize_t>(text.size());
|
||||
while (rem > 0) {
|
||||
ssize_t n = ::write(panel.master_fd, p, static_cast<size_t>(rem));
|
||||
if (n <= 0) {
|
||||
if (errno == EINTR) continue;
|
||||
fn_log::log_error("terminal_panel: write to pty failed: %s", strerror(errno));
|
||||
break;
|
||||
}
|
||||
p += n;
|
||||
rem -= n;
|
||||
}
|
||||
}
|
||||
|
||||
void close(TerminalPanel& panel) {
|
||||
// Señalar al reader thread que pare.
|
||||
panel.reader_running.store(false);
|
||||
|
||||
// Cerrar el fd maestro del PTY; esto hace que el hijo reciba HUP.
|
||||
if (panel.master_fd >= 0) {
|
||||
::close(panel.master_fd);
|
||||
panel.master_fd = -1;
|
||||
}
|
||||
|
||||
// Matar al hijo si sigue vivo.
|
||||
if (panel.child_pid > 0) {
|
||||
::kill(panel.child_pid, SIGTERM);
|
||||
int status = 0;
|
||||
// Esperar hasta 200 ms; si no terminó, SIGKILL.
|
||||
for (int i = 0; i < 20; i++) {
|
||||
if (::waitpid(panel.child_pid, &status, WNOHANG) > 0) break;
|
||||
usleep(10000);
|
||||
}
|
||||
::kill(panel.child_pid, SIGKILL);
|
||||
::waitpid(panel.child_pid, &status, 0);
|
||||
panel.child_pid = -1;
|
||||
}
|
||||
|
||||
// Esperar al reader thread.
|
||||
if (panel.reader_thread.joinable()) panel.reader_thread.join();
|
||||
|
||||
fn_log::log_info("terminal_panel: closed");
|
||||
}
|
||||
|
||||
} // namespace fn_term
|
||||
|
||||
#endif // !_WIN32
|
||||
@@ -0,0 +1,244 @@
|
||||
// terminal_panel_windows.cpp — backend ConPTY para Windows.
|
||||
// Compilado solo en plataformas Windows (_WIN32).
|
||||
//
|
||||
// Implementacion: CreatePseudoConsole (ConPTY, Windows 10 v1809+) +
|
||||
// CreateProcess + ReadFile en thread de lectura.
|
||||
//
|
||||
// Si ConPTY no está disponible (Windows < 10 v1809), cae a un stub que
|
||||
// reporta error y deja is_open() == false.
|
||||
//
|
||||
// TODO(0132): fallback CreatePipe sin PTY para Windows < v1809.
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include "viz/terminal_panel/terminal_panel.h"
|
||||
#include "core/logger.h"
|
||||
|
||||
// Incluir Windows.h con defines minimos para evitar conflictos con ImGui.
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <windows.h>
|
||||
|
||||
// ConPTY: disponible en Windows SDK >= 17763 (v1809).
|
||||
// Si el SDK no tiene ConPTY, definimos stubs minimos para que compile.
|
||||
#if defined(NTDDI_WIN10_RS5) && NTDDI_VERSION >= NTDDI_WIN10_RS5
|
||||
# define FN_CONPTY_AVAILABLE 1
|
||||
# include <consoleapi3.h>
|
||||
# include <processthreadsapi.h>
|
||||
#else
|
||||
# define FN_CONPTY_AVAILABLE 0
|
||||
// Stub para evitar errores de compilacion en SDKs viejos.
|
||||
typedef VOID* HPCON;
|
||||
#endif
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace fn_term {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string default_shell_windows() {
|
||||
// Preferir PowerShell si está disponible; fallback a cmd.exe.
|
||||
char buf[MAX_PATH] = {};
|
||||
if (ExpandEnvironmentStringsA("%COMSPEC%", buf, sizeof(buf)) > 0 && buf[0] != '\0')
|
||||
return buf;
|
||||
return "cmd.exe";
|
||||
}
|
||||
|
||||
#if FN_CONPTY_AVAILABLE
|
||||
|
||||
// Thread de lectura: lee del pipe de salida del ConPTY en bloques.
|
||||
DWORD WINAPI reader_thread_fn(LPVOID param) {
|
||||
auto* panel = static_cast<TerminalPanel*>(param);
|
||||
char buf[4096];
|
||||
DWORD bytes_read = 0;
|
||||
while (panel->reader_running.load()) {
|
||||
BOOL ok = ReadFile(static_cast<HANDLE>(panel->pipe_read),
|
||||
buf, sizeof(buf), &bytes_read, nullptr);
|
||||
if (ok && bytes_read > 0) {
|
||||
process_output(*panel, buf, static_cast<size_t>(bytes_read));
|
||||
} else {
|
||||
DWORD err = GetLastError();
|
||||
if (err == ERROR_BROKEN_PIPE || err == ERROR_NO_DATA) break;
|
||||
if (!ok) {
|
||||
fn_log::log_error("terminal_panel: ReadFile error %lu", err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recolectar código de salida.
|
||||
if (panel->proc_handle) {
|
||||
DWORD exit_code = 0;
|
||||
GetExitCodeProcess(static_cast<HANDLE>(panel->proc_handle), &exit_code);
|
||||
panel->exit_code = static_cast<int>(exit_code);
|
||||
}
|
||||
panel->process_exited.store(true);
|
||||
panel->reader_running.store(false);
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif // FN_CONPTY_AVAILABLE
|
||||
|
||||
} // namespace
|
||||
|
||||
void open(TerminalPanel& panel) {
|
||||
if (panel.is_open()) return;
|
||||
|
||||
#if !FN_CONPTY_AVAILABLE
|
||||
fn_log::log_error("terminal_panel: ConPTY not available on this Windows SDK version");
|
||||
// TODO(0132): fallback a CreatePipe sin PTY
|
||||
return;
|
||||
#else
|
||||
std::string sh = panel.shell.empty() ? default_shell_windows() : panel.shell;
|
||||
|
||||
// Crear dos pares de pipes: una para PTY→app (lectura) y otra para app→PTY (escritura).
|
||||
HANDLE hPipeIn_Read = nullptr; // PTY lee desde aqui (stdin del proceso hijo)
|
||||
HANDLE hPipeIn_Write = nullptr; // app escribe aqui
|
||||
HANDLE hPipeOut_Read = nullptr; // app lee desde aqui (stdout del proceso hijo)
|
||||
HANDLE hPipeOut_Write= nullptr; // PTY escribe aqui
|
||||
|
||||
SECURITY_ATTRIBUTES sa;
|
||||
sa.nLength = sizeof(SECURITY_ATTRIBUTES);
|
||||
sa.bInheritHandle = FALSE;
|
||||
sa.lpSecurityDescriptor = nullptr;
|
||||
|
||||
if (!CreatePipe(&hPipeIn_Read, &hPipeIn_Write, &sa, 0) ||
|
||||
!CreatePipe(&hPipeOut_Read, &hPipeOut_Write, &sa, 0)) {
|
||||
fn_log::log_error("terminal_panel: CreatePipe failed: %lu", GetLastError());
|
||||
return;
|
||||
}
|
||||
|
||||
// Crear ConPTY.
|
||||
COORD consoleSize;
|
||||
consoleSize.X = 80;
|
||||
consoleSize.Y = 24;
|
||||
HPCON hPC = nullptr;
|
||||
HRESULT hr = CreatePseudoConsole(consoleSize, hPipeIn_Read, hPipeOut_Write, 0, &hPC);
|
||||
if (FAILED(hr)) {
|
||||
fn_log::log_error("terminal_panel: CreatePseudoConsole failed: hr=0x%08lX", hr);
|
||||
CloseHandle(hPipeIn_Read);
|
||||
CloseHandle(hPipeIn_Write);
|
||||
CloseHandle(hPipeOut_Read);
|
||||
CloseHandle(hPipeOut_Write);
|
||||
return;
|
||||
}
|
||||
|
||||
// Los extremos del ConPTY (hPipeIn_Read + hPipeOut_Write) ya no los necesitamos.
|
||||
CloseHandle(hPipeIn_Read);
|
||||
CloseHandle(hPipeOut_Write);
|
||||
|
||||
// Preparar STARTUPINFOEX con el ConPTY.
|
||||
SIZE_T attrListSize = 0;
|
||||
InitializeProcThreadAttributeList(nullptr, 1, 0, &attrListSize);
|
||||
auto* attrList = static_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(
|
||||
HeapAlloc(GetProcessHeap(), 0, attrListSize));
|
||||
if (!attrList || !InitializeProcThreadAttributeList(attrList, 1, 0, &attrListSize)) {
|
||||
fn_log::log_error("terminal_panel: InitializeProcThreadAttributeList failed");
|
||||
ClosePseudoConsole(hPC);
|
||||
CloseHandle(hPipeIn_Write);
|
||||
CloseHandle(hPipeOut_Read);
|
||||
if (attrList) HeapFree(GetProcessHeap(), 0, attrList);
|
||||
return;
|
||||
}
|
||||
UpdateProcThreadAttribute(attrList, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
|
||||
hPC, sizeof(HPCON), nullptr, nullptr);
|
||||
|
||||
STARTUPINFOEXA siEx = {};
|
||||
siEx.StartupInfo.cb = sizeof(STARTUPINFOEXA);
|
||||
siEx.lpAttributeList = attrList;
|
||||
|
||||
PROCESS_INFORMATION pi = {};
|
||||
// cmd es la cadena de comando (mutable, CreateProcessA la modifica en algunos casos).
|
||||
std::string cmd = sh;
|
||||
if (!CreateProcessA(nullptr, &cmd[0], nullptr, nullptr, FALSE,
|
||||
EXTENDED_STARTUPINFO_PRESENT, nullptr,
|
||||
panel.cwd.empty() ? nullptr : panel.cwd.c_str(),
|
||||
&siEx.StartupInfo, &pi)) {
|
||||
fn_log::log_error("terminal_panel: CreateProcess failed: %lu", GetLastError());
|
||||
DeleteProcThreadAttributeList(attrList);
|
||||
HeapFree(GetProcessHeap(), 0, attrList);
|
||||
ClosePseudoConsole(hPC);
|
||||
CloseHandle(hPipeIn_Write);
|
||||
CloseHandle(hPipeOut_Read);
|
||||
return;
|
||||
}
|
||||
|
||||
// El thread handle del hijo no lo necesitamos.
|
||||
CloseHandle(pi.hThread);
|
||||
|
||||
DeleteProcThreadAttributeList(attrList);
|
||||
HeapFree(GetProcessHeap(), 0, attrList);
|
||||
|
||||
panel.pty_handle = static_cast<void*>(hPC);
|
||||
panel.pipe_read = static_cast<void*>(hPipeOut_Read);
|
||||
panel.pipe_write = static_cast<void*>(hPipeIn_Write);
|
||||
panel.proc_handle = static_cast<void*>(pi.hProcess);
|
||||
panel.process_exited.store(false);
|
||||
panel.reader_running.store(true);
|
||||
|
||||
// Arrancar el reader thread via CreateThread (evitamos std::thread con WINAPI).
|
||||
HANDLE hThread = CreateThread(nullptr, 0, reader_thread_fn, &panel, 0, nullptr);
|
||||
if (!hThread) {
|
||||
fn_log::log_error("terminal_panel: CreateThread failed: %lu", GetLastError());
|
||||
// No fatal — el panel queda en estado parcial; close() limpiará.
|
||||
} else {
|
||||
// Convertir el HANDLE a std::thread via native_handle trick no es portable.
|
||||
// Para integración con std::thread::join(), usamos un wrapper.
|
||||
// En v1: detachamos el thread y usamos el atomic reader_running como señal.
|
||||
CloseHandle(hThread);
|
||||
// TODO(0132): migrar a std::thread para poder join() correctamente.
|
||||
}
|
||||
|
||||
fn_log::log_info("terminal_panel: opened shell '%s' pid=%lu",
|
||||
sh.c_str(), static_cast<unsigned long>(pi.dwProcessId));
|
||||
#endif // FN_CONPTY_AVAILABLE
|
||||
}
|
||||
|
||||
void send(TerminalPanel& panel, const std::string& text) {
|
||||
#if !FN_CONPTY_AVAILABLE
|
||||
(void)panel; (void)text;
|
||||
#else
|
||||
if (!panel.is_open() || panel.readonly || text.empty()) return;
|
||||
DWORD written = 0;
|
||||
WriteFile(static_cast<HANDLE>(panel.pipe_write),
|
||||
text.c_str(), static_cast<DWORD>(text.size()), &written, nullptr);
|
||||
#endif
|
||||
}
|
||||
|
||||
void close(TerminalPanel& panel) {
|
||||
panel.reader_running.store(false);
|
||||
|
||||
#if FN_CONPTY_AVAILABLE
|
||||
if (panel.pipe_write) {
|
||||
CloseHandle(static_cast<HANDLE>(panel.pipe_write));
|
||||
panel.pipe_write = nullptr;
|
||||
}
|
||||
if (panel.pipe_read) {
|
||||
CloseHandle(static_cast<HANDLE>(panel.pipe_read));
|
||||
panel.pipe_read = nullptr;
|
||||
}
|
||||
if (panel.proc_handle) {
|
||||
TerminateProcess(static_cast<HANDLE>(panel.proc_handle), 0);
|
||||
WaitForSingleObject(static_cast<HANDLE>(panel.proc_handle), 500);
|
||||
CloseHandle(static_cast<HANDLE>(panel.proc_handle));
|
||||
panel.proc_handle = nullptr;
|
||||
}
|
||||
if (panel.pty_handle) {
|
||||
ClosePseudoConsole(static_cast<HPCON>(panel.pty_handle));
|
||||
panel.pty_handle = nullptr;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Esperar al reader thread si está joinable.
|
||||
if (panel.reader_thread.joinable()) panel.reader_thread.join();
|
||||
|
||||
fn_log::log_info("terminal_panel: closed (windows)");
|
||||
}
|
||||
|
||||
} // namespace fn_term
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -316,3 +316,27 @@ add_fn_test(test_agent_runs_timeline test_agent_runs_timeline.cpp
|
||||
add_fn_test(test_sse_client test_sse_client.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/sse_client.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/http_request.cpp)
|
||||
|
||||
# --- gltf_load_mesh: GLB 2.0 parser puro (CPU, sin GL) ---
|
||||
# Incluimos nlohmann desde cpp/vendor/. El parser no necesita GL ni imgui.
|
||||
add_fn_test(test_gltf_load_mesh test_gltf_load_mesh.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/gfx/gltf_load_mesh.cpp)
|
||||
target_include_directories(test_gltf_load_mesh PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../vendor)
|
||||
|
||||
# --- Issue 0132 — ansi_parser: logica pura, sin ImGui ---
|
||||
add_fn_test(test_ansi_parser test_ansi_parser.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/ansi_parser.cpp)
|
||||
|
||||
# --- Issue 0132 — terminal_panel smoke: spawn real PTY (Linux only) ---
|
||||
# En Windows: todos los casos se skipean via SKIP(). En Linux necesita -lutil.
|
||||
# Linkamos fn_framework para obtener logger.cpp (fn_log) + imgui + implot.
|
||||
add_fn_test(test_terminal_panel_smoke test_terminal_panel_smoke.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/ansi_parser.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/terminal_panel/terminal_panel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/terminal_panel/terminal_panel_linux.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/terminal_panel/terminal_panel_windows.cpp)
|
||||
target_link_libraries(test_terminal_panel_smoke PRIVATE fn_framework)
|
||||
if(NOT WIN32)
|
||||
target_link_libraries(test_terminal_panel_smoke PRIVATE util)
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""E2E tests for terminal_panel demos in primitives_gallery.
|
||||
|
||||
Lanza primitives_gallery en modo --capture, captura el demo "terminal_panel"
|
||||
como PNG y verifica que la region del terminal tiene fondo oscuro (fix del
|
||||
issue 0132: fondo negro + prompt input).
|
||||
|
||||
Uso desde la raiz del registry:
|
||||
python/.venv/bin/python3 -m pytest cpp/tests/e2e/test_terminal_panel_e2e.py -v
|
||||
|
||||
Requisitos:
|
||||
- primitives_gallery compilado (Linux o Windows .exe).
|
||||
- WSL2 con interop habilitado para el path Windows.
|
||||
- Pillow instalado en el venv del registry (python/.venv).
|
||||
|
||||
En entornos sin GL (CI headless), el binario sale != 0 y el test se skipea
|
||||
automaticamente (SKIP, no FAIL).
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers de localizacion del binario
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
REGISTRY_ROOT = Path(__file__).resolve().parents[3] # fn_registry/
|
||||
|
||||
|
||||
def _find_binary() -> Path | None:
|
||||
"""Devuelve el primer primitives_gallery encontrado (Linux o Windows)."""
|
||||
# Paths fijos conocidos primero.
|
||||
candidates = [
|
||||
REGISTRY_ROOT / "cpp" / "build" / "apps" / "primitives_gallery" / "primitives_gallery",
|
||||
REGISTRY_ROOT / "cpp" / "build" / "linux" / "apps" / "primitives_gallery" / "primitives_gallery",
|
||||
REGISTRY_ROOT / "cpp" / "build" / "windows" / "apps" / "primitives_gallery" / "primitives_gallery.exe",
|
||||
# Desktop de Windows (deploy anterior)
|
||||
Path("/mnt/c/Users/lucas/Desktop/apps/primitives_gallery/primitives_gallery.exe"),
|
||||
]
|
||||
for p in candidates:
|
||||
if p.exists():
|
||||
return p
|
||||
# Busqueda amplia como fallback.
|
||||
for pattern in ("primitives_gallery", "primitives_gallery.exe"):
|
||||
for found in (REGISTRY_ROOT / "cpp" / "build").rglob(pattern):
|
||||
if found.is_file():
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture: captura PNG del demo terminal_panel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def terminal_png(tmp_path_factory) -> Path:
|
||||
"""Lanza primitives_gallery --capture y devuelve el PNG generado."""
|
||||
binary = _find_binary()
|
||||
if binary is None:
|
||||
pytest.skip("primitives_gallery binary not found — build it first")
|
||||
|
||||
out_dir = tmp_path_factory.mktemp("terminal_capture")
|
||||
|
||||
# En WSL, un .exe Windows necesita invocarse como proceso Windows.
|
||||
# En Linux, se invoca directamente con LIBGL_ALWAYS_SOFTWARE=1.
|
||||
env = os.environ.copy()
|
||||
is_windows_exe = binary.suffix == ".exe"
|
||||
|
||||
if is_windows_exe:
|
||||
# Convertir el out_dir a path Windows via wslpath.
|
||||
wslpath_result = subprocess.run(
|
||||
["wslpath", "-w", str(out_dir)],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if wslpath_result.returncode != 0:
|
||||
pytest.skip("wslpath not available — can't convert path for Windows exe")
|
||||
win_out_dir = wslpath_result.stdout.strip()
|
||||
cmd = [str(binary), "--capture", win_out_dir]
|
||||
else:
|
||||
env["LIBGL_ALWAYS_SOFTWARE"] = "1"
|
||||
cmd = [str(binary), "--capture", str(out_dir)]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
cwd=str(REGISTRY_ROOT),
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
# Sin GL o sin display — skip en lugar de FAIL.
|
||||
pytest.skip(
|
||||
f"primitives_gallery --capture exited {result.returncode} "
|
||||
f"(no GL context?). stdout: {result.stdout[-200:]} "
|
||||
f"stderr: {result.stderr[-200:]}"
|
||||
)
|
||||
|
||||
png_path = out_dir / "terminal_panel.png"
|
||||
if not png_path.exists():
|
||||
pytest.skip(f"terminal_panel.png not generated in {out_dir}. "
|
||||
f"stdout: {result.stdout[-300:]}")
|
||||
|
||||
return png_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_terminal_panel_png_exists(terminal_png: Path):
|
||||
"""El PNG del demo terminal_panel debe existir despues del capture."""
|
||||
assert terminal_png.exists(), f"PNG not found: {terminal_png}"
|
||||
assert terminal_png.stat().st_size > 1000, "PNG sospechosamente pequeño"
|
||||
|
||||
|
||||
def test_terminal_panel_not_all_white(terminal_png: Path):
|
||||
"""La imagen no debe ser completamente blanca (render vacio)."""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
pytest.skip("Pillow not installed — run: pip install Pillow")
|
||||
|
||||
img = Image.open(terminal_png).convert("RGB")
|
||||
px = img.load()
|
||||
w, h = img.size
|
||||
total = w * h
|
||||
white_count = sum(
|
||||
1
|
||||
for y in range(h)
|
||||
for x in range(w)
|
||||
if px[x, y][0] > 240 and px[x, y][1] > 240 and px[x, y][2] > 240 # type: ignore[index]
|
||||
)
|
||||
white_ratio = white_count / total
|
||||
|
||||
assert white_ratio < 0.95, (
|
||||
f"Image is {white_ratio:.1%} white — terminal render likely failed. "
|
||||
f"({terminal_png})"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_panel_dark_background(terminal_png: Path):
|
||||
"""La region central del terminal debe ser mayormente oscura (fondo negro fix 0132)."""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
pytest.skip("Pillow not installed — run: pip install Pillow")
|
||||
|
||||
img = Image.open(terminal_png).convert("RGB")
|
||||
w, h = img.size
|
||||
|
||||
# Recortar la region central-inferior (donde vive el scrollback del terminal).
|
||||
# El demo header ocupa ~15% superior; el resto deberia ser el area del terminal.
|
||||
# Ajustar: top=20%, bottom=85%, left=10%, right=90%.
|
||||
left = int(w * 0.10)
|
||||
right = int(w * 0.90)
|
||||
top = int(h * 0.20)
|
||||
bottom = int(h * 0.85)
|
||||
|
||||
region = img.crop((left, top, right, bottom))
|
||||
rw, rh = region.size
|
||||
total = rw * rh
|
||||
|
||||
if total == 0:
|
||||
pytest.skip("Crop region empty — image too small?")
|
||||
|
||||
rpx = region.load()
|
||||
# Pixel oscuro: todos los canales RGB < 60.
|
||||
dark_count = sum(
|
||||
1
|
||||
for y in range(rh)
|
||||
for x in range(rw)
|
||||
if rpx[x, y][0] < 60 and rpx[x, y][1] < 60 and rpx[x, y][2] < 60 # type: ignore[index]
|
||||
)
|
||||
dark_ratio = dark_count / total
|
||||
|
||||
assert dark_ratio >= 0.30, (
|
||||
f"Terminal region has only {dark_ratio:.1%} dark pixels (expected >= 30%). "
|
||||
f"The black background fix (issue 0132) may not be active. "
|
||||
f"Region: ({left},{top})-({right},{bottom}) in {w}x{h} image. "
|
||||
f"({terminal_png})"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_panel_has_light_text_on_dark(terminal_png: Path):
|
||||
"""Debe haber pixels claros (texto/toolbar) sobre fondo oscuro — render activo.
|
||||
|
||||
En modo --capture el PTY reader es async y puede no entregar output en los
|
||||
primeros frames. Verificamos que al menos la toolbar (Clear/Copy/Reset) y el
|
||||
borde del child tienen pixels no-negros (> 0.3% de la imagen total), lo que
|
||||
confirma que el panel se renderizo.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
pytest.skip("Pillow not installed — run: pip install Pillow")
|
||||
|
||||
img = Image.open(terminal_png).convert("RGB")
|
||||
pixels = img.load()
|
||||
w, h = img.size
|
||||
total = w * h
|
||||
|
||||
# Contar pixels con al menos un canal > 60 en toda la imagen.
|
||||
# Incluye la toolbar (botones), bordes, prompt "$ " y cualquier output.
|
||||
light_count = sum(
|
||||
1
|
||||
for y in range(h)
|
||||
for x in range(w)
|
||||
if max(pixels[x, y]) > 60 # type: ignore[index]
|
||||
)
|
||||
light_ratio = light_count / total
|
||||
|
||||
# Umbral conservador: > 0.3% — basta con que la toolbar sea visible.
|
||||
# En modo interactivo con PTY output el ratio sera mucho mayor (> 5%).
|
||||
assert light_ratio >= 0.003, (
|
||||
f"Image has only {light_ratio:.2%} non-dark pixels — "
|
||||
f"terminal panel may not be rendering at all. "
|
||||
f"Check that fn_term::render is called and ImGui window is visible. "
|
||||
f"({terminal_png})"
|
||||
)
|
||||
@@ -0,0 +1,215 @@
|
||||
// test_ansi_parser.cpp — tests unitarios para fn_term::AnsiParser.
|
||||
//
|
||||
// Logica pura: no requiere ImGui ni contexto GL. Cubre:
|
||||
// - SGR: reset, FG color, BG color, bright colors, bold
|
||||
// - Cursor moves: CUU/CUD/CUF/CUB, CUP
|
||||
// - ED(2) erase display, EL(2) erase line
|
||||
// - Texto normal + secuencias mixtas
|
||||
// - CR, LF, BS
|
||||
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch_amalgamated.hpp"
|
||||
|
||||
#include "core/ansi_parser.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace fn_term;
|
||||
|
||||
// Helper: parsea una cadena y colecta los eventos.
|
||||
static std::vector<AnsiEvent> parse(const std::string& s) {
|
||||
AnsiParser p;
|
||||
std::vector<AnsiEvent> evs;
|
||||
p.feed(s.c_str(), s.size(), [&](const AnsiEvent& ev) {
|
||||
evs.push_back(ev);
|
||||
});
|
||||
return evs;
|
||||
}
|
||||
|
||||
// Helper: obtiene estados SGR después de parsear (sin eventos de salida).
|
||||
struct SgrState { uint8_t fg; uint8_t bg; uint8_t bold; };
|
||||
static SgrState parse_sgr(const std::string& s) {
|
||||
AnsiParser p;
|
||||
p.feed(s.c_str(), s.size(), [](const AnsiEvent&) {});
|
||||
return {p.current_fg(), p.current_bg(), p.current_bold()};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SGR tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("SGR reset sets default colors", "[ansi_parser][sgr]") {
|
||||
// Primero ponemos FG rojo, luego reset.
|
||||
auto st = parse_sgr("\x1b[31m\x1b[0m");
|
||||
REQUIRE(st.fg == kColorDefault);
|
||||
REQUIRE(st.bg == kColorDefault);
|
||||
REQUIRE(st.bold == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("SGR fg color 31 sets red", "[ansi_parser][sgr]") {
|
||||
auto st = parse_sgr("\x1b[31m");
|
||||
REQUIRE(st.fg == 1); // rojo = index 1
|
||||
}
|
||||
|
||||
TEST_CASE("SGR bg color 44 sets blue background", "[ansi_parser][sgr]") {
|
||||
auto st = parse_sgr("\x1b[44m");
|
||||
REQUIRE(st.bg == 4); // azul = index 4
|
||||
}
|
||||
|
||||
TEST_CASE("SGR bright fg 91 sets bright red", "[ansi_parser][sgr]") {
|
||||
auto st = parse_sgr("\x1b[91m");
|
||||
REQUIRE(st.fg == 9); // bright red = index 8+1 = 9
|
||||
}
|
||||
|
||||
TEST_CASE("SGR bold sets bold flag", "[ansi_parser][sgr]") {
|
||||
auto st = parse_sgr("\x1b[1m");
|
||||
REQUIRE(st.bold == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("SGR reset via bare ESC[m", "[ansi_parser][sgr]") {
|
||||
// ESC [ m sin parametro = reset
|
||||
auto st = parse_sgr("\x1b[31m\x1b[m");
|
||||
REQUIRE(st.fg == kColorDefault);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor move tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("cursor CUU moves up N", "[ansi_parser][cursor]") {
|
||||
auto evs = parse("\x1b[3A");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::CursorMove);
|
||||
REQUIRE(evs[0].cursor_rel.dir == CursorDir::Up);
|
||||
REQUIRE(evs[0].cursor_rel.n == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("cursor CUF moves forward N", "[ansi_parser][cursor]") {
|
||||
auto evs = parse("\x1b[5C");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::CursorMove);
|
||||
REQUIRE(evs[0].cursor_rel.dir == CursorDir::Forward);
|
||||
REQUIRE(evs[0].cursor_rel.n == 5);
|
||||
}
|
||||
|
||||
TEST_CASE("cursor CUB moves back 1 when no param", "[ansi_parser][cursor]") {
|
||||
auto evs = parse("\x1b[D");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::CursorMove);
|
||||
REQUIRE(evs[0].cursor_rel.dir == CursorDir::Back);
|
||||
REQUIRE(evs[0].cursor_rel.n == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("cursor CUP absolute position", "[ansi_parser][cursor]") {
|
||||
// ESC[5;10H → row=4, col=9 (0-based)
|
||||
auto evs = parse("\x1b[5;10H");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::CursorAbsolute);
|
||||
REQUIRE(evs[0].cursor_abs.row == 4);
|
||||
REQUIRE(evs[0].cursor_abs.col == 9);
|
||||
}
|
||||
|
||||
TEST_CASE("cursor CUP default params (ESC[H) = origin", "[ansi_parser][cursor]") {
|
||||
auto evs = parse("\x1b[H");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::CursorAbsolute);
|
||||
REQUIRE(evs[0].cursor_abs.row == 0);
|
||||
REQUIRE(evs[0].cursor_abs.col == 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Erase tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("erase display ED 2", "[ansi_parser][erase]") {
|
||||
auto evs = parse("\x1b[2J");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::EraseDisplay);
|
||||
}
|
||||
|
||||
TEST_CASE("erase line EL 2", "[ansi_parser][erase]") {
|
||||
auto evs = parse("\x1b[2K");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::EraseLine);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Control chars
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("newline and carriage return", "[ansi_parser][control]") {
|
||||
auto evs = parse("\r\n");
|
||||
REQUIRE(evs.size() == 2);
|
||||
REQUIRE(evs[0].type == AnsiEventType::CarriageReturn);
|
||||
REQUIRE(evs[1].type == AnsiEventType::Newline);
|
||||
}
|
||||
|
||||
TEST_CASE("backspace emits Backspace event", "[ansi_parser][control]") {
|
||||
auto evs = parse("\x08");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::Backspace);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text + mixed sequences
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("plain text emits Char events", "[ansi_parser][text]") {
|
||||
auto evs = parse("hi");
|
||||
REQUIRE(evs.size() == 2);
|
||||
REQUIRE(evs[0].type == AnsiEventType::Char);
|
||||
REQUIRE(evs[0].cell.ch == U'h');
|
||||
REQUIRE(evs[1].cell.ch == U'i');
|
||||
}
|
||||
|
||||
TEST_CASE("mixed text and SGR sequence", "[ansi_parser][mixed]") {
|
||||
// "A" con FG rojo, luego reset, luego "B".
|
||||
auto evs = parse("\x1b[31mA\x1b[0mB");
|
||||
// Debemos tener exactamente 2 eventos Char: A (fg=1) y B (fg=default).
|
||||
REQUIRE(evs.size() == 2);
|
||||
REQUIRE(evs[0].type == AnsiEventType::Char);
|
||||
REQUIRE(evs[0].cell.ch == U'A');
|
||||
REQUIRE(evs[0].cell.fg == 1); // rojo
|
||||
REQUIRE(evs[1].type == AnsiEventType::Char);
|
||||
REQUIRE(evs[1].cell.ch == U'B');
|
||||
REQUIRE(evs[1].cell.fg == kColorDefault);
|
||||
}
|
||||
|
||||
TEST_CASE("char inherits current SGR attrs", "[ansi_parser][sgr]") {
|
||||
AnsiParser p;
|
||||
std::vector<AnsiEvent> evs;
|
||||
// Poner BG azul, luego emitir texto.
|
||||
std::string s = "\x1b[44mX";
|
||||
p.feed(s.c_str(), s.size(), [&](const AnsiEvent& ev) { evs.push_back(ev); });
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].cell.ch == U'X');
|
||||
REQUIRE(evs[0].cell.bg == 4); // azul
|
||||
}
|
||||
|
||||
TEST_CASE("unknown CSI final byte ignored silently", "[ansi_parser][robustness]") {
|
||||
// ESC [ Z es desconocido — no debe emitir nada ni crashear.
|
||||
auto evs = parse("a\x1b[Zb");
|
||||
REQUIRE(evs.size() == 2);
|
||||
REQUIRE(evs[0].cell.ch == U'a');
|
||||
REQUIRE(evs[1].cell.ch == U'b');
|
||||
}
|
||||
|
||||
TEST_CASE("incomplete escape at end of buffer", "[ansi_parser][robustness]") {
|
||||
// Buffer termina a mitad de una secuencia — no debe crashear.
|
||||
AnsiParser p;
|
||||
std::string s1 = "\x1b[3";
|
||||
std::string s2 = "1m";
|
||||
p.feed(s1.c_str(), s1.size(), [](const AnsiEvent&) {});
|
||||
p.feed(s2.c_str(), s2.size(), [](const AnsiEvent&) {});
|
||||
REQUIRE(p.current_fg() == 1); // FG rojo aplicado correctamente
|
||||
}
|
||||
|
||||
TEST_CASE("reset() clears state", "[ansi_parser][reset]") {
|
||||
AnsiParser p;
|
||||
std::string s = "\x1b[31m"; // FG rojo
|
||||
p.feed(s.c_str(), s.size(), [](const AnsiEvent&) {});
|
||||
REQUIRE(p.current_fg() == 1);
|
||||
p.reset();
|
||||
REQUIRE(p.current_fg() == kColorDefault);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Unit tests para gltf_load_mesh (issue: gltf_load_mesh_cpp_gfx).
|
||||
// Cubre: reject magic invalido, triangulo con POSITION+indices sin NORMAL
|
||||
// (normales generadas correctamente), quad (2 tris), load desde memoria.
|
||||
// No requiere contexto GL — logica CPU pura.
|
||||
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch_amalgamated.hpp"
|
||||
|
||||
#include "gfx/gltf_load_mesh.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GLB builder helpers (minimal — construye GLB en memoria para tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// nlohmann is in vendor/ but test includes functions/ and framework/ by default.
|
||||
// Build a GLB manually via byte helpers to avoid adding another include path.
|
||||
|
||||
static void write_u32le(std::vector<uint8_t>& buf, uint32_t v) {
|
||||
buf.push_back(v & 0xFF);
|
||||
buf.push_back((v >> 8) & 0xFF);
|
||||
buf.push_back((v >> 16) & 0xFF);
|
||||
buf.push_back((v >> 24) & 0xFF);
|
||||
}
|
||||
|
||||
static void write_f32le(std::vector<uint8_t>& buf, float v) {
|
||||
uint32_t u; std::memcpy(&u, &v, 4);
|
||||
write_u32le(buf, u);
|
||||
}
|
||||
|
||||
// Align 'buf' to 4-byte boundary by appending 0x20 (space) padding.
|
||||
static void align4(std::vector<uint8_t>& buf) {
|
||||
while (buf.size() % 4 != 0) buf.push_back(0x20);
|
||||
}
|
||||
|
||||
// Build a minimal GLB with:
|
||||
// positions: flat float xyz array (length = nv*3)
|
||||
// indices: uint16 array (length = ni)
|
||||
// normals: optional float xyz array (length = nv*3, nullptr = omit)
|
||||
// Returns the complete GLB byte vector.
|
||||
static std::vector<uint8_t> build_glb(const float* positions, size_t nv,
|
||||
const uint16_t* indices, size_t ni,
|
||||
const float* normals = nullptr) {
|
||||
// BIN chunk: positions | indices | (normals)
|
||||
std::vector<uint8_t> bin;
|
||||
size_t pos_offset = 0;
|
||||
size_t pos_byteLen = nv * 3 * 4;
|
||||
for (size_t i = 0; i < nv*3; ++i) write_f32le(bin, positions[i]);
|
||||
|
||||
// pad before indices so they start at 4-byte alignment
|
||||
while (bin.size() % 4 != 0) bin.push_back(0);
|
||||
size_t idx_offset = bin.size();
|
||||
size_t idx_byteLen = ni * 2;
|
||||
for (size_t i = 0; i < ni; ++i) {
|
||||
bin.push_back(indices[i] & 0xFF);
|
||||
bin.push_back((indices[i] >> 8) & 0xFF);
|
||||
}
|
||||
|
||||
size_t nrm_offset = 0, nrm_byteLen = 0;
|
||||
if (normals) {
|
||||
while (bin.size() % 4 != 0) bin.push_back(0);
|
||||
nrm_offset = bin.size();
|
||||
nrm_byteLen = nv * 3 * 4;
|
||||
for (size_t i = 0; i < nv*3; ++i) write_f32le(bin, normals[i]);
|
||||
}
|
||||
// GLB chunk length must be multiple of 4
|
||||
while (bin.size() % 4 != 0) bin.push_back(0);
|
||||
|
||||
// Build JSON
|
||||
// accessor 0: POSITION (vec3 float, bufferView 0)
|
||||
// accessor 1: indices (scalar uint16, bufferView 1)
|
||||
// accessor 2: NORMAL (vec3 float, bufferView 2) — if normals present
|
||||
std::string json = "{";
|
||||
json += "\"asset\":{\"version\":\"2.0\"},";
|
||||
json += "\"buffers\":[{\"byteLength\":" + std::to_string(bin.size()) + "}],";
|
||||
|
||||
// bufferViews
|
||||
json += "\"bufferViews\":[";
|
||||
json += "{\"buffer\":0,\"byteOffset\":" + std::to_string(pos_offset) +
|
||||
",\"byteLength\":" + std::to_string(pos_byteLen) + "}";
|
||||
json += ",{\"buffer\":0,\"byteOffset\":" + std::to_string(idx_offset) +
|
||||
",\"byteLength\":" + std::to_string(idx_byteLen) + "}";
|
||||
if (normals) {
|
||||
json += ",{\"buffer\":0,\"byteOffset\":" + std::to_string(nrm_offset) +
|
||||
",\"byteLength\":" + std::to_string(nrm_byteLen) + "}";
|
||||
}
|
||||
json += "],";
|
||||
|
||||
// accessors
|
||||
json += "\"accessors\":[";
|
||||
json += "{\"bufferView\":0,\"byteOffset\":0,\"componentType\":5126,\"count\":" +
|
||||
std::to_string(nv) + ",\"type\":\"VEC3\"}";
|
||||
json += ",{\"bufferView\":1,\"byteOffset\":0,\"componentType\":5123,\"count\":" +
|
||||
std::to_string(ni) + ",\"type\":\"SCALAR\"}";
|
||||
if (normals) {
|
||||
json += ",{\"bufferView\":2,\"byteOffset\":0,\"componentType\":5126,\"count\":" +
|
||||
std::to_string(nv) + ",\"type\":\"VEC3\"}";
|
||||
}
|
||||
json += "],";
|
||||
|
||||
// meshes / primitives
|
||||
std::string attrs = "\"POSITION\":0";
|
||||
if (normals) attrs += ",\"NORMAL\":2";
|
||||
json += "\"meshes\":[{\"primitives\":[{\"attributes\":{" + attrs + "},\"indices\":1}]}]";
|
||||
json += "}";
|
||||
|
||||
// Pad JSON to 4-byte boundary
|
||||
while (json.size() % 4 != 0) json += ' ';
|
||||
|
||||
// Assemble GLB
|
||||
std::vector<uint8_t> glb;
|
||||
uint32_t json_chunk_len = (uint32_t)json.size();
|
||||
uint32_t bin_chunk_len = (uint32_t)bin.size();
|
||||
uint32_t total = 12 + 8 + json_chunk_len + 8 + bin_chunk_len;
|
||||
|
||||
// Header
|
||||
write_u32le(glb, 0x46546C67u); // magic "glTF"
|
||||
write_u32le(glb, 2u); // version
|
||||
write_u32le(glb, total);
|
||||
|
||||
// Chunk 0: JSON
|
||||
write_u32le(glb, json_chunk_len);
|
||||
write_u32le(glb, 0x4E4F534Au); // "JSON"
|
||||
for (char c : json) glb.push_back((uint8_t)c);
|
||||
|
||||
// Chunk 1: BIN
|
||||
write_u32le(glb, bin_chunk_len);
|
||||
write_u32le(glb, 0x004E4942u); // "BIN\0"
|
||||
for (uint8_t b : bin) glb.push_back(b);
|
||||
|
||||
return glb;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("invalid magic -> empty Mesh + last_error set", "[gltf][reject]") {
|
||||
std::vector<uint8_t> bad(12, 0);
|
||||
bad[0] = 0xDE; bad[1] = 0xAD; bad[2] = 0xBE; bad[3] = 0xEF;
|
||||
// version=2, total=12
|
||||
bad[4]=2; bad[8]=12;
|
||||
|
||||
auto m = fn::gfx::gltf_load_mesh_from_memory(bad.data(), bad.size());
|
||||
REQUIRE(m.positions.empty());
|
||||
REQUIRE(m.indices.empty());
|
||||
std::string err = fn::gfx::gltf_load_last_error();
|
||||
REQUIRE(!err.empty());
|
||||
INFO("last_error: " << err);
|
||||
// Should mention magic or "not a GLB"
|
||||
REQUIRE((err.find("magic") != std::string::npos ||
|
||||
err.find("GLB") != std::string::npos));
|
||||
}
|
||||
|
||||
TEST_CASE("too-small buffer -> empty Mesh + last_error set", "[gltf][reject]") {
|
||||
std::vector<uint8_t> tiny = {0x67, 0x6C, 0x54, 0x46}; // only 4 bytes
|
||||
auto m = fn::gfx::gltf_load_mesh_from_memory(tiny.data(), tiny.size());
|
||||
REQUIRE(m.positions.empty());
|
||||
std::string err = fn::gfx::gltf_load_last_error();
|
||||
REQUIRE(!err.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("triangle without NORMAL -> normals generated, correct count", "[gltf][triangle][normals]") {
|
||||
// One triangle in XY plane (z=0): (0,0,0), (1,0,0), (0,1,0)
|
||||
// Face normal = (0,0,1) → all vertices should get approx (0,0,1)
|
||||
float pos[] = { 0,0,0, 1,0,0, 0,1,0 };
|
||||
uint16_t idx[] = { 0, 1, 2 };
|
||||
|
||||
auto glb = build_glb(pos, 3, idx, 3, /*normals=*/nullptr);
|
||||
auto m = fn::gfx::gltf_load_mesh_from_memory(glb.data(), glb.size());
|
||||
|
||||
REQUIRE(m.positions.size() == 9); // 3 vertices * 3 floats
|
||||
REQUIRE(m.indices.size() == 3);
|
||||
REQUIRE(m.normals.size() == m.positions.size());
|
||||
|
||||
// Check positions
|
||||
REQUIRE(m.positions[0] == Catch::Approx(0.0f));
|
||||
REQUIRE(m.positions[1] == Catch::Approx(0.0f));
|
||||
REQUIRE(m.positions[2] == Catch::Approx(0.0f));
|
||||
REQUIRE(m.positions[3] == Catch::Approx(1.0f));
|
||||
REQUIRE(m.positions[6] == Catch::Approx(0.0f));
|
||||
REQUIRE(m.positions[7] == Catch::Approx(1.0f));
|
||||
|
||||
// Check indices
|
||||
REQUIRE(m.indices[0] == 0u);
|
||||
REQUIRE(m.indices[1] == 1u);
|
||||
REQUIRE(m.indices[2] == 2u);
|
||||
|
||||
// Generated normals should point toward +Z for all 3 vertices
|
||||
for (int v = 0; v < 3; ++v) {
|
||||
REQUIRE(m.normals[v*3+0] == Catch::Approx(0.0f).margin(1e-5f));
|
||||
REQUIRE(m.normals[v*3+1] == Catch::Approx(0.0f).margin(1e-5f));
|
||||
REQUIRE(m.normals[v*3+2] == Catch::Approx(1.0f).margin(1e-5f));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("quad (2 triangles) -> positions.size()==12, indices.size()==6", "[gltf][quad]") {
|
||||
// Quad in XY: (0,0,0),(1,0,0),(1,1,0),(0,1,0) split into 2 tris
|
||||
float pos[] = { 0,0,0, 1,0,0, 1,1,0, 0,1,0 };
|
||||
uint16_t idx[] = { 0,1,2, 0,2,3 };
|
||||
|
||||
auto glb = build_glb(pos, 4, idx, 6, nullptr);
|
||||
auto m = fn::gfx::gltf_load_mesh_from_memory(glb.data(), glb.size());
|
||||
|
||||
REQUIRE(m.positions.size() == 12); // 4 * 3
|
||||
REQUIRE(m.normals.size() == 12);
|
||||
REQUIRE(m.indices.size() == 6);
|
||||
|
||||
// All normals should be (0,0,1) — flat XY plane
|
||||
for (int v = 0; v < 4; ++v) {
|
||||
REQUIRE(m.normals[v*3+2] == Catch::Approx(1.0f).margin(1e-5f));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("explicit normals -> passed through unchanged", "[gltf][normals]") {
|
||||
float pos[] = { 0,0,0, 1,0,0, 0,1,0 };
|
||||
uint16_t idx[] = { 0, 1, 2 };
|
||||
// Provide normals pointing in -Z (unusual, but should be respected)
|
||||
float nrm[] = { 0,0,-1, 0,0,-1, 0,0,-1 };
|
||||
|
||||
auto glb = build_glb(pos, 3, idx, 3, nrm);
|
||||
auto m = fn::gfx::gltf_load_mesh_from_memory(glb.data(), glb.size());
|
||||
|
||||
REQUIRE(m.positions.size() == 9);
|
||||
REQUIRE(m.normals.size() == 9);
|
||||
|
||||
for (int v = 0; v < 3; ++v) {
|
||||
REQUIRE(m.normals[v*3+0] == Catch::Approx(0.0f).margin(1e-5f));
|
||||
REQUIRE(m.normals[v*3+1] == Catch::Approx(0.0f).margin(1e-5f));
|
||||
REQUIRE(m.normals[v*3+2] == Catch::Approx(-1.0f).margin(1e-5f));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("nonexistent file -> empty Mesh + last_error set", "[gltf][file]") {
|
||||
auto m = fn::gfx::gltf_load_mesh_from_file("/tmp/does_not_exist_gltf_test_abc123.glb");
|
||||
REQUIRE(m.positions.empty());
|
||||
std::string err = fn::gfx::gltf_load_last_error();
|
||||
REQUIRE(!err.empty());
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// test_terminal_panel_smoke.cpp — smoke test para terminal_panel.
|
||||
//
|
||||
// Prueba real del PTY en Linux: spawn "echo hello && exit 0",
|
||||
// espera output, verifica que el scrollback contiene "hello".
|
||||
//
|
||||
// En Windows: test skipped (ConPTY require DISPLAY y proceso vivo — CI).
|
||||
// En Linux sin forkpty: verifica que el build es correcto al menos.
|
||||
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch_amalgamated.hpp"
|
||||
|
||||
#include "viz/terminal_panel/terminal_panel.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#ifdef _WIN32
|
||||
// En Windows en CI, skipeamos el smoke del proceso real.
|
||||
TEST_CASE("smoke: spawn echo hello and exit, scrollback contains hello", "[terminal_panel][smoke]") {
|
||||
SKIP("Smoke PTY test skipped on Windows CI");
|
||||
}
|
||||
#else
|
||||
|
||||
// Helper: concatena todas las celdas del scrollback como texto plano.
|
||||
static std::string scrollback_text(fn_term::TerminalPanel& p) {
|
||||
std::lock_guard<std::mutex> lk(p.buf_mutex);
|
||||
std::string result;
|
||||
for (const auto& line : p.lines) {
|
||||
for (const auto& cell : line) {
|
||||
if (cell.ch >= 0x20 && cell.ch < 0x7F)
|
||||
result += static_cast<char>(cell.ch);
|
||||
}
|
||||
result += '\n';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
TEST_CASE("smoke: spawn echo hello and exit, scrollback contains hello", "[terminal_panel][smoke]") {
|
||||
fn_term::TerminalPanel term;
|
||||
term.shell = "/bin/bash";
|
||||
term.scrollback_lines = 100;
|
||||
|
||||
fn_term::open(term);
|
||||
REQUIRE(term.is_open());
|
||||
|
||||
// Enviar el comando y esperar a que el proceso salga.
|
||||
fn_term::send(term, "echo hello && exit 0\n");
|
||||
|
||||
// Esperar máximo 2 segundos a que el proceso termine.
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
while (!term.process_exited.load()
|
||||
&& std::chrono::steady_clock::now() < deadline) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
|
||||
// Dar 100ms adicionales para que el reader thread procese el último output.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
|
||||
std::string text = scrollback_text(term);
|
||||
fn_term::close(term);
|
||||
|
||||
INFO("scrollback: " << text);
|
||||
REQUIRE(text.find("hello") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("smoke: process exits cleanly", "[terminal_panel][smoke]") {
|
||||
fn_term::TerminalPanel term;
|
||||
term.shell = "/bin/bash";
|
||||
term.scrollback_lines = 50;
|
||||
|
||||
fn_term::open(term);
|
||||
REQUIRE(term.is_open());
|
||||
|
||||
fn_term::send(term, "exit 0\n");
|
||||
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
while (!term.process_exited.load()
|
||||
&& std::chrono::steady_clock::now() < deadline) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
|
||||
REQUIRE(term.process_exited.load());
|
||||
REQUIRE(term.exit_code == 0);
|
||||
|
||||
fn_term::close(term);
|
||||
}
|
||||
|
||||
TEST_CASE("smoke: readonly panel ignores send", "[terminal_panel][smoke]") {
|
||||
fn_term::TerminalPanel term;
|
||||
term.shell = "/bin/bash";
|
||||
term.readonly = true;
|
||||
term.scrollback_lines = 50;
|
||||
|
||||
fn_term::open(term);
|
||||
REQUIRE(term.is_open());
|
||||
|
||||
// send() no debe hacer nada (readonly).
|
||||
fn_term::send(term, "echo should_not_appear\n");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||
|
||||
std::string text = scrollback_text(term);
|
||||
fn_term::close(term);
|
||||
|
||||
// "should_not_appear" no debería estar en el scrollback porque send es no-op.
|
||||
INFO("scrollback: " << text);
|
||||
REQUIRE(text.find("should_not_appear") == std::string::npos);
|
||||
}
|
||||
|
||||
#endif // !_WIN32
|
||||
@@ -0,0 +1,274 @@
|
||||
---
|
||||
name: agentes-dispositivos-mesh
|
||||
id: 0009
|
||||
status: pending
|
||||
created: 2026-05-23
|
||||
updated: 2026-05-23
|
||||
priority: high
|
||||
risk: high
|
||||
related_issues: [0134, 0135, 0136, 0137, 0138, 0139, 0140, 0141, 0142, 0143]
|
||||
apps: [agents_dashboard, agents_and_robots, wg_hub, device_agent]
|
||||
projects: [element_agents]
|
||||
vaults: []
|
||||
capability_groups: [wireguard, device-agent, docker-agent]
|
||||
trigger: manual
|
||||
schedule: ""
|
||||
expected_runtime_s: 300
|
||||
tags: [mesh, wireguard, matrix, e2ee, agents, devices, docker, sandboxing]
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Hablar desde Element con dispositivos completos (PCs, moviles, raspberry, IoT) y con
|
||||
contenedores Docker como si fueran agentes Matrix. Cada device/container ejecuta sus
|
||||
capabilities declaradas (shell/fs/camera/docker/sensores) bajo:
|
||||
|
||||
1. **Mesh WireGuard** anclado en `organic-machine.com` — sin abrir puertos en los devices.
|
||||
2. **Matrix E2EE** como bus de control y chat — un room por device/container.
|
||||
3. **Capability manifest firmado** ed25519 — el device rechaza lo que no este firmado.
|
||||
|
||||
## Pre-requisitos
|
||||
|
||||
- VPS `organic-machine.com` con root SSH (alias `vps` en `~/.ssh/config`).
|
||||
- `agents_and_robots` y `agents_dashboard` desplegados (ya OK).
|
||||
- `pass` con clave operador ed25519 (`pass insert operator/ed25519` — crear si falta).
|
||||
- `apt-get install wireguard wireguard-tools` permitido en el VPS.
|
||||
- Devices Linux/WSL: sudo sin password para `wg`, `wg-quick`, `systemctl`.
|
||||
- Devices Android: Termux + WireGuard app + `pkg install golang openssh-client`.
|
||||
|
||||
## Funciones del registry recomendadas
|
||||
|
||||
| Rol | Funcion candidata | Estado |
|
||||
|---|---|---|
|
||||
| WG install (host) | `wg_install_bash_infra` | FALTA: crear |
|
||||
| WG keygen | `wg_keygen_go_infra` | FALTA: crear |
|
||||
| WG hub setup | `wg_hub_setup_bash_infra` | FALTA: crear |
|
||||
| WG peer add (hub) | `wg_peer_add_go_infra` | FALTA: crear |
|
||||
| WG peer remove (hub) | `wg_peer_remove_go_infra` | FALTA: crear |
|
||||
| WG peer revoke (kill switch) | `wg_peer_revoke_go_infra` | FALTA: crear |
|
||||
| WG client config gen | `wg_client_config_go_infra` | FALTA: crear |
|
||||
| WG client install (device) | `wg_client_install_bash_infra` | FALTA: crear |
|
||||
| WG status (parse `wg show`) | `wg_status_bash_infra` | FALTA: crear |
|
||||
| Docker list (host) | `docker_container_list_go_infra` | FALTA: crear |
|
||||
| Docker exec capability | `docker_container_exec_go_infra` | FALTA: crear |
|
||||
| Docker logs tail | `docker_container_logs_go_infra` | FALTA: crear |
|
||||
| Docker container enroll | `docker_container_enroll_go_infra` | FALTA: crear |
|
||||
| Capability sign | `capability_manifest_sign_go_infra` | FALTA: crear |
|
||||
| Capability verify | `capability_manifest_verify_go_infra` | FALTA: crear |
|
||||
| Enrollment token gen | `enrollment_token_create_go_infra` | FALTA: crear |
|
||||
| Enrollment token verify | `enrollment_token_verify_go_infra` | FALTA: crear |
|
||||
| Matrix room per device | `matrix_room_for_device_py_browser` (extender) | OK base, EXTENDER |
|
||||
| Provision hub pipeline | `provision_wg_hub_bash_pipelines` | FALTA: crear |
|
||||
| Enroll device pipeline | `enroll_device_bash_pipelines` | FALTA: crear |
|
||||
| Sink audit log | `device_audit_append_go_infra` | FALTA: crear |
|
||||
| Notify approval | `matrix_send_message_py_browser` (existente) | OK |
|
||||
|
||||
## Apps tocadas
|
||||
|
||||
- `agents_dashboard` (cockpit ImGui) — panel "Mesh" + "Devices" + "Containers" + approval queue.
|
||||
- `agents_and_robots` (hub Matrix VPS) — listener Matrix por device/container.
|
||||
- `wg_hub` (nuevo service Go en VPS) — enrollment endpoint, peer CRUD, SSE stream.
|
||||
- `device_agent` (nuevo binario per-host) — capability dispatcher con sandbox.
|
||||
- `container_agent_sidecar` (opcional, nuevo) — sidecar para containers que necesitan WG-peer propio.
|
||||
|
||||
## Projects relacionados
|
||||
|
||||
- `element_agents` (parent project — agents Matrix).
|
||||
|
||||
## Vaults / storage
|
||||
|
||||
- `apps/wg_hub/operations.db` — tabla `wg_peers`, `wg_enrollment_tokens`, `device_audit`.
|
||||
- `apps/agents_dashboard/local_files/agents_dashboard.db` — cache devices + capabilities.
|
||||
- `pass operator/ed25519` — clave maestra del operador (firma manifests).
|
||||
- `pass wg/preshared/<device_id>` — PSK por peer.
|
||||
|
||||
## Capability groups consultados
|
||||
|
||||
- `wireguard` (nuevo, ver `docs/capabilities/wireguard.md`).
|
||||
- `device-agent` (nuevo, capability dispatcher + sandbox + audit).
|
||||
- `docker-agent` (nuevo, capabilities sobre containers locales).
|
||||
|
||||
## Flow
|
||||
|
||||
### Fase A — registry primero (delegar a fn-constructor en paralelo)
|
||||
|
||||
1. `function: wg_install_bash_infra` (delegada).
|
||||
2. `function: wg_keygen_go_infra` (delegada).
|
||||
3. `function: wg_hub_setup_bash_infra` (delegada).
|
||||
4. `function: wg_peer_add_go_infra` (delegada).
|
||||
5. `function: wg_peer_remove_go_infra` (delegada).
|
||||
6. `function: wg_peer_revoke_go_infra` (delegada).
|
||||
7. `function: wg_client_config_go_infra` (delegada).
|
||||
8. `function: wg_client_install_bash_infra` (delegada).
|
||||
9. `function: wg_status_bash_infra` (delegada).
|
||||
10. `cmd: ./fn index` — registra las 9 nuevas.
|
||||
11. `cmd: fn doctor unused | grep wg_` — confirma que estan listas y no huerfanas (se usan en pasos C).
|
||||
|
||||
### Fase C — POC manual end-to-end
|
||||
|
||||
12. `function: wg_install_bash_infra` (sobre `organic-machine.com` via SSH).
|
||||
13. `function: wg_keygen_go_infra` → key par hub.
|
||||
14. `function: wg_hub_setup_bash_infra` — wg0, 10.42.0.1/24, ufw 51820/udp, persistencia.
|
||||
15. `function: wg_keygen_go_infra` → key par device `home-wsl`.
|
||||
16. `function: wg_peer_add_go_infra` (en hub) → asigna 10.42.0.10.
|
||||
17. `function: wg_client_config_go_infra` → genera client.conf.
|
||||
18. `function: wg_client_install_bash_infra` (en `home-wsl`).
|
||||
19. `cmd: ping -c3 10.42.0.1` desde `home-wsl` — verifica handshake.
|
||||
20. `cmd: curl http://10.42.0.1:8080/healthz` — agents_and_robots accesible por IP privada.
|
||||
21. Repetir 15-19 para `pc-aurgi`.
|
||||
|
||||
### Fase B — spec + capability manifest + bot Matrix
|
||||
|
||||
22. Issue 0134 spec protocol: envelope JSON `{request_id, capability, args, signature, nonce}`,
|
||||
error model, approval flow, audit chain hash.
|
||||
23. `function: capability_manifest_sign_go_infra` (operator firma).
|
||||
24. `function: capability_manifest_verify_go_infra` (device verifica antes de aceptar request).
|
||||
25. `function: enrollment_token_create_go_infra` (token QR firmado, TTL 10min).
|
||||
26. `function: enrollment_token_verify_go_infra` (hub valida en `/enroll`).
|
||||
27. Implementar `apps/device_agent/` (Go cross-compile) — Matrix client + capability dispatcher + sandbox firejail.
|
||||
28. Panel "Devices" en `agents_dashboard` — lista + capability matrix + approval queue + boton revoke.
|
||||
29. Bot Matrix por device: cuando hablas en el room `#dev-aurgi:organic-machine.com`,
|
||||
`agents_and_robots` parsea, valida capability, despacha a device_agent, devuelve resultado al room.
|
||||
|
||||
### Fase D — agentes-contenedores docker
|
||||
|
||||
30. `function: docker_container_list_go_infra` — corre en host con docker socket access.
|
||||
31. `function: docker_container_exec_go_infra` — exec en container con whitelist binarios.
|
||||
32. `function: docker_container_logs_go_infra` — tail logs SSE.
|
||||
33. Modo "light": container expuesto via host's `device_agent` capability `docker.*`.
|
||||
Element room: `#host-aurgi:organic-machine.com` con comando `!docker exec mycontainer ps`.
|
||||
34. Modo "deep": container = peer WG propio. `container_agent_sidecar` corre WG dentro del container
|
||||
(privileged) o sidecar gluetun-wg. Manifest firmado mapea `agent_X` → container_id.
|
||||
35. Sub-bot Matrix por container: `#cont-mycontainer:organic-machine.com` (opcional, modo deep).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] 9 funciones `wg_*` creadas + indexadas + sin huerfanas.
|
||||
- [ ] Hub WG corriendo en `organic-machine.com`, `wg show` muestra interface wg0.
|
||||
- [ ] `home-wsl` y `pc-aurgi` con IP estable 10.42.0.10/11, `ping` OK.
|
||||
- [ ] `agents_and_robots` accesible solo desde subnet 10.42.0.0/24 (publico = DROP en :8080).
|
||||
- [ ] `agents_dashboard` panel "Mesh" muestra peers vivos via SSE.
|
||||
- [ ] Chat en `#dev-aurgi` ejecuta capability (ej. `!ls /home/lucas`) y devuelve resultado.
|
||||
- [ ] Capability fuera del manifest rechazada con error en room.
|
||||
- [ ] Capability `requires_approval=true` espera confirmacion en `#operator-approvals` antes de ejecutar.
|
||||
- [ ] `docker.container.list` invocado desde Element devuelve containers del host.
|
||||
- [ ] `docker.container.exec` con binario fuera de whitelist rechazado.
|
||||
- [ ] Revoke device desde `agents_dashboard` → device pierde acceso en <5s.
|
||||
- [ ] Audit log append-only inviolable (hash chain) sobrevive reinicio.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
Triada obligatoria (ver `.claude/rules/dod_quality.md`). Sin las 3 capas + 0 anti-criterios el flow NO se mueve a `completed/`.
|
||||
|
||||
### Mecanica (pre-requisito)
|
||||
|
||||
- [ ] **Build device_agent**: `cd apps/device_agent && CGO_ENABLED=0 GOOS=linux go build -o device_agent .` exit 0; cross-compile `GOOS=windows` + `GOOS=android GOARCH=arm64` tambien verdes.
|
||||
- [ ] **Build agents_and_robots + agents_dashboard**: `./fn run redeploy_cpp_app_windows agents_dashboard apps/agents_dashboard --build` + Go build de `agents_and_robots` exit 0.
|
||||
- [ ] **Tests unitarios funciones nuevas verdes**: `CGO_ENABLED=1 go test -tags fts5 -count=1 ./functions/infra/...` cubriendo wg_*, capability_*, enrollment_*, device_audit_*. Lista de IDs en `## Notas`.
|
||||
- [ ] **`./fn index`** sin warnings nuevos tras anadir las ~20 funciones.
|
||||
- [ ] **`./fn doctor unused --json | jq '.[]|select(.id|startswith("wg_"))'`** vacio (las wg_* tienen consumidores reales).
|
||||
- [ ] **`./fn doctor uses-functions`** verde para `apps/device_agent/app.md`, `apps/wg_hub/app.md`, `apps/agents_dashboard/app.md`.
|
||||
- [ ] **`./fn doctor services-spec`** verde para `wg_hub.service` y `device_agent.service` con bloque service: completo.
|
||||
|
||||
### Cobertura de comportamiento
|
||||
|
||||
Minimo: golden + 8 edge/error documentados aqui con assert ejecutable. Cada uno deja entry en `e2e_runs` de la app afectada (`apps/device_agent/operations.db`, `apps/wg_hub/operations.db`).
|
||||
|
||||
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||
|---|---|---|---|
|
||||
| Golden: comando whitelist OK | e2e | Element `!exec ls /home/lucas` en `#dev-home-wsl` | output `ls` en <3s, entry en `device_audit` con hash valido |
|
||||
| Edge: comando NO whitelist rechazado | e2e | Element `!exec rm -rf /` | reply `capability rejected: shell.exec.rm not in manifest`; entry `device_audit` status=`rejected_capability` |
|
||||
| Edge: capability fuera de manifest | e2e | Element `!camera.snapshot` en device sin esa capability | reply `capability not in manifest`; alerta a `#operator-approvals` |
|
||||
| Edge: replay nonce viejo | e2e | reenviar mismo envelope con nonce ya visto (cmd test: `device_agent --replay-test <envelope.json>`) | rechazo + log `nonce_replay`; entry `device_audit` status=`rejected_nonce` |
|
||||
| Edge: ed25519 manifest invalido | e2e | servir manifest firmado por clave que no es operator; `device_agent` lo recibe en enrollment | `device_agent` rechaza + no instala wg_peer; hub log muestra `manifest_invalid_signature` |
|
||||
| Edge: token enrollment expirado | e2e | `enrollment_token_create` con TTL=1s, esperar 5s, `POST /enroll` | hub responde 401 `token_expired`; cmd `curl ...` exit != 0 |
|
||||
| Approval flow honrado | e2e | Element `!fs.write /tmp/x hello` (requires_approval=true); operador hace 👍 en `#operator-approvals` | exec ocurre SOLO tras approval; sin approval no escribe; entry `device_audit` con `approval_msg_id` |
|
||||
| Approval flow no se salta | e2e | Forzar via API directa salto del approval queue (test negativo: cmd `curl --data ...` directo al device) | device rechaza + log; sin approval_msg_id en envelope = rechazo |
|
||||
| Mesh-down handled | e2e | `wg-quick down wg0` en hub mientras device manda comando | device entra en `degraded`, comando encolado o respuesta `mesh_unreachable`; al volver hub: handshake reanuda, cola se vacia |
|
||||
| Dos devices simultaneos sin interferencia | e2e | `home-wsl` y `pc-aurgi` ejecutan capabilities en paralelo (script python con 2 threads) | cada audit chain es independiente, sin cross-contamination; `device_audit` muestra 2 chains separadas, hash chain valido en cada una |
|
||||
| Audit chain valida tras restart | e2e | matar `device_agent` mid-flight (`kill -9`) + relanzar; `cmd: device_audit_verify_chain --device home-wsl` | chain integra, hash anterior coincide, sin huecos |
|
||||
| Revoke device <5s | e2e | desde `agents_dashboard` panel "Mesh" boton "Revoke home-wsl"; medir tiempo hasta `wg show` no liste peer | peer ausente en <5s; siguientes comandos a `#dev-home-wsl` -> `peer_revoked` |
|
||||
|
||||
**Regla**: cada fila genera `e2e_check` en `app.md` correspondiente (issue 0068). `fn-analizador` los corre periodicamente.
|
||||
|
||||
### Vida util validada
|
||||
|
||||
| Metrica | Umbral | Donde se observa | Ventana |
|
||||
|---|---|---|---|
|
||||
| Peers vivos en mesh | `>=2` constantes (home-wsl + pc-aurgi) | `agents_dashboard` panel "Mesh" (last_handshake < 3min) | 7 dias |
|
||||
| Crashes `device_agent` | `0` | `journalctl --user -u device_agent.service` en cada device | 7 dias |
|
||||
| Crashes `wg_hub` | `0` | `ssh vps journalctl -u wg_hub.service` | 7 dias |
|
||||
| Huecos en audit chain | `0` | `cmd: device_audit_verify_chain --all` | continuo |
|
||||
| Rollback de wg config | `0 ocurrencias` | hub: `git -C /etc/wireguard status` debe ser clean; sin restore manual | 7 dias |
|
||||
| Handshake fail rate | `<5%` | `wg show all dump` parseado por `agents_dashboard` | 7 dias |
|
||||
| Approval queue stuck | `0 pendientes >24h` | `agents_dashboard` panel "Approvals" | continuo |
|
||||
| Comandos exec latencia p95 | `<3s` | `call_monitor.function_stats` para `capability.shell.exec` | 7 dias |
|
||||
| Replay attacks bloqueados | `>=1 detectado y bloqueado` (pen-test real) | `device_audit` status=`rejected_nonce` count | 30 dias |
|
||||
|
||||
### User-facing (reforzado)
|
||||
|
||||
- [ ] **User-facing surface**: humano abre Element en movil/web (`element.organic-machine.com`), entra a `#dev-<nombre>` y escribe comandos. Output en el mismo room. NO en una BD, NO en un log.
|
||||
- [ ] **User-facing usage real**: el operador (humano) usa Element con `home-wsl` Y `pc-aurgi` (>=2 maquinas reales), **>=1 sesion/dia durante >=7 dias consecutivos**, **>=20 comandos totales** repartidos entre devices.
|
||||
- [ ] **User-facing variado**: cubre capabilities de **>=4 tipos**: read (`!fs.read`, `!ls`), write (`!fs.write`), exec (`!exec`), approval-required (`!fs.write` en path sensible), docker (`!docker exec`).
|
||||
- [ ] **User-facing onboarding**: parrafo en `## Notas` con pasos numerados: abrir Element -> entrar a room -> `!help` -> ejemplo de comando. Sin leer el flow entero.
|
||||
- [ ] **User-facing latencia**: tras enviar mensaje en Element, output visible en <3s (read/exec) o <5s (con approval) — medido y registrado en `## Notas`.
|
||||
|
||||
### Anti-criterios (invalidan DoD aunque checkboxes verdes)
|
||||
|
||||
- [ ] **Solo-en-home-wsl**: el flow funciona en mi WSL pero falla en `pc-aurgi` u otro device fisico.
|
||||
- [ ] **device_agent muere cada noche**: cualquier crash recurrente del proceso device_agent en los 7 dias de validacion.
|
||||
- [ ] **Approval flow se salta**: alguna entrada en `device_audit` con capability `requires_approval=true` ejecutada sin `approval_msg_id` valido.
|
||||
- [ ] **Audit chain rota**: `device_audit_verify_chain` reporta huecos o hash mismatch en algun device.
|
||||
- [ ] **wg config drift**: cambios manuales en `/etc/wireguard/wg0.conf` del hub sin pasar por `wg_peer_add/remove/revoke`. Git status muestra cambios sin trackear.
|
||||
- [ ] **Dashboard fantasma**: `agents_dashboard` declarado pero el operador no lo abre durante la ventana de 7 dias. Telemetria muerta.
|
||||
- [ ] **Pen-test no ejercitado**: replay attack / capability fuera de manifest / token expirado declarados pero sin entry real en `device_audit` con status `rejected_*` en los 7 dias.
|
||||
- [ ] **Silent-fail**: peer cae >24h y nadie se entera (sin alerta a `#operator-approvals` ni badge rojo en dashboard).
|
||||
- [ ] **Secrets en repo**: cualquier hit de `git grep -E 'PrivateKey|PSK|operator/ed25519' -- ':!*.md'` en cualquier rama.
|
||||
|
||||
### Custom (security-specific, deben tener evidencia en `device_audit`)
|
||||
|
||||
- [ ] _(custom)_ Pen-test capability fuera de manifest: entry `device_audit` status=`rejected_capability` ejercitado intencionalmente >=1 vez.
|
||||
- [ ] _(custom)_ Pen-test replay: entry `device_audit` status=`rejected_nonce` ejercitado >=1 vez con cmd reproducible.
|
||||
- [ ] _(custom)_ Stale device: forzar `home-wsl` offline >24h, verificar badge `stale` en `agents_dashboard` + mensaje en `#operator-approvals`.
|
||||
- [ ] _(custom)_ Operator key rotation: ejecutar rollover de la clave ed25519 maestra + revoke-all + re-enroll, sin perder audit chain historica. Documentado en `## Notas`.
|
||||
|
||||
## Telemetria esperada
|
||||
|
||||
- `call_monitor.calls`: cada `wg_*`, `capability.*`, `docker.*` con duration_ms, success.
|
||||
- `apps/wg_hub/operations.db`: tabla `wg_peers` + `device_audit` (hash-chained append-only).
|
||||
- `apps/agents_and_robots/operations.db`: tabla `matrix_capability_dispatches`.
|
||||
- `apps/agents_dashboard/local_files/agents_dashboard.db`: cache devices + approval queue.
|
||||
- Dashboards visibles: `agents_dashboard` panel "Mesh" (peers vivos + last handshake + bytes rx/tx).
|
||||
- Matrix room `#operator-approvals` recibe cada approval_request.
|
||||
- Element en movil aprueba/rechaza con reacciones (👍/👎) o comando `!approve <id>`.
|
||||
|
||||
## Riesgos / gotchas
|
||||
|
||||
- **VPS UDP/51820**: firewall del proveedor del VPS puede bloquearlo. Verificar con `nc -u -v vps 51820`.
|
||||
- **NAT carrier-grade (4G/5G)**: device tras NAT estricto → `PersistentKeepalive = 25` obligatorio.
|
||||
- **Sleep laptop / android doze**: handshake muere. Auto-reconnect via `systemd-networkd-wait-online` + script.
|
||||
- **Privilegio sudo**: `wg-quick` requiere root. Devices necesitan sudo-NOPASSWD para `wg-quick@wg0`.
|
||||
- **Clock skew**: tokens enrollment + nonces dependen de NTP. Forzar `chrony` en VPS y devices.
|
||||
- **Container privileged**: modo "deep" docker requiere `--cap-add NET_ADMIN`. Riesgo si container compromised.
|
||||
Mitigacion: solo modo "deep" para containers de tu propio control (ej. `agents_and_robots` self-hosted), no third-party.
|
||||
- **Operator key compromise**: si tu ed25519 leaks → cualquiera firma manifests. Plan B: rotacion + revoke-all + re-enroll.
|
||||
- **Matrix homeserver compromise**: chat E2EE protege contenido, pero metadata (quien habla con quien) leak.
|
||||
Aceptable porque homeserver es tuyo en `organic-machine.com`.
|
||||
|
||||
## Notas
|
||||
|
||||
(rellenar tras ejecutar fases A/C/B/D)
|
||||
|
||||
### Para hablar con un device desde Element (onboarding)
|
||||
|
||||
1. Abre Element en movil o web (`element.organic-machine.com`).
|
||||
2. Entra al room `#dev-<nombre>` (un room por device).
|
||||
3. Escribe `!help` → bot del room (`agents_and_robots`) responde con capability matrix del device.
|
||||
4. Escribe comando, ej. `!exec ls /home/lucas` o `!fs.read /var/log/syslog`.
|
||||
5. Si capability requiere approval, te llega notification a `#operator-approvals` → reaccionas 👍 → ejecuta.
|
||||
6. Output aparece en el mismo room del device.
|
||||
|
||||
### Para hablar con un container docker
|
||||
|
||||
1. Si el host del container ya esta en la mesh: room `#dev-<host>` con `!docker exec <container> <cmd>`.
|
||||
2. Modo deep: room dedicado `#cont-<container>` (solo containers enrolled).
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
name: matrix-client-pc
|
||||
id: 0010
|
||||
status: pending
|
||||
created: 2026-05-24
|
||||
updated: 2026-05-24
|
||||
priority: high
|
||||
risk: medium
|
||||
related_issues: [0147, 0148, 0149, 0150, 0151, 0152, 0153, 0162, 0163]
|
||||
related_flows: [0009, 0011]
|
||||
apps: [matrix_client_pc]
|
||||
projects: [element_agents]
|
||||
vaults: []
|
||||
capability_groups: [matrix-client, livekit-calls, e2ee, widgets]
|
||||
trigger: manual
|
||||
schedule: ""
|
||||
expected_runtime_s: 0
|
||||
tags: [matrix, element, wails, react, mantine, livekit, e2ee, widgets, agents]
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Cliente Matrix propio para PC (Win/Linux/macOS) construido con Wails (Go backend) + React+Mantine+`@fn_library` frontend. Replica capacidades actuales de Element Web (chat, E2EE, calls LiveKit) y se abre a mejoras propias: mini-webapps embebidas en conversaciones gestionadas por agentes del project `element_agents`, paneles especiales para llamadas, integracion directa con `agents_and_robots` + `agents_dashboard` + `device_agent` + futuro mesh WireGuard (flow 0009).
|
||||
|
||||
## Pre-requisitos
|
||||
|
||||
- Synapse + MAS + LiveKit funcionando en `organic-machine.com` (app `element_matrix_chat` ya desplegada, 5+ semanas uptime).
|
||||
- `livekit-jwt` container vivo para generar tokens (ver `docker-compose.livekit.yml`).
|
||||
- Sygnal push gateway (Synapse) — TBD si no existe, anadir container para push notifs PC + Android.
|
||||
- Cuenta Matrix de test (`@dev-pc:matrix-af2f3d.organic-machine.com`) registrada via MAS.
|
||||
- Go 1.22+ + Wails CLI v2 instalado (`go install github.com/wailsapp/wails/v2/cmd/wails@latest`).
|
||||
- pnpm + Node 20+ (ya en el repo para `frontend/`).
|
||||
|
||||
## Funciones del registry recomendadas
|
||||
|
||||
| Rol | Funcion candidata | Estado |
|
||||
|---|---|---|
|
||||
| Matrix client init (Go) | `matrix_client_init_go_infra` | FALTA: wrapper sobre `mautrix-go` (login MAS OIDC, sync, store SQLite) |
|
||||
| LiveKit token gen (Go) | `livekit_token_gen_go_infra` | FALTA: JWT con `livekit-server-sdk-go` |
|
||||
| Matrix room subscribe SSE (Go) | `matrix_room_subscribe_go_infra` | FALTA: stream eventos Synapse -> frontend Wails via SSE/IPC |
|
||||
| Matrix message send (Go) | `matrix_message_send_go_infra` | FALTA: text + markdown + reply + edit + reaction |
|
||||
| Matrix E2EE bootstrap (Go) | `matrix_e2ee_bootstrap_go_infra` | FALTA: cross-signing keys, recovery passphrase |
|
||||
| Matrix device verify (Go) | `matrix_device_verify_go_infra` | FALTA: SAS verification flow |
|
||||
| LiveKit room hook (TS) | `livekit_room_ts_ui` | FALTA: hook React wrapper sobre `livekit-client` |
|
||||
| Widget host iframe (TS) | `widget_host_ts_ui` | FALTA: iframe sandbox + postMessage Matrix Widget API v2 |
|
||||
| Matrix timeline hook (TS) | `useMatrixTimeline_ts_ui` | FALTA: hook React con pagination, dedupe, optimistic UI |
|
||||
| Markdown render (TS) | reuse existing `markdown_render_ts_ui` si existe, sino crear | check |
|
||||
| HTTP client (Go) | `http_json_client_go_infra` | OK (reusar) |
|
||||
| SQLite open (Go) | `sqlite_open_go_infra` | OK (reusar) |
|
||||
| HTTP server SSE | `http_sse_server_go_infra` | OK (reusar) |
|
||||
| Notify (impure) | `notify_desktop_go_infra` | FALTA: Win/Linux/mac notifications nativas |
|
||||
|
||||
## Apps tocadas
|
||||
|
||||
- `projects/element_agents/apps/matrix_client_pc` (nueva — Wails + React).
|
||||
- `projects/element_agents/apps/element_matrix_chat` (backend ya activo; quiza anadir sygnal container).
|
||||
- `projects/element_agents/apps/agents_and_robots` (consumidor — el cliente PC dialoga con agentes via rooms Matrix).
|
||||
- `projects/element_agents/apps/agents_dashboard` (referencia UI — algunos paneles se reusan).
|
||||
|
||||
## Projects relacionados
|
||||
|
||||
- `element_agents` (root project — agrupa todo).
|
||||
|
||||
## Vaults / storage
|
||||
|
||||
- Local del PC: `~/.matrix_client_pc/store.db` (sync state + crypto store SQLite).
|
||||
- Cache media: `~/.matrix_client_pc/media/`.
|
||||
|
||||
## Capability groups consultados
|
||||
|
||||
- `matrix-client` (a crear: documenta wrappers `mautrix-go`).
|
||||
- `livekit-calls` (a crear: token gen + room join + UI calls).
|
||||
- `e2ee` (a crear: bootstrap + verification + recovery).
|
||||
- `widgets` (a crear: Matrix Widget API v2 host + sandbox + permisos).
|
||||
|
||||
## Flow
|
||||
|
||||
Pasos numerados. Cada paso = issue propio (ver `related_issues`).
|
||||
|
||||
1. **0147 — Scaffold Wails + login MAS.** Crear app `matrix_client_pc/` con Wails init, conectar a Synapse via MAS OIDC, mostrar perfil del usuario logueado. Persistencia tokens en `pass` o keychain del SO.
|
||||
2. **0148 — Rooms list + timeline.** Sidebar con rooms (DMs + spaces + grupos), panel central timeline con pagination scroll-up, dedupe, optimistic UI. Reusar layout `AppShell` Mantine.
|
||||
3. **0149 — Composer + interacciones.** Composer markdown, replies, edits, reactions, threads, upload media (imagenes, files, voice msg). Drag&drop. Slash commands placeholder.
|
||||
4. **0150 — E2EE.** `mautrix-go` con crypto store SQLite. Cross-signing setup, recovery passphrase, SAS verification de devices, key backup. UI para verificar otros usuarios.
|
||||
5. **0151 — Calls LiveKit.** Boton call en room -> token JWT desde Go backend -> join LiveKit room -> UI con tiles participantes, mute/cam/screen/hangup. 1:1 + grupales hasta 16 (limite actual del config).
|
||||
6. **0152 — Mini-webapps embebidas.** Implementar Matrix Widget API v2: iframe sandbox + postMessage handshake + permisos (capabilities `m.always_on_screen`, `org.matrix.msc2762.send.event`, etc.). Lanzar webapps desde slash command `/widget <url>` o desde state event `m.widget`. Agentes pueden publicar widgets en su room (ej. dashboard de telemetria, formulario, kanban inline).
|
||||
7. **0153 — Agent integration.** Paneles especiales para rooms operados por agentes de `agents_and_robots`: timeline + panel lateral con estado del agente (uptime, cola de tasks, last_error). Reusar SSE del `agents_dashboard`.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] App Wails compila y arranca en Win+Linux con binario standalone.
|
||||
- [ ] Login MAS OIDC completo, token persistido entre arranques.
|
||||
- [ ] Sync incremental con Synapse funciona; reconexion automatica tras red caida.
|
||||
- [ ] E2EE: enviar/recibir mensajes cifrados con otro cliente (Element Web o Android).
|
||||
- [ ] Call 1:1 con video+audio funcional via LiveKit.
|
||||
- [ ] Widget de prueba (HTML estatico servido por `agents_and_robots`) se carga en iframe sandbox y postMessage handshake completa.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
### Mecanica (pre-requisito)
|
||||
|
||||
- `go build -tags wails` verde para Win + Linux.
|
||||
- `pnpm build` frontend verde.
|
||||
- `fn doctor cpp-apps` no aplica; `fn doctor services` confirma backend Matrix sano.
|
||||
- `app.md` con `uses_functions` declarando todas las dependencias del registry.
|
||||
|
||||
### Cobertura de comportamiento
|
||||
|
||||
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||
|---|---|---|---|
|
||||
| Golden: login + recibir mensaje E2EE | e2e | `e2e/test_login_and_receive.sh` | mensaje aparece en timeline en <2s, descifrado OK |
|
||||
| Edge: red cae 30s, vuelve | e2e | `e2e/test_reconnect.sh` | sync se reanuda sin perder mensajes |
|
||||
| Edge: 2000 mensajes en 1 room | e2e | `e2e/test_perf_timeline.sh` | scroll a 60fps, memoria <500MB |
|
||||
| Edge: device nuevo no verificado envia msg | e2e | `e2e/test_unverified_device.sh` | warning visible en UI, msg cifra a este device solo si user confirma |
|
||||
| Error: token MAS expira | e2e | `e2e/test_token_refresh.sh` | refresh automatico, sin logout visible |
|
||||
| Error: LiveKit SFU caido | e2e | `e2e/test_livekit_down.sh` | error claro en UI, no crash de la app |
|
||||
|
||||
### Vida util validada (>=7 dias uso real)
|
||||
|
||||
| Metrica | Umbral | Donde se observa | Ventana |
|
||||
|---|---|---|---|
|
||||
| Crashes proceso PC | `0` | `journalctl --user -u matrix_client_pc` (Linux) / Event Viewer (Win) | 7 dias |
|
||||
| Latencia send msg | `p95 < 500ms` | panel propio de la app + `call_monitor` | 7 dias |
|
||||
| Calls fallidas | `< 5%` | counter en app + logs LiveKit | 7 dias |
|
||||
| Uso real diario | `>= 4 dias/semana` | `last_active_at` en store local | 7 dias |
|
||||
| Onboarding nuevo usuario | `< 5min hasta primer msg E2EE` | screencast operador | 1 sesion |
|
||||
|
||||
### Anti-criterios
|
||||
|
||||
- NO marcar done si E2EE se silent-falla (mensajes no se descifran y la UI no lo dice).
|
||||
- NO marcar done si la app solo funciona en `home-wsl` y peta en `aurgi-pc`.
|
||||
- NO marcar done si widget host carga `javascript:` URLs (XSS).
|
||||
- NO marcar done si calls grupales >3 participantes lagean con audio cortado.
|
||||
|
||||
## Notas
|
||||
|
||||
**Onboarding rapido:**
|
||||
1. `cd projects/element_agents/apps/matrix_client_pc`
|
||||
2. `wails dev` para desarrollo con hot-reload.
|
||||
3. `wails build -platform linux/amd64,windows/amd64` para release.
|
||||
4. Tokens MAS guardados via `keyring` (Go bindings al keychain del SO).
|
||||
5. Para probar E2EE: crear segundo usuario en Synapse Admin, abrir Element Web como segundo cliente, intercambiar verifications.
|
||||
|
||||
**Camino futuro (post-DoD):**
|
||||
- Push notifs nativas via `sygnal` + APNs/FCM-equivalent desktop (Win Action Center, Linux notify-send).
|
||||
- Mini-webapp catalog: registry de widgets internos (`projects/element_agents/widgets/`) publicables a rooms con un comando.
|
||||
- Threads UI mejorado (vs Element que es plano).
|
||||
- Integracion `agents_and_robots`: panel embebido que muestra logs del agente del room actual.
|
||||
- Cuando flow 0009 (mesh wireguard) este vivo: este cliente PC habla con `device_agent` de cada PC del mesh via su room Matrix.
|
||||
|
||||
**Decisiones clave (justificacion en hilo Claude 2026-05-24):**
|
||||
- Wails > Tauri: Go es stack principal del registry, reusa funciones existentes, `mautrix-go` es el SDK Matrix mas maduro en Go.
|
||||
- React+Vite+Mantine+`@fn_library`: defaults del proyecto, ver `frontend_theming.md`.
|
||||
- 2 codebases (PC Wails + Android Kotlin nativo): tradeoff aceptado por calidad nativa Android + reuso Go en PC. Contrato compartido en `docs/client_contract.md` (TBD).
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v0.1.0 (2026-05-24) — baseline (flow creado).
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
name: matrix-client-android
|
||||
id: 0011
|
||||
status: pending
|
||||
created: 2026-05-24
|
||||
updated: 2026-05-24
|
||||
priority: high
|
||||
risk: medium
|
||||
related_issues: [0154, 0155, 0156, 0157, 0158, 0159, 0160, 0161, 0162, 0163]
|
||||
related_flows: [0009, 0010]
|
||||
apps: [matrix_client_android]
|
||||
projects: [element_agents]
|
||||
vaults: []
|
||||
capability_groups: [matrix-client, livekit-calls, e2ee, widgets, android-native]
|
||||
trigger: manual
|
||||
schedule: ""
|
||||
expected_runtime_s: 0
|
||||
tags: [matrix, element, android, kotlin, compose, livekit, e2ee, widgets, agents, fcm, push]
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Cliente Matrix Android nativo (Kotlin + Jetpack Compose) que comparte contrato con el cliente PC (flow 0010) pero usa SDKs nativos para calidad superior: `matrix-rust-sdk` Kotlin bindings (E2EE rust, mejor), `livekit-android` (codecs HW, audio focus, AEC), FCM push directo via `sygnal`, foreground service para calls en background. Replica capacidades de Element Android + abre mini-webapps embebidas (Matrix Widget API v2 dentro de WebView) gestionadas por agentes del project `element_agents`.
|
||||
|
||||
## Pre-requisitos
|
||||
|
||||
- Stack Synapse + MAS + LiveKit ya activo en `organic-machine.com` (flow 0010 compartido).
|
||||
- Container `sygnal` corriendo en VPS (anadir si no existe — issue 0159 lo cubre).
|
||||
- Firebase project con FCM activado + service account JSON. Hosting gratuito.
|
||||
- Android Studio Iguana+, NDK r26+, Kotlin 1.9+.
|
||||
- `init_kotlin_app_bash_pipelines` (ya existe, ver issues 0073/0074/0075/0078 completados) para scaffold inicial.
|
||||
- Device fisico o emulator Android 9+ (API 28+) para test.
|
||||
- Capability del usuario operador: instalar APK debug + microphone/camera/notification grants.
|
||||
|
||||
## Funciones del registry recomendadas
|
||||
|
||||
| Rol | Funcion candidata | Estado |
|
||||
|---|---|---|
|
||||
| Kotlin app scaffold | `init_kotlin_app_bash_pipelines` | OK (reusar) |
|
||||
| Matrix rust-sdk wrapper (Kotlin) | `matrix_client_kotlin_infra` | FALTA: facade sobre `matrix-rust-sdk` Kotlin bindings |
|
||||
| LiveKit Android wrapper | `livekit_call_kotlin_infra` | FALTA: wrapper `io.livekit:livekit-android` |
|
||||
| FCM token register | `fcm_register_kotlin_infra` | FALTA: registrar device en sygnal via Synapse pusher API |
|
||||
| Sygnal pusher add | `sygnal_pusher_add_go_infra` | FALTA: Go helper para configurar push gateway |
|
||||
| Compose Room list | `RoomListScreen_kotlin_ui` | FALTA |
|
||||
| Compose Timeline | `TimelineScreen_kotlin_ui` | FALTA |
|
||||
| Compose Composer | `Composer_kotlin_ui` | FALTA |
|
||||
| Compose CallScreen | `CallScreen_kotlin_ui` | FALTA |
|
||||
| Compose WidgetHost | `WidgetHost_kotlin_ui` | FALTA: WebView + JS bridge Widget API |
|
||||
| Foreground service call | `CallForegroundService_kotlin_infra` | FALTA |
|
||||
| ICE permissions helper | `permissions_request_kotlin_core` | FALTA: mic/cam/notif/foreground service grants |
|
||||
| Local DB Room | reusar `androidx.room` directo | OK |
|
||||
|
||||
## Apps tocadas
|
||||
|
||||
- `projects/element_agents/apps/matrix_client_android` (nueva — Kotlin+Compose).
|
||||
- `projects/element_agents/apps/element_matrix_chat` (anadir sygnal container — issue 0159).
|
||||
- `projects/element_agents/apps/agents_and_robots` (consumidor agent panels).
|
||||
|
||||
## Projects relacionados
|
||||
|
||||
- `element_agents`.
|
||||
|
||||
## Vaults / storage
|
||||
|
||||
- Local Android: `/data/data/com.fnregistry.matrix_client_android/databases/` (room DB encriptada via SQLCipher).
|
||||
- Crypto store de matrix-rust-sdk: gestionado por el SDK en `files/matrix/<userId>/`.
|
||||
|
||||
## Capability groups consultados
|
||||
|
||||
- `matrix-client` (compartido con flow 0010).
|
||||
- `livekit-calls` (compartido).
|
||||
- `e2ee` (compartido).
|
||||
- `widgets` (compartido — contrato Widget API igual).
|
||||
- `android-native` (a crear: foreground service, FCM, MediaSession para calls).
|
||||
|
||||
## Flow
|
||||
|
||||
1. **0154 — Scaffold Kotlin + Compose + login MAS.** App `matrix_client_android/` con `init_kotlin_app`, Material 3 + tema propio acorde a `frontend_theming.md` (paleta equivalente). Login MAS OIDC via Chrome Custom Tabs. Tokens persistidos en EncryptedSharedPreferences.
|
||||
2. **0155 — Rooms list + Timeline.** Compose UI con `LazyColumn` virtualizado, sync via `matrix-rust-sdk` (corrutinas). Pagination, optimistic UI, swipe-to-react.
|
||||
3. **0156 — Composer.** Markdown, replies, edits, reactions, media (camara + galeria + voice msg con `MediaRecorder` opus).
|
||||
4. **0157 — E2EE rust-sdk.** Cross-signing setup, SAS verification (emoji), recovery passphrase, key backup. UI dialog verificacion.
|
||||
5. **0158 — Calls LiveKit Android nativo.** `livekit-android` SDK con codecs HW (H.264/VP9 hardware decoder), audio focus, echo cancellation, noise suppression. PiP mode Android nativo.
|
||||
6. **0159 — Push FCM via sygnal.** Anadir container `sygnal` al stack `element_matrix_chat`. Registrar FCM token via Synapse Pusher API. Handle push payload -> open room / wake up para incoming call.
|
||||
7. **0160 — Mini-webapps en WebView.** `WebView` con `WebViewClient` + JS bridge implementando Matrix Widget API v2. Sandbox via `setAllowFileAccess(false)`, `setAllowContentAccess(false)`, CSP estricta. Mismo contrato widgets que cliente PC.
|
||||
8. **0161 — Foreground service para calls + lifecycle.** `CallForegroundService` con notification ongoing, audio routing (speaker/earpiece/bluetooth), MediaSession para controls en lockscreen, wakelock controlado.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] APK debug instala + arranca en Android 9+ (API 28).
|
||||
- [ ] Login MAS via Chrome Custom Tabs, token persistido en EncryptedSharedPreferences.
|
||||
- [ ] Sync incremental funciona; reconexion automatica tras avion mode toggle.
|
||||
- [ ] E2EE: mensaje enviado desde PC (Wails) se descifra en Android (y al reves).
|
||||
- [ ] Call 1:1 con video+audio nativos, calidad superior a WebView.
|
||||
- [ ] Push FCM despierta app para incoming msg / call.
|
||||
- [ ] Widget de prueba se carga en WebView sandbox con bridge funcional.
|
||||
- [ ] Foreground service mantiene call viva con app en background + pantalla bloqueada.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
### Mecanica (pre-requisito)
|
||||
|
||||
- `./gradlew assembleDebug` verde.
|
||||
- `./gradlew test` verde.
|
||||
- `./gradlew connectedAndroidTest` verde en emulator API 31+ (instrumented).
|
||||
- `app.md` con `uses_functions` declarando dependencias del registry.
|
||||
|
||||
### Cobertura de comportamiento
|
||||
|
||||
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||
|---|---|---|---|
|
||||
| Golden: login + E2EE msg | instrumented | `./gradlew connectedAndroidTest --tests *LoginE2EE*` | msg descifrado en <2s, shield green |
|
||||
| Edge: avion mode 30s | instrumented | `./gradlew connectedAndroidTest --tests *Reconnect*` | sync resume, sin perder msgs |
|
||||
| Edge: 1000 msgs en room | benchmark | `./gradlew :app:benchmark` | scroll a 60fps, RAM <300MB |
|
||||
| Edge: incoming call, pantalla apagada | manual + screencast | apagar pantalla + recibir call desde PC | notif full-screen + ring, accept funciona |
|
||||
| Error: FCM token rotation | instrumented | `./gradlew connectedAndroidTest --tests *FCMRotation*` | re-register automatico en sygnal |
|
||||
| Error: WebView widget malicioso | instrumented | `./gradlew connectedAndroidTest --tests *WidgetSandbox*` | bloqueado, no escape |
|
||||
| Battery: call 30min | manual + dumpsys batterystats | call 30min | drain <15%, sin OOM |
|
||||
|
||||
### Vida util validada (>=7 dias uso real)
|
||||
|
||||
| Metrica | Umbral | Donde se observa | Ventana |
|
||||
|---|---|---|---|
|
||||
| Crashes (ANRs/forced close) | `0` | `adb logcat -e FATAL` + Play Console (si publicado) | 7 dias |
|
||||
| Push latency (msg enviado -> notif visible) | `p95 < 3s` | log custom en app + sygnal | 7 dias |
|
||||
| Call drops in-pocket (lockscreen) | `< 5%` | counter app | 7 dias |
|
||||
| Battery drain idle | `< 2%/h` | dumpsys batterystats | 7 dias |
|
||||
| Uso real diario | `>= 5 dias/semana` | last_active en local DB | 7 dias |
|
||||
|
||||
### Anti-criterios
|
||||
|
||||
- NO marcar done si E2EE silent-falla.
|
||||
- NO marcar done si call con pantalla bloqueada se corta a los <5min (battery optimization mata el service).
|
||||
- NO marcar done si WebView de widget permite acceso a `file://` o cookies del browser host.
|
||||
- NO marcar done si la app solo funciona en el device del operador y peta en Android < 11.
|
||||
- NO marcar done sin probar en Android 9 (legacy, muchos dispositivos antiguos siguen vivos).
|
||||
|
||||
## Notas
|
||||
|
||||
**Onboarding rapido:**
|
||||
1. `cd projects/element_agents/apps/matrix_client_android`
|
||||
2. `./gradlew assembleDebug && adb install -r app/build/outputs/apk/debug/app-debug.apk`
|
||||
3. Para hot-reload UI: `./gradlew :app:installDebug` + Android Studio Compose preview.
|
||||
4. Para test push: enviar msg desde Element Web a la cuenta del Android; debe llegar notif via FCM en <3s.
|
||||
|
||||
**Decisiones clave:**
|
||||
- `matrix-rust-sdk` Kotlin bindings > matrix-android-sdk2 (deprecated). Rust-sdk es el futuro oficial de matrix.org.
|
||||
- `livekit-android` nativo > WebRTC.org directo. SDK oficial mantiene mejor performance + features.
|
||||
- Jetpack Compose > XML views. Encaja mejor con reactive model + menos boilerplate.
|
||||
- EncryptedSharedPreferences para tokens MAS. NO usar SharedPreferences plain.
|
||||
- Material 3 con tema propio (paleta similar a Mantine accent del cliente PC para coherencia visual).
|
||||
|
||||
**Camino futuro (post-DoD):**
|
||||
- Wear OS companion app (notifs + quick reply).
|
||||
- Android Auto integration (read msgs voice + reply voice).
|
||||
- Conversation shortcuts API (Android 11+) para que cada room aparezca en share sheet.
|
||||
- Bubble notifications (Android 11+) para conversaciones favoritas.
|
||||
|
||||
**Compartido con flow 0010:**
|
||||
- Contrato `m.widget` y Widget API v2 IDENTICO. Mismo widget html funciona en ambos.
|
||||
- Contrato `m.agent.metadata` para detectar rooms de agentes IDENTICO.
|
||||
- Cuando flow 0009 (mesh) este vivo, ambos clientes hablan a `device_agent` igual.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v0.1.0 (2026-05-24) — baseline.
|
||||
@@ -12,6 +12,7 @@ Tabla de casos de uso multi-app. Mantenida por `/flow create` y `/flow done`.
|
||||
| [0006](0006-metabase-versioning.md) | metabase-versioning | gitops | auto_metabase, dag_engine | pending | medium | 0% | 2026-05-16 |
|
||||
| [0007](0007-matrix-telemetry-bot.md) | matrix-telemetry-bot | event-driven | data_factory, dag_engine, call_monitor, agents_and_robots | pending | low | 0% | 2026-05-16 |
|
||||
| [0008](0008-kanban-cpp-and-agent-workflows.md) | kanban-cpp-and-agent-workflows | realtime-loop | kanban_cpp, kanban, skill_tree, agent_runner_api | pending | medium | 0% | 2026-05-18 |
|
||||
| [0009](0009-agentes-dispositivos-mesh.md) | agentes-dispositivos-mesh | event-driven | agents_dashboard, agents_and_robots, wg_hub, device_agent | pending | high | 0% | 2026-05-23 |
|
||||
|
||||
## Leyenda
|
||||
|
||||
|
||||
+92
-25
@@ -12,49 +12,116 @@ Un flow describe una secuencia de pasos que atraviesa varias apps (`navegator_da
|
||||
- **Definition of Done OBLIGATORIA** — ver seccion abajo. Sin DoD el flow NO puede crearse.
|
||||
- Cerrados se mueven a `completed/`.
|
||||
|
||||
## Definition of Done (OBLIGATORIA)
|
||||
## Definition of Done (OBLIGATORIA — triada)
|
||||
|
||||
Cada flow al crearse DEBE declarar un bloque `## Definition of Done` distinto de `## Acceptance`. Sin el, `/flow create` rechaza el scaffold y `/flow done` rechaza el cierre.
|
||||
|
||||
**Diferencia:**
|
||||
**Regla absoluta**: DoD no es checkbox que se marca a mano. Cada item lleva **evidencia ejecutable** (comando, e2e_check, dashboard URL con datos frescos, log query, screenshot link). Si no puedes probarlo, no es DoD: es deseo. Ver `.claude/rules/dod_quality.md` para la regla completa.
|
||||
|
||||
**Diferencia con `## Acceptance`:**
|
||||
|
||||
| `## Acceptance` | `## Definition of Done` |
|
||||
|---|---|
|
||||
| Checks task-level del flow (ejecucion concreta) | Contrato global de calidad para considerar el flow CERRADO |
|
||||
| Pueden quedar `[ ]` mientras iteras | TODOS deben estar `[x]` antes de mover a `completed/` |
|
||||
| Verifica que el flow CORRE | Verifica que el flow es REPETIBLE, OBSERVABLE y MANTENIBLE |
|
||||
| Checks task-level del flow (el flow corre una vez) | Contrato global de calidad: el flow sobrevive uso real |
|
||||
| Pueden quedar `[ ]` mientras iteras | TODAS las capas verdes con evidencia antes de mover a `completed/` |
|
||||
| Verifica que el flow CORRE | Verifica que el flow es REPETIBLE, OBSERVABLE, MANTENIBLE y USADO |
|
||||
|
||||
**Plantilla minima de DoD** (anadir/ajustar segun flow):
|
||||
### Las 3 capas obligatorias
|
||||
|
||||
**1. Mecanica** (pre-requisito, NO es DoD por si misma):
|
||||
Build verde, tests verdes, `fn index` limpio, `fn doctor` verde, `uses_functions` sin drift. Hacer compilar la cosa NO es haberla terminado.
|
||||
|
||||
**2. Cobertura de comportamiento**:
|
||||
Tabla `escenario | tipo | comando | resultado esperado`. Minimo 1 golden + 2 edge + 1 error path con assert real, no smoke "no peto". Cuando aplique, las pruebas dejan entry en `e2e_runs` de la app afectada.
|
||||
|
||||
**3. Vida util validada**:
|
||||
Tabla `metrica | umbral | dashboard | ventana`. El flow sobrevive **>=7 dias de uso real** sin romperse silenciosamente. Crashes = 0, huecos en audit chains = 0, error_rate < umbral declarado, dashboard observable abierto periodicamente. **El humano usa la cosa en su PC, en su contexto real, >=N veces variadas, no en sandbox aislado**.
|
||||
|
||||
### Plantilla obligatoria
|
||||
|
||||
Ver `template.md` para el esqueleto completo. Bloques:
|
||||
|
||||
```markdown
|
||||
## Definition of Done
|
||||
|
||||
- [ ] **Repetibilidad**: el flow corre N veces consecutivas (N declarado en el flow, default 3) sin intervencion manual.
|
||||
- [ ] **Observabilidad**: queda trazado en `call_monitor.calls` + `data_factory.runs` + dashboard correspondiente.
|
||||
- [ ] **Error-path**: al menos 1 modo de fallo probado y manejado (no crash silencioso).
|
||||
- [ ] **Idempotencia**: re-ejecutar no duplica datos ni rompe estado (clave en sinks).
|
||||
- [ ] **Secrets**: cero credenciales en disco fuera de `pass`/vaults; cero datos sensibles fuera de `risk` declarado.
|
||||
- [ ] **Docs**: `## Notas` rellenado con hallazgos reales + comandos para reproducir.
|
||||
- [ ] **Registry-first**: todas las piezas reutilizables existen como funciones del registry (no inline en apps).
|
||||
- [ ] **INDEX + status**: `status: done` en frontmatter + fila actualizada en `INDEX.md` + archivo movido a `completed/`.
|
||||
### Mecanica
|
||||
- [ ] Build verde (`cmd: ...`)
|
||||
- [ ] Tests verdes (`cmd: ...`)
|
||||
- [ ] fn index limpio
|
||||
- [ ] fn doctor verde
|
||||
- [ ] uses_functions auditado
|
||||
|
||||
### Cobertura de comportamiento
|
||||
| Escenario | Tipo | Comando | Resultado esperado |
|
||||
|---|---|---|---|
|
||||
| Golden: ... | unit/e2e | `cmd` | output concreto |
|
||||
| Edge 1: ... | unit/e2e | `cmd` | comportamiento concreto |
|
||||
| Error 1: ... | e2e | `cmd que rompe` | fallo manejado, no crash |
|
||||
| Error 2: ... | e2e | `cmd` | degradacion graceful + log |
|
||||
|
||||
### Vida util validada
|
||||
| Metrica | Umbral | Donde se observa | Ventana |
|
||||
|---|---|---|---|
|
||||
| <metrica> | `>=N` | `<dashboard URL>` | 7 dias |
|
||||
| crashes | `0` | `journalctl ...` | 7 dias |
|
||||
|
||||
### User-facing (reforzado)
|
||||
- [ ] User-facing surface (lugar concreto, NO BD ni log).
|
||||
- [ ] User-facing usage real: >=N veces en >=7 dias, en PC real, con inputs reales.
|
||||
- [ ] User-facing variado: >=3 capabilities/casos distintos.
|
||||
- [ ] User-facing onboarding (parrafo en `## Notas`).
|
||||
- [ ] User-facing latencia <X medida.
|
||||
|
||||
### Anti-criterios (invalidan DoD aunque checkboxes verdes)
|
||||
- [ ] solo-en-mi-PC
|
||||
- [ ] solo-en-sandbox-vacio
|
||||
- [ ] camino feliz unico (error paths declarados pero nunca ejercitados)
|
||||
- [ ] dashboard fantasma (no abierto en >30 dias)
|
||||
- [ ] self-test sin asserts
|
||||
- [ ] silent-fail
|
||||
- [ ] approval saltado
|
||||
```
|
||||
|
||||
Cada flow puede anadir DoD especificos al dominio (ej. `bbva-movimientos`: "datos NUNCA cruzan a registry.organic-machine"). El bloque DoD se **versiona con el flow** — un cambio de DoD requiere bump de `updated:` en frontmatter.
|
||||
### Reglas duras para marcar `status: done`
|
||||
|
||||
### User-facing surface (sub-bloque OBLIGATORIO dentro de DoD)
|
||||
`/flow done` rechaza el cierre si:
|
||||
|
||||
"DoD verde" sin valor visible al humano = plumbing limpio sin razon de existir. Cada DoD DEBE incluir, al menos, estos cuatro checks tipo `User-facing`:
|
||||
1. Falta alguna de las 3 capas (mecanica + cobertura + vida).
|
||||
2. En Cobertura: <1 golden, <2 edge, <1 error path con evidencia.
|
||||
3. En Vida util: tabla vacia o sin dashboard observable real.
|
||||
4. User-facing usage real <7 dias o <N usos declarados.
|
||||
5. Cualquier anti-criterio marcado como cierto.
|
||||
6. `## Notas` sin parrafo onboarding.
|
||||
7. Algun item sin comando/URL/log query — solo texto.
|
||||
|
||||
```markdown
|
||||
- [ ] **User-facing**: <accion concreta del humano + lugar exacto donde ve/usa el output>.
|
||||
- [ ] **User-facing repeat**: el humano vuelve manana al mismo lugar y ve datos frescos sin conocer el flow.
|
||||
- [ ] **User-facing onboarding**: parrafo en `## Notas` que explica "para ver/usar esto: hacer X" — sin leer el flow.
|
||||
- [ ] **User-facing latencia**: el humano percibe el cambio en <X segundos|minutos> tras el evento (X declarado por flow).
|
||||
```
|
||||
Cada flow puede anadir DoD especificos al dominio. El bloque DoD se **versiona con el flow** — un cambio de DoD requiere bump de `updated:` en frontmatter.
|
||||
|
||||
Regla: si la respuesta a "donde lo ves" es "en una BD" o "en un log" -> NO vale. Tiene que ser una superficie usada por el humano (UI de una app, sala Matrix, dashboard, Metabase card, repo Gitea, archivo en vault abierto a mano). Si el output solo lo consume otra app/flow, esa app/flow es quien debe declarar su propia user-facing surface.
|
||||
### User-facing surface (regla complementaria)
|
||||
|
||||
`/flow done` rechaza el cierre si falta alguno de los 4 user-facing checks o si `## Notas` no contiene parrafo onboarding.
|
||||
Si la respuesta a "donde lo ves" es "en una BD" o "en un log" -> NO vale. Tiene que ser una superficie usada por el humano (UI de app, sala Matrix, dashboard, Metabase card, repo Gitea, archivo en vault abierto a mano). Si el output solo lo consume otra app/flow, esa app/flow declara su propia user-facing surface.
|
||||
|
||||
### Antipatrones documentados
|
||||
|
||||
| Antipatron | Por que es malo |
|
||||
|---|---|
|
||||
| Marcar `done` porque pasa una vez | Tarea "hecha" se rompe al primer uso real |
|
||||
| Checkbox sin evidencia ejecutable | DoD se convierte en placebo, no en gate |
|
||||
| Test que solo verifica camino feliz | El error path es donde se pierden datos en produccion |
|
||||
| Observabilidad declarada pero dashboard no abierto en 30 dias | Telemetria muerta = ceguera |
|
||||
| "Repetible 3 veces consecutivas" con BD efimera | No prueba comportamiento sobre datos reales acumulados |
|
||||
| Aprobacion saltada en algun camino | Security gate roto pero invisible |
|
||||
| Error path manejado solo "en teoria" | Cuando ocurra en produccion el manejo no existe |
|
||||
|
||||
### Validacion programatica de DoD (TBD)
|
||||
|
||||
`/flow done` ejecuta checks programaticos:
|
||||
- Parsea bloques `### Mecanica`, `### Cobertura`, `### Vida util`, `### User-facing`, `### Anti-criterios`.
|
||||
- Verifica que cada item tiene `cmd:` / URL / log query / e2e_check_id asociado.
|
||||
- Cuenta filas en Cobertura: >=1 golden + >=2 edge + >=1 error.
|
||||
- Cruza con `e2e_runs` y `call_monitor.calls` para confirmar evidencias en BDs reales.
|
||||
- Aborta cierre si falta cobertura o algun anti-criterio esta marcado.
|
||||
|
||||
Hoy parte de esto es manual (revision humana). Ver `audit_dod_schema_go_infra` (issue 0114) + `fn doctor dod`.
|
||||
|
||||
### DoD evidence schema (issue 0114, opcional)
|
||||
|
||||
|
||||
+60
-14
@@ -68,28 +68,74 @@ Pasos numerados. Cada paso puede ser:
|
||||
|
||||
## Acceptance
|
||||
|
||||
Checks task-level del flow — verifican que el flow CORRE una vez. Pueden quedar `[ ]` mientras iteras. NO sustituyen a la DoD.
|
||||
|
||||
- [ ] Criterio 1.
|
||||
- [ ] Criterio 2.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
Contrato global de cierre. TODOS marcados antes de mover a `completed/`. Ver README.md seccion "Definition of Done".
|
||||
**Filosofia triada (ver `.claude/rules/dod_quality.md`):** DoD no es checkbox que se marca a mano. Cada item debe llevar **evidencia ejecutable** (comando, e2e_check, screenshot link, dashboard URL con datos frescos, log query). Si no puedes probarlo, no es DoD: es deseo. Las 3 capas son obligatorias.
|
||||
|
||||
- [ ] **Repetibilidad**: corre 3 veces consecutivas sin intervencion manual.
|
||||
- [ ] **Observabilidad**: trazado en `call_monitor.calls` + `data_factory.runs` + dashboard relevante.
|
||||
- [ ] **Error-path**: >=1 modo de fallo probado y manejado.
|
||||
- [ ] **Idempotencia**: re-ejecucion no duplica ni corrompe sinks.
|
||||
- [ ] **Secrets**: cero credenciales fuera de `pass`/vaults; risk declarado coincide con datos reales.
|
||||
- [ ] **Docs**: `## Notas` con hallazgos + comandos reproducibles.
|
||||
- [ ] **Registry-first**: piezas reutilizables viven como funciones del registry.
|
||||
- [ ] **INDEX + status**: `status: done` + `INDEX.md` actualizado + movido a `completed/`.
|
||||
### Mecanica (pre-requisito, NO sustituye al resto)
|
||||
|
||||
### User-facing (obligatorio)
|
||||
Construir verde no es estar hecho. Es la base para empezar a probar.
|
||||
|
||||
- [ ] **User-facing**: <accion concreta del humano + lugar exacto donde ve/usa el output>.
|
||||
- [ ] **User-facing repeat**: humano vuelve manana al mismo lugar, ve datos frescos sin conocer el flow.
|
||||
- [ ] **User-facing onboarding**: parrafo en `## Notas` explica "para ver/usar esto: hacer X" sin leer el flow.
|
||||
- [ ] **User-facing latencia**: humano percibe el cambio en <Xs|Xmin> tras el evento (X declarado).
|
||||
- [ ] **Build verde** (`cmd: <comando build>`).
|
||||
- [ ] **Tests unitarios verdes** (`cmd: <comando test>`, listar IDs/paths).
|
||||
- [ ] **`fn index` limpio** (sin warnings nuevos).
|
||||
- [ ] **`fn doctor` verde** en artefactos tocados (`cmd: ./fn doctor --json | jq ...`).
|
||||
- [ ] **`uses_functions` auditado** (sin drift en `app.md` vs imports reales).
|
||||
|
||||
### Cobertura de comportamiento
|
||||
|
||||
Cada escenario debe tener una prueba ejecutable con assert real (no smoke "no peto"). Anadir tantas filas como casos relevantes — golden path + edge cases + error paths.
|
||||
|
||||
| Escenario | Tipo de prueba | Comando / evidencia | Resultado esperado |
|
||||
|---|---|---|---|
|
||||
| Golden path: <descripcion> | unit / e2e / manual | `<cmd>` | <output concreto, no "ok"> |
|
||||
| Edge case 1: <input limite> | unit / e2e | `<cmd>` | <comportamiento concreto> |
|
||||
| Edge case 2: <estado raro> | unit / e2e | `<cmd>` | <comportamiento concreto> |
|
||||
| Error path 1: <fallo esperado> | e2e | `<cmd que provoca fallo>` | <fallo manejado, no crash> |
|
||||
| Error path 2: <recursos caidos> | e2e | `<cmd>` | <degradacion graceful + log> |
|
||||
|
||||
**Regla**: al menos 1 golden + 2 edge + 1 error path. Tests inscritos en `e2e_runs` de la app correspondiente cuando aplique.
|
||||
|
||||
### Vida util validada
|
||||
|
||||
El flow no esta hecho hasta que sobrevive **uso real** durante >=7 dias sin romperse silenciosamente. Cada metrica tiene umbral medible y dashboard observable.
|
||||
|
||||
| Metrica | Umbral | Donde se observa | Ventana |
|
||||
|---|---|---|---|
|
||||
| <metrica 1, ej. handshakes vivos> | `>=N` | `<dashboard URL / app panel>` | 7 dias |
|
||||
| <metrica 2, ej. error_rate> | `<X%` | `<dashboard URL>` | 7 dias |
|
||||
| <metrica 3, ej. duracion p95> | `<Xms` | `call_monitor.function_stats` | 30 dias |
|
||||
| crashes del proceso | `0` | `journalctl -u <unit>` | 7 dias |
|
||||
| huecos en audit chain | `0` | `cmd: <verify chain>` | continuo |
|
||||
|
||||
**Regla**: las metricas NO se autoreportan en el flow; las lee el operador del dashboard real. Si el dashboard no existe, el item se invalida.
|
||||
|
||||
### User-facing (reforzado)
|
||||
|
||||
"DoD verde" sin uso humano real = plumbing limpio sin razon de existir.
|
||||
|
||||
- [ ] **User-facing surface**: <accion concreta del humano + lugar exacto donde ve/usa el output (NO una BD, NO un log)>.
|
||||
- [ ] **User-facing usage real**: el humano (operador) usa la cosa en **su PC, en su contexto real**, **>=N veces en >=7 dias**, con inputs reales (no demo, no sandbox).
|
||||
- [ ] **User-facing variado**: cubre >=3 capabilities/casos distintos (no solo "abre dashboard y mira").
|
||||
- [ ] **User-facing onboarding**: parrafo en `## Notas` que explica "para ver/usar esto: hacer X" sin leer el flow.
|
||||
- [ ] **User-facing latencia**: percepcion del cambio <Xs|Xmin> tras el evento (X declarado, medido).
|
||||
|
||||
### Anti-criterios (invalidan la DoD aunque los checkboxes esten verdes)
|
||||
|
||||
Marca el flow como **NO done** si cualquiera de estas condiciones es cierta:
|
||||
|
||||
- [ ] **Solo-en-mi-PC**: el flow funciona en `home-wsl` pero falla en `pc-aurgi` u otro PC del operador.
|
||||
- [ ] **Repetible-en-sandbox-vacio**: solo pasa con BD limpia / cuenta limpia / sin datos historicos.
|
||||
- [ ] **Camino feliz unico**: los error paths fueron declarados pero NUNCA se ejercitaron (sin entry en `e2e_runs` o logs reales).
|
||||
- [ ] **Dashboard fantasma**: el dashboard declarado en "Vida util" no se ha abierto en >30 dias.
|
||||
- [ ] **Self-test que no apesta**: el `e2e_check` retorna `pass` sin verificar nada material (no asserts).
|
||||
- [ ] **Silent-fail**: el proceso muere/degrada sin alerta visible al operador.
|
||||
- [ ] **Approval saltado**: alguna capability con `requires_approval=true` fue ejecutada sin el approval flow.
|
||||
|
||||
### Custom (opcional, dominio-especifico)
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
id: "0126"
|
||||
title: "pipeline_launcher: aplicar migracion 003_logs a operations.db"
|
||||
status: pendiente
|
||||
type: bugfix
|
||||
domain:
|
||||
- apps-infra
|
||||
scope: app
|
||||
priority: baja
|
||||
depends: []
|
||||
blocks: []
|
||||
related:
|
||||
- "0121a"
|
||||
created: 2026-05-19
|
||||
updated: 2026-05-19
|
||||
tags: [pipeline_launcher, migrations, db]
|
||||
---
|
||||
|
||||
# 0126 — pipeline_launcher migracion 003_logs
|
||||
|
||||
Origen: detectado lateral por `fn-recopilador design-e2e apps/pipeline_launcher` en 0121a.
|
||||
|
||||
## Problema
|
||||
|
||||
`apps/pipeline_launcher/operations.db` tiene migraciones 001+002 aplicadas pero falta 003_logs (definida en `fn_operations/migrations/003_logs.sql`). La tabla `logs` no existe → cualquier feature futuro de logging in-app falla silencioso.
|
||||
|
||||
Investigacion necesaria: por que no aplico? Probable que pipeline_launcher use version vieja del codigo `fn_operations` o tenga su propio applier que no lee la migracion 003.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Diagnosticar por que 003 no aplico (busca `applyMigrations` en codigo de pipeline_launcher o si usa la libreria `fn_operations`).
|
||||
2. Aplicar 003 a la BD existente preservando datos.
|
||||
3. Si pipeline_launcher tiene applier custom, hacerlo consumir las migraciones del registry padre via `embed.FS`.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Inspeccionar `apps/pipeline_launcher/{main.go, db.go, store.go}` para localizar applier.
|
||||
2. Aplicar `003_logs.sql` manualmente: `sqlite3 apps/pipeline_launcher/operations.db < fn_operations/migrations/003_logs.sql`.
|
||||
3. Si custom applier: refactor para consumir migraciones del padre.
|
||||
4. Verificar con `PRAGMA table_info(logs);` que la tabla existe.
|
||||
5. Actualizar propuesta 0121a `pipeline_launcher.yaml` removiendo check `ops_schema_complete` (ya no aplica).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] `sqlite3 apps/pipeline_launcher/operations.db "PRAGMA table_info(logs);"` devuelve columnas esperadas.
|
||||
- [ ] Reaplicar 003 sobre BD ya migrada NO falla (idempotente — `CREATE TABLE IF NOT EXISTS`).
|
||||
- [ ] Tests de pipeline_launcher pasan (si existen).
|
||||
|
||||
## DoD
|
||||
|
||||
- **Donde**: sqlite3 introspeccion + log de la app si tiene.
|
||||
- **Latencia**: invisible al usuario.
|
||||
- **Onboarding**: "Si una app tiene operations.db, las migraciones del registry padre se aplican al arrancar — verificar con `PRAGMA table_info`."
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
id: "0128"
|
||||
title: "kanban: adjuntar archivos (drag&drop desc/chat + tab Archivos)"
|
||||
status: in_progress
|
||||
type: feature
|
||||
domain:
|
||||
- apps-tools
|
||||
- frontend
|
||||
scope: app
|
||||
priority: media
|
||||
depends: []
|
||||
blocks: []
|
||||
related: []
|
||||
created: 2026-05-27
|
||||
updated: 2026-05-27
|
||||
tags: [kanban, files, upload, sqlite, mantine]
|
||||
---
|
||||
|
||||
# 0128 — kanban: adjuntos de archivos en cards
|
||||
|
||||
Hoy el tab "Archivos" del `CardEditPanel` esta disabled ("Proximamente"). Se habilita con tres vias de upload y vista agregada estilo `CardLinksPanel`.
|
||||
|
||||
## Alcance
|
||||
|
||||
- Adjuntar archivos a una card desde:
|
||||
1. Drag&drop en el editor de descripcion → inserta markdown ref.
|
||||
2. Drag&drop / boton paperclip en el chat → mensaje con ref.
|
||||
3. Boton "Subir" en el tab Archivos (sin embed).
|
||||
- Render inline en chat y descripcion:
|
||||
- Imagenes (png/jpg/webp/gif): thumb clickable.
|
||||
- PDFs, excel, csv, txt, resto: chip con icono + nombre + size.
|
||||
- Tab "Archivos" agrega:
|
||||
- Uploads directos sobre la card.
|
||||
- Refs detectadas en `description`.
|
||||
- Refs detectadas en mensajes del chat.
|
||||
- MIME soportado: cualquiera. Limite 10 MB por archivo. Sin quota agregada.
|
||||
- Borrado: cualquier usuario del board borra. Soft delete (`deleted_at`). Cron purge fuera de scope.
|
||||
|
||||
## Backend
|
||||
|
||||
- Migracion `backend/migrations/014_card_files.sql` (aditiva, idempotente):
|
||||
- `card_files(id TEXT PK, card_id TEXT FK, uploader_id TEXT, filename TEXT, mime TEXT, size INTEGER, stored_path TEXT, source TEXT, created_at TEXT, deleted_at TEXT NULL)`
|
||||
- `source IN ('upload','description','chat')` — informativo, no condiciona logica.
|
||||
- Index `(card_id, deleted_at)`.
|
||||
- Endpoints nuevos en `backend/files.go`:
|
||||
- `POST /api/cards/{id}/files` multipart, max 10MB, devuelve metadata.
|
||||
- `GET /api/cards/{id}/files` lista activa (deleted_at IS NULL).
|
||||
- `GET /api/files/{id}` sirve binario con Content-Type + Content-Disposition.
|
||||
- `DELETE /api/files/{id}` soft delete.
|
||||
- Storage en disco: `apps/kanban/uploads/<card_id>/<file_id>__<safe_filename>`.
|
||||
- `apps/kanban/uploads/` gitignored en el sub-repo.
|
||||
|
||||
## Frontend
|
||||
|
||||
- `CardFilesPanel.tsx` (replica de `CardLinksPanel`):
|
||||
- Carga `/api/cards/{id}/files` al montar.
|
||||
- Detecta refs en `description` + mensajes (regex sobre `/api/files/<id>`).
|
||||
- Render grid: imagenes en `<Image>` Mantine como thumb 120px, resto como chip con `IconFile*` segun MIME.
|
||||
- Boton borrar por archivo (confirm modal).
|
||||
- Boton "Subir" → input file → POST.
|
||||
- `CardChatPanel`: dropzone + boton paperclip. Tras upload, inyecta mensaje con `` (imagen) o `[name](url)` (resto).
|
||||
- `CardForm` (editor desc): `<Dropzone>` Mantine envolviendo el textarea. Tras upload, insertar ref en posicion del cursor.
|
||||
- Render inline en chat: parser markdown ya existente (revisar) o componente simple. Imagenes via `<Image fit="contain" maw={200}>`. Resto chip.
|
||||
|
||||
## Tests
|
||||
|
||||
- `e2e/files_smoke.sh` (bash):
|
||||
- Login.
|
||||
- Crear card.
|
||||
- POST imagen 1KB → asserts 200 + JSON con id.
|
||||
- GET lista archivos → asserts 1 elemento.
|
||||
- GET binario → asserts content-type image/png.
|
||||
- DELETE → asserts 204.
|
||||
- GET lista → asserts 0 elementos.
|
||||
|
||||
## Versionado
|
||||
|
||||
- Bump `apps/kanban/app.md` 0.4.0 → 0.5.0.
|
||||
- Anadir entrada en `## Capability growth log`:
|
||||
`v0.5.0 (2026-05-27) — adjuntos de archivos por card (issue 0128): drag&drop en desc/chat, tab Archivos agregado, soft delete, 10MB max`.
|
||||
|
||||
## DoD
|
||||
|
||||
- [ ] Migracion aplicada, schema verificable con `sqlite3 operations.db ".schema card_files"`.
|
||||
- [ ] 4 endpoints responden segun spec (testeados con curl).
|
||||
- [ ] Tab Archivos lista uploads + refs.
|
||||
- [ ] Drag&drop funciona en desc y en chat.
|
||||
- [ ] Render inline de imagenes en chat y desc.
|
||||
- [ ] Soft delete oculta el archivo de la lista y los embeds rompen (esperado).
|
||||
- [ ] e2e smoke pasa.
|
||||
- [ ] PR draft a `dataforge/kanban`.
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
id: "0130"
|
||||
title: Kanban C++ v2 — gestor de dev/issues y dev/flows con backend Go + frontend ImGui
|
||||
status: pendiente
|
||||
type: epic
|
||||
domain:
|
||||
- cpp-stack
|
||||
- apps-infra
|
||||
- dev-ux
|
||||
scope: multi-app
|
||||
priority: alta
|
||||
depends: []
|
||||
blocks: []
|
||||
related:
|
||||
- "0112"
|
||||
- "0119"
|
||||
tags:
|
||||
- kanban
|
||||
- cpp
|
||||
- imgui
|
||||
- dev_ux
|
||||
- issues
|
||||
- flows
|
||||
created: "2026-05-22"
|
||||
updated: "2026-05-22"
|
||||
---
|
||||
|
||||
# 0130 — Kanban C++ v2
|
||||
|
||||
**Status:** pendiente
|
||||
|
||||
## Por que
|
||||
|
||||
La v1 (`apps/kanban_cpp` borrada el 2026-05-22) mezclaba paneles ajenos al dominio kanban (agent runs, DoD, worktrees, calendar) y un backend que no era reutilizable. Para gestionar los 98 issues activos + 12 flows del proyecto necesitamos una vista board nativa, sin web, con edicion bidireccional de los archivos markdown.
|
||||
|
||||
## Que entrega
|
||||
|
||||
App kanban_cpp v2 con dos piezas:
|
||||
|
||||
1. **Backend Go** (`apps/kanban_cpp/backend/`) — service HTTP en puerto 8487.
|
||||
- Parser bidireccional MD <-> SQLite (cache).
|
||||
- Watcher fsnotify sobre `dev/issues/` (+ `completed/`) y `dev/flows/`.
|
||||
- Endpoints REST: `/api/issues`, `/api/issues/{id}` (GET/PATCH), `/api/flows`, `/api/flows/{id}`, `/api/meta`, `/api/sse`.
|
||||
- PATCH a issue reescribe el frontmatter en disco preservando body + orden de campos.
|
||||
|
||||
2. **Frontend C++ ImGui** (`apps/kanban_cpp/`) sobre el framework `fn::run_app`.
|
||||
- Panel **Board**: columnas por status (pendiente / in-progress / bloqueado / completado). Drag-drop = PATCH status.
|
||||
- Panel **Flows**: lista de flows con detalle.
|
||||
- Panel **Filtros** (Aside): multi-select domain, scope, priority, tags.
|
||||
- Panel **Detalle**: edicion de campos frontmatter de un issue (status, priority, scope, tags, depends, blocks).
|
||||
- SSE para refrescar tras cambios externos en disco.
|
||||
|
||||
## Sub-issues
|
||||
|
||||
- **0130a** — parser MD + scan dirs (funciones registry).
|
||||
- **0130b** — backend Go: schema + handlers + watcher + SSE.
|
||||
- **0130c** — frontend C++: paneles + http client.
|
||||
|
||||
Cada sub-issue mergeable independiente en su rama corta TBD.
|
||||
|
||||
## Reusa del registry
|
||||
|
||||
Backend Go:
|
||||
- `sqlite_open_go_infra`, `sqlite_apply_migrations_go_infra`
|
||||
- `http_router_go_infra`, `http_serve_go_infra`, `http_middleware_chain_go_infra`
|
||||
- `http_cors_middleware_go_infra`, `http_logger_middleware_go_infra`
|
||||
- `http_json_response_go_infra`, `http_error_response_go_infra`, `http_parse_body_go_infra`
|
||||
- `random_hex_id_go_core`
|
||||
|
||||
Frontend C++:
|
||||
- `http_request_cpp_core`
|
||||
- `sse_client_cpp_core`
|
||||
- `data_table_cpp_viz` (lista flows)
|
||||
- `kpi_card_cpp_viz` (contadores por status)
|
||||
|
||||
## Crea (delegadas a fn-constructor en 0130a)
|
||||
|
||||
- `parse_issue_md_go_infra` — lee .md → struct (frontmatter YAML + body).
|
||||
- `write_issue_md_go_infra` — escribe struct → .md preservando body + orden de campos.
|
||||
- `scan_issues_dir_go_infra` — walk `dev/issues/` + `dev/issues/completed/`.
|
||||
- `scan_flows_dir_go_infra` — walk `dev/flows/`.
|
||||
- `watch_dir_fsnotify_go_infra` (si no existe) — events channel.
|
||||
|
||||
## DoD
|
||||
|
||||
- `fn doctor` verde para ambas apps (artefacts + e2e).
|
||||
- `e2e_checks` en ambos `app.md` (build + health + self-test).
|
||||
- Drag-drop en frontend reescribe el `.md` correspondiente y `git diff` lo muestra (solo frontmatter, body intacto).
|
||||
- Trio obligatorio (`description` + `icon.phosphor` + `icon.accent`) en ambos `app.md`.
|
||||
- Sub-repos Gitea creados (`dataforge/kanban_cpp` reactivado o nuevo, mismo nombre).
|
||||
|
||||
dod_evidence_schema:
|
||||
- id: backend_health
|
||||
kind: cmd
|
||||
expected: "curl -fsS http://localhost:8487/api/health == 200"
|
||||
required: true
|
||||
- id: api_issues_count
|
||||
kind: cmd
|
||||
expected: "curl -fsS http://localhost:8487/api/issues | jq 'length' >= 90"
|
||||
required: true
|
||||
- id: patch_writes_md
|
||||
kind: cmd
|
||||
expected: "PATCH /api/issues/0130 status=in-progress reescribe dev/issues/0130-*.md (git diff muestra solo status)"
|
||||
required: true
|
||||
- id: frontend_self_test
|
||||
kind: cmd
|
||||
expected: "./cpp/build/linux/apps/kanban_cpp/kanban_cpp --self-test exit 0"
|
||||
required: true
|
||||
- id: board_screenshot
|
||||
kind: screenshot
|
||||
expected: "kanban_cpp Board panel con 4 columnas pobladas con issues reales"
|
||||
required: true
|
||||
|
||||
## Anti-scope
|
||||
|
||||
NO incluye en esta version:
|
||||
- Grafo de dependencias (depends/blocks/related visual).
|
||||
- Edicion de body MD desde la app (solo frontmatter).
|
||||
- Multi-PC sync (backend es local).
|
||||
- Crear issues nuevos desde la UI (solo editar existentes).
|
||||
- DoD evidence panel, agent runs, calendar, worktrees (la v1 los mezclaba — fuera).
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
id: 0130a
|
||||
title: 'Funciones registry: parser MD + scan dirs + writer + watcher'
|
||||
status: pendiente
|
||||
type: infra
|
||||
domain:
|
||||
- registry-quality
|
||||
- dev-ux
|
||||
scope: registry-only
|
||||
priority: alta
|
||||
depends: []
|
||||
blocks:
|
||||
- 0130b
|
||||
related:
|
||||
- "0130"
|
||||
tags:
|
||||
- registry
|
||||
- go
|
||||
- parser
|
||||
- frontmatter
|
||||
- fsnotify
|
||||
flow: "0130"
|
||||
created: "2026-05-22"
|
||||
updated: "2026-05-22"
|
||||
---
|
||||
|
||||
# 0130a — Funciones registry para kanban_cpp v2
|
||||
|
||||
**Status:** pendiente
|
||||
|
||||
## Por que
|
||||
|
||||
El backend de kanban_cpp v2 necesita parsear/escribir frontmatter YAML de los `.md` de `dev/issues/` y `dev/flows/`. Estas piezas son reusables (cualquier app del registry puede operar sobre issues/flows), asi que viven en el registry, no en el backend de la app.
|
||||
|
||||
## Funciones a crear (delegar a fn-constructor en paralelo)
|
||||
|
||||
| ID | Firma | Pureza |
|
||||
|---|---|---|
|
||||
| `parse_issue_md_go_infra` | `(path string) (Issue, []byte body, error)` | impure (FS) |
|
||||
| `write_issue_md_go_infra` | `(path string, issue Issue, body []byte) error` | impure (FS) |
|
||||
| `scan_issues_dir_go_infra` | `(root string) ([]Issue, error)` | impure (FS) |
|
||||
| `scan_flows_dir_go_infra` | `(root string) ([]Flow, error)` | impure (FS) |
|
||||
| `watch_dir_fsnotify_go_infra` | `(ctx, root) (<-chan FsEvent, error)` | impure (FS, async) |
|
||||
|
||||
Tipos:
|
||||
- `Issue_go_infra` — struct con campos del frontmatter (id, title, status, type, domain, scope, priority, depends, blocks, related, flow, tags, created, updated, file_path, mtime_ns).
|
||||
- `Flow_go_infra` — struct equivalente para flows.
|
||||
- `FsEvent_go_infra` — `{path, op}` con `op in {create, write, remove, rename}`.
|
||||
|
||||
## Notas de implementacion
|
||||
|
||||
- Usar `gopkg.in/yaml.v3` para parsing (preserva orden de keys via `yaml.Node`).
|
||||
- Writer DEBE preservar:
|
||||
- Orden de campos del frontmatter original.
|
||||
- Body MD intacto (todo lo que va despues del segundo `---`).
|
||||
- Comentarios YAML (libre, best-effort).
|
||||
- `parse_issue_md` debe ser tolerante: si falta un campo opcional, default empty.
|
||||
- `watch_dir_fsnotify` recursivo, debounce 200ms.
|
||||
|
||||
## DoD
|
||||
|
||||
- 5 pares `.go` + `.md` en `functions/infra/`.
|
||||
- Tests unitarios:
|
||||
- parse → write → parse round-trip preserva struct.
|
||||
- scan_issues_dir devuelve >=90 issues actuales.
|
||||
- watcher detecta creacion + modificacion + borrado.
|
||||
- `fn index` registra los 5 IDs + 3 tipos.
|
||||
- `fn doctor uses-functions` limpio.
|
||||
|
||||
## Anti-scope
|
||||
|
||||
NO incluye en esta tanda:
|
||||
- Markdown rendering del body (eso lo hace el frontend si quiere).
|
||||
- Validacion contra TAXONOMY (existe `fn doctor issues`).
|
||||
- CRUD de issues nuevos (write_issue cubre el caso, pero crear file = scope del backend).
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
id: "0130b"
|
||||
title: "Backend Go kanban_cpp v2: schema + handlers + watcher + SSE"
|
||||
status: pendiente
|
||||
type: app
|
||||
domain:
|
||||
- apps-infra
|
||||
- dev-ux
|
||||
scope: app-scoped
|
||||
priority: alta
|
||||
depends:
|
||||
- "0130a"
|
||||
blocks:
|
||||
- "0130c"
|
||||
related:
|
||||
- "0130"
|
||||
created: 2026-05-22
|
||||
updated: 2026-05-22
|
||||
tags: [service, kanban, go, sqlite, sse]
|
||||
flow: "0130"
|
||||
---
|
||||
|
||||
# 0130b — Backend Go kanban_cpp v2
|
||||
|
||||
**Status:** pendiente
|
||||
|
||||
## Por que
|
||||
|
||||
Servicio HTTP local que sirve los issues + flows del proyecto al frontend C++. Es un wrapper fino sobre las funciones del registry de 0130a + SQLite cache + watcher.
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
apps/kanban_cpp/backend/
|
||||
app.md # tag service
|
||||
go.mod
|
||||
main.go # entry: flags + run
|
||||
db.go # open + apply migrations + upsert helpers
|
||||
handlers.go # endpoints REST
|
||||
sse_hub.go # broadcaster
|
||||
watcher.go # bind a watch_dir_fsnotify + re-ingesta + emit SSE
|
||||
ingest.go # scan → upsert; usa 0130a
|
||||
migrations/
|
||||
001_init.sql
|
||||
operations.db # creada en runtime
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Verbo | Path | Notas |
|
||||
|---|---|---|
|
||||
| GET | `/api/health` | `{ok:true, version, count_issues, count_flows}` |
|
||||
| GET | `/api/issues` | filtros: `status`, `domain`, `priority`, `tag`, `scope` |
|
||||
| GET | `/api/issues/{id}` | issue + body |
|
||||
| PATCH | `/api/issues/{id}` | partial update frontmatter → `write_issue_md` + re-ingesta + SSE |
|
||||
| GET | `/api/flows` | filtros: `status`, `kind` |
|
||||
| GET | `/api/flows/{id}` | flow + body |
|
||||
| GET | `/api/meta` | enums leidos de `dev/TAXONOMY.md` |
|
||||
| GET | `/api/sse` | stream `{type, id, path}` |
|
||||
|
||||
CORS abierto local (`*`). Logger middleware.
|
||||
|
||||
## Schema (migrations/001_init.sql)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS issues (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
type TEXT,
|
||||
scope TEXT,
|
||||
priority TEXT,
|
||||
domain_json TEXT NOT NULL DEFAULT '[]',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
depends_json TEXT NOT NULL DEFAULT '[]',
|
||||
blocks_json TEXT NOT NULL DEFAULT '[]',
|
||||
related_json TEXT NOT NULL DEFAULT '[]',
|
||||
flow_id TEXT,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
file_path TEXT NOT NULL,
|
||||
mtime_ns INTEGER NOT NULL,
|
||||
created_at TEXT,
|
||||
updated_at TEXT,
|
||||
completed INTEGER NOT NULL DEFAULT 0 -- 1 si vive en completed/
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_priority ON issues(priority);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flows (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT,
|
||||
kind TEXT,
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
file_path TEXT NOT NULL,
|
||||
mtime_ns INTEGER NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
## DoD
|
||||
|
||||
- `curl http://localhost:8487/api/health` devuelve 200 + counts.
|
||||
- `curl http://localhost:8487/api/issues | jq 'length' >= 90`.
|
||||
- `curl -X PATCH /api/issues/0130 -d '{"status":"in-progress"}'` reescribe `dev/issues/0130-*.md` (status updated, body intacto).
|
||||
- Despues del PATCH, suscriptor SSE recibe evento `{type:"updated", id:"0130"}`.
|
||||
- Tras `mv dev/issues/0130-*.md dev/issues/completed/`, watcher actualiza fila (`completed=1`).
|
||||
- `go test ./...` verde.
|
||||
|
||||
## Anti-scope
|
||||
|
||||
- No expone proposals ni capabilities (eso es MCP registry).
|
||||
- No autentica (local-only por ahora).
|
||||
- No persiste estado UI (eso lo hace el frontend).
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
id: "0130c"
|
||||
title: "Frontend C++ ImGui kanban_cpp v2: board + flows + filtros + detalle"
|
||||
status: pendiente
|
||||
type: app
|
||||
domain:
|
||||
- cpp-stack
|
||||
- dev-ux
|
||||
scope: app-scoped
|
||||
priority: alta
|
||||
depends:
|
||||
- "0130b"
|
||||
blocks: []
|
||||
related:
|
||||
- "0130"
|
||||
created: 2026-05-22
|
||||
updated: 2026-05-22
|
||||
tags: [cpp, imgui, kanban, frontend]
|
||||
flow: "0130"
|
||||
---
|
||||
|
||||
# 0130c — Frontend C++ ImGui kanban_cpp v2
|
||||
|
||||
**Status:** pendiente
|
||||
|
||||
## Por que
|
||||
|
||||
UI nativa sobre el backend 0130b. Aprovecha el framework `fn::run_app` (menubar, layouts, settings, about, log) y los componentes del registry (`data_table`, `kpi_card`, `http_request`, `sse_client`).
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
apps/kanban_cpp/
|
||||
app.md
|
||||
appicon.ico
|
||||
CMakeLists.txt
|
||||
main.cpp # fn::run_app + cfg.panels
|
||||
data.h / data.cpp # http client + state global (issues, flows, filters)
|
||||
panel_board.cpp # 4 columnas + drag-drop
|
||||
panel_flows.cpp # tabla via data_table_cpp_viz
|
||||
panel_filters.cpp # Aside con multi-select
|
||||
panel_detail.cpp # form editable del issue seleccionado
|
||||
panels.h
|
||||
```
|
||||
|
||||
## Trio obligatorio (`app.md`)
|
||||
|
||||
```yaml
|
||||
description: "Kanban C++ v2 para gestionar dev/issues y dev/flows del registry"
|
||||
icon:
|
||||
phosphor: "kanban"
|
||||
accent: "#a855f7"
|
||||
```
|
||||
|
||||
## Paneles
|
||||
|
||||
1. **Board** (`TI_KANBAN " Board"`) — 4 columnas (pendiente / in-progress / bloqueado / completado). Cada card: id + title (trunc 60) + priority badge + first domain chip. Drag-drop con `ImGui::BeginDragDropSource/Target` -> PATCH status.
|
||||
2. **Flows** (`TI_FLOW " Flows"`) — `data_table_cpp_viz` con columnas id/title/status/kind. Click fila → carga detail.
|
||||
3. **Filters** (`TI_FUNNEL " Filters"`) — AppShell.Aside-equivalente (panel lateral fijo). Multi-select por domain, scope, priority, tags. Estado local; rebuild request query.
|
||||
4. **Detail** (`TI_INFO " Detail"`) — modal/panel lateral con form: status (combo), priority (combo), scope (combo), tags (chips editables), depends/blocks (listas), body (read-only multiline).
|
||||
|
||||
## HTTP client (data.cpp)
|
||||
|
||||
- `fetch_issues(filters)` → GET con query string → parse JSON → vector<Issue>.
|
||||
- `fetch_flows()` → similar.
|
||||
- `patch_issue(id, partial)` → PATCH JSON → recibe issue actualizado.
|
||||
- `subscribe_sse()` thread aparte → push events a queue mutex → consumir en main loop → re-fetch afectados.
|
||||
|
||||
Usa `http_request_cpp_core` + `sse_client_cpp_core`. JSON via `nlohmann/json` (ya en cpp/vendor o sacar al header-only).
|
||||
|
||||
## DoD
|
||||
|
||||
- `cmake --build cpp/build/linux --target kanban_cpp -j` verde.
|
||||
- `./cpp/build/linux/apps/kanban_cpp/kanban_cpp --self-test` exit 0:
|
||||
- inicializa contexto ImGui sin display.
|
||||
- parsea respuesta JSON sintetica.
|
||||
- no toca red salvo si `--backend http://...` se pasa.
|
||||
- e2e_checks en `app.md`: build + self_test + backend_health (corre backend en background) + smoke (drag-drop reescribe MD).
|
||||
- Captura screenshot board con 4 columnas pobladas → guardar en `dod_evidence/board_screenshot.png`.
|
||||
|
||||
## Anti-scope
|
||||
|
||||
- Sin grafo de dependencias (epic 0130 lo describe como anti-scope v1).
|
||||
- Sin crear issues nuevos (solo editar existentes).
|
||||
- Sin edicion de body MD (solo frontmatter).
|
||||
- Sin syntax highlighting markdown.
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
id: "0131"
|
||||
title: "Modulo C++ chat_panel — panel ImGui para chat con agentes"
|
||||
status: pendiente
|
||||
type: app
|
||||
domain:
|
||||
- cpp-stack
|
||||
- agents
|
||||
- dev-ux
|
||||
scope: cross-stack
|
||||
priority: alta
|
||||
depends:
|
||||
- "0113"
|
||||
blocks: []
|
||||
related:
|
||||
- "0130"
|
||||
created: 2026-05-22
|
||||
updated: 2026-05-22
|
||||
tags: [cpp, imgui, agents, chat, module, sse]
|
||||
flow: ""
|
||||
---
|
||||
|
||||
# 0131 — Modulo C++ chat_panel
|
||||
|
||||
**Status:** pendiente
|
||||
|
||||
## Por que
|
||||
|
||||
Tras lanzar un agente desde kanban_cpp (issue 0130), no hay forma de interactuar con el desde la propia app. Hoy el flujo es: lanzar agente, abrir terminal aparte, `tail -f /tmp/wt-.../agent.log`. Queremos un panel C++ reutilizable que cualquier app embebra para chatear con un agente (Claude headless o futuros) y ver su output en streaming.
|
||||
|
||||
## Que entrega
|
||||
|
||||
Modulo `cpp/functions/viz/chat_panel/` (paquete del registry, kind: function, lang: cpp, domain: viz). API:
|
||||
|
||||
```cpp
|
||||
namespace fn_chat {
|
||||
struct ChatPanel {
|
||||
// run_id del agent_runner_api; null = panel vacio "no agent attached"
|
||||
std::string run_id;
|
||||
std::string backend_url = "http://127.0.0.1:8486"; // agent_runner_api
|
||||
bool auto_scroll = true;
|
||||
};
|
||||
void render(ChatPanel& panel);
|
||||
}
|
||||
```
|
||||
|
||||
Comportamiento:
|
||||
- Conecta SSE `/api/runs/<run_id>/sse` en background thread (reusa `sse_client_cpp_core`).
|
||||
- Parsea eventos `state`, `log`, `evidence`, `finish` y los renderiza:
|
||||
- `log` → linea cruda en buffer scrollable.
|
||||
- `state` → badge superior con status (`pending/running/done/aborted/failed`).
|
||||
- `evidence` → chip lateral con kind + payload_url.
|
||||
- `finish` → marca run terminada, deja conexion para ver historico.
|
||||
- Input box inferior (multiline) + boton "Send". POST a `/api/runs/<run_id>/message` (endpoint A IMPLEMENTAR en agent_runner_api — extension paralela; si no existe, boton se deshabilita).
|
||||
- Toolbar: `Abort run`, `Clear buffer`, `Show evidence panel`.
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
cpp/functions/viz/chat_panel/
|
||||
chat_panel.h
|
||||
chat_panel.cpp
|
||||
chat_panel.md
|
||||
chat_panel_test.cpp
|
||||
```
|
||||
|
||||
## Reusa del registry
|
||||
|
||||
- `sse_client_cpp_core` — SSE async.
|
||||
- `http_request_cpp_core` — POST mensajes / abort.
|
||||
- `selectable_text_cpp_viz` — copy log lines.
|
||||
- `data_table_cpp_viz` — opcional para tabla de evidencias.
|
||||
|
||||
## DoD
|
||||
|
||||
- Modulo compila en Linux + Windows.
|
||||
- Demo en `primitives_gallery` o app dedicada `agent_chat_demo` con run_id fijo + mock SSE feeder.
|
||||
- Integracion en kanban_cpp v2: nuevo panel "Chat" que se abre al hacer click en card con agent_active, run_id se pasa automatico.
|
||||
- `e2e_checks`: smoke con mock SSE; assertion: tras emitir 3 eventos de log, panel los muestra en orden.
|
||||
|
||||
## Anti-scope (v1)
|
||||
|
||||
- No persiste history local (refresh = perdemos buffer; agent.log es la fuente).
|
||||
- No syntax highlight markdown / codigo.
|
||||
- Sin multi-run (un panel = un run).
|
||||
- Sin file diff inline (kind:evidence con kind:diff queda como link a `git show`).
|
||||
|
||||
## Notas
|
||||
|
||||
Si el endpoint POST `/api/runs/:id/message` no existe en agent_runner_api, abrir issue paralelo `0131b` para anadirlo (claude headless aceptara mensajes via stdin del subprocess — el runner debe forwardearlos). Para v1 se acepta panel read-only.
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
id: "0132"
|
||||
title: "Modulo C++ terminal_panel — emulador TTY ImGui embebible"
|
||||
status: pendiente
|
||||
type: app
|
||||
domain:
|
||||
- cpp-stack
|
||||
- dev-ux
|
||||
- apps-infra
|
||||
scope: cross-stack
|
||||
priority: alta
|
||||
depends: []
|
||||
blocks: []
|
||||
related:
|
||||
- "0130"
|
||||
- "0131"
|
||||
created: 2026-05-22
|
||||
updated: 2026-05-22
|
||||
tags: [cpp, imgui, terminal, pty, module]
|
||||
flow: ""
|
||||
---
|
||||
|
||||
# 0132 — Modulo C++ terminal_panel
|
||||
|
||||
**Status:** pendiente
|
||||
|
||||
## Por que
|
||||
|
||||
Apps del ecosistema (kanban_cpp, services_monitor, agents_dashboard) necesitan ver output crudo de comandos shell sin abrir un terminal externo. Tipico: tail de un log, watch de un curl, ejecutar `git status` rapido. Solucion estandar: modulo `terminal_panel` reusable que arranca un shell hijo via PTY y lo renderiza en ImGui.
|
||||
|
||||
## Que entrega
|
||||
|
||||
Modulo `cpp/functions/viz/terminal_panel/`:
|
||||
|
||||
```cpp
|
||||
namespace fn_term {
|
||||
struct TerminalPanel {
|
||||
std::string shell; // "/bin/bash" linux, "powershell.exe" windows; default auto
|
||||
std::string cwd; // working dir; default = current
|
||||
std::vector<std::string> env; // KEY=VAL extras
|
||||
int scrollback_lines = 5000;
|
||||
bool readonly = false; // true = no input forwarding (tail-only)
|
||||
};
|
||||
void open(TerminalPanel& panel); // crea proceso hijo + PTY
|
||||
void render(TerminalPanel& panel);
|
||||
void send(TerminalPanel& panel, const std::string& text); // stdin
|
||||
void close(TerminalPanel& panel);
|
||||
}
|
||||
```
|
||||
|
||||
Implementacion:
|
||||
- Linux: `forkpty` + `read/write` non-blocking en background thread.
|
||||
- Windows: ConPTY (CreatePseudoConsole) + ReadFile en thread.
|
||||
- Buffer circular `scrollback_lines` filas; render con `ImGui::TextUnformatted` por chunk para minimizar costo.
|
||||
- Soporte minimo de ANSI: cursor pos, color FG/BG basico (16 colores), clear screen. NO soporte completo (no Vim, no top, no curses pesado).
|
||||
- Toolbar: clear, copy selection, reset shell, scroll-to-bottom.
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
cpp/functions/viz/terminal_panel/
|
||||
terminal_panel.h
|
||||
terminal_panel.cpp
|
||||
terminal_panel.md
|
||||
terminal_panel_linux.cpp // forkpty path
|
||||
terminal_panel_windows.cpp // ConPTY path
|
||||
terminal_panel_test.cpp
|
||||
```
|
||||
|
||||
## Reusa del registry
|
||||
|
||||
- `logger_cpp_core` (fn_log) — log errores spawn/io.
|
||||
- `ansi_parser_cpp_core` — si existe, parsear secuencias ANSI. Si no, delegar a `fn-constructor` para crearlo dentro de este issue (sub-deliverable).
|
||||
|
||||
## DoD
|
||||
|
||||
- Compila Linux + Windows.
|
||||
- Demo: `primitives_gallery` muestra terminal corriendo `bash -i` (linux) / `cmd.exe` (windows).
|
||||
- Smoke test: spawn `echo hello && exit 0` → buffer contiene "hello".
|
||||
- Integracion en kanban_cpp v2: panel "Logs" que toma `run_id` de issue activa y arranca `tail -f /tmp/wt-<slug>-<runid>/agent.log` (readonly=true).
|
||||
- FPS sin caida bajo carga de `yes "x"` (saturado): 60fps target con scrollback truncado.
|
||||
|
||||
## Anti-scope (v1)
|
||||
|
||||
- Sin soporte completo ANSI (no italics, no 256 colores, no Unicode wide).
|
||||
- Sin Vim / programas curses-pesados (cursor visible solo).
|
||||
- Sin SSH remoto (solo shell local).
|
||||
- Sin tabs multiples en un panel (un panel = un proceso).
|
||||
|
||||
## Notas
|
||||
|
||||
ConPTY requiere Windows 10 v1809+. Si target inferior, fallback a CreatePipe sin PTY (sin redimensionado).
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
id: "0133"
|
||||
title: "data_table: optimizar para 10M filas sin caida de FPS (finalize modulo)"
|
||||
status: pendiente
|
||||
type: refactor
|
||||
domain:
|
||||
- cpp-stack
|
||||
- data-ingest
|
||||
scope: app-scoped
|
||||
priority: alta
|
||||
depends: []
|
||||
blocks: []
|
||||
related:
|
||||
- "0081"
|
||||
- "0097"
|
||||
created: 2026-05-22
|
||||
updated: 2026-05-22
|
||||
tags: [cpp, imgui, performance, data_table, finalize]
|
||||
flow: ""
|
||||
---
|
||||
|
||||
# 0133 — data_table 10M rows sin caida FPS
|
||||
|
||||
**Status:** pendiente
|
||||
|
||||
## Por que
|
||||
|
||||
`data_table_cpp_viz` (modulo `fn_module_data_table` / `fn_table_viz`) actualmente maneja decenas de miles de filas con `ImGuiListClipper` y rinde bien. Apps reales (call_monitor con telemetria, services_monitor con escalado, futuro graph_explorer con nodos) ya nos llevan a millones de filas. Objetivo: cerrar el modulo con benchmark estable de **10M filas a >=60fps** en hardware tipico (Ryzen 5 / i5 8th gen + 16GB).
|
||||
|
||||
## Que entrega
|
||||
|
||||
Refactor del modulo manteniendo API publica + un benchmark suite.
|
||||
|
||||
### Cambios tecnicos
|
||||
|
||||
1. **Storage columnar** — hoy `std::vector<std::vector<Cell>>` row-major. Cambiar a column-major (`Column { type; vector<T> data }`) para localidad de cache + iteracion. Las celdas se materializan solo para las filas visibles.
|
||||
2. **String interning** — columnas de tipo string usan tabla de strings global con `uint32_t` indices. 10M filas con 50% strings repetidas → ahorra 60-70% RAM.
|
||||
3. **Lazy filter/sort indices** — en vez de re-ordenar el storage, mantener `vector<uint32_t> visible_rows` que apunta al storage subyacente. Filter/sort solo reescribe ese vector.
|
||||
4. **Computed columns en bloques** — `compute_stage_cpp_core` ahora corre por cell; cambiar a procesar bloques de 1024 filas con SIMD via `OpenMP` (ya esta linkeado en fn_framework).
|
||||
5. **Render path** — `ImGuiListClipper` sigue siendo el frontend, pero el callback de render no debe asignar memoria por fila. Pre-formatear strings de display en `column.display_cache[row_idx]` con LRU de 100k entradas; resto se formatea on-the-fly.
|
||||
6. **Color rules** — `data_table_color_rules_cpp_viz` se evalua hoy por celda visible. Cachear el rule_id resuelto por row_idx tras primer paint.
|
||||
7. **Stats** — `compute_column_stats_cpp_core` solo se recalcula cuando cambia el filtro, no cada frame.
|
||||
|
||||
### Benchmark suite
|
||||
|
||||
`cpp/apps/data_table_bench/`:
|
||||
- Genera dataset sintetico 10M filas x 20 cols (mix int/float/string/timestamp).
|
||||
- Mide FPS sostenido durante:
|
||||
- scroll lineal full range (down → bottom).
|
||||
- filter por string match (`LIKE %foo%`).
|
||||
- sort por columna numerica.
|
||||
- color rule `value > p95`.
|
||||
- Output: `fps_p50`, `fps_p1`, `mem_rss_mb`, `cpu_pct`.
|
||||
- Asercion DoD: `fps_p1 >= 60` en cada escenario.
|
||||
|
||||
## DoD
|
||||
|
||||
- Refactor entregado sin romper apps consumidoras (call_monitor, services_monitor, graph_explorer, navegator_dashboard, kanban_cpp future).
|
||||
- Benchmark suite ejecutable: `./data_table_bench --rows 10000000 --duration 30`.
|
||||
- Resultados de benchmark guardados en `apps/data_table_bench/operations.db` con assertion `fps_p1 >= 60`.
|
||||
- `e2e_checks` corriendo benchmark con dataset reducido (100k filas) en CI; full bench manual.
|
||||
- Modulo marcado `version: 1.0.0` y `tags: [stable]` en su `.md`.
|
||||
- Guia "porting old call sites" si la API publica cambia (en `cpp/functions/viz/data_table/MIGRATION.md`).
|
||||
|
||||
## Anti-scope
|
||||
|
||||
- Sin GPU rendering (sigue siendo CPU + ImGui).
|
||||
- Sin paginacion remota (sigue todo in-memory).
|
||||
- Sin streaming append-while-rendering (snapshot al frame inicio).
|
||||
- Sin virtualizacion horizontal (todas las cols se renderizan; assumed N_cols <= 100).
|
||||
|
||||
## Notas
|
||||
|
||||
Issue 0081 introdujo la migracion inline → modulo. Issue 0097 cerro el wrapping en fn_module/fn_table_viz. Esta issue es el **finalize**: lo deja `1.0.0` con benchmark + suficiente performance para que las apps de telemetria/graph no necesiten paginar manual.
|
||||
@@ -0,0 +1,979 @@
|
||||
---
|
||||
id: "0134"
|
||||
title: "Mesh protocol spec: capability manifests, ed25519 envelopes, enrollment, audit chain"
|
||||
status: pending
|
||||
type: spec
|
||||
domain:
|
||||
- infra
|
||||
- cybersecurity
|
||||
- protocols
|
||||
scope: cross-app
|
||||
priority: high
|
||||
depends: []
|
||||
blocks:
|
||||
- "0135"
|
||||
- "0136"
|
||||
- "0137"
|
||||
- "0138"
|
||||
- "0139"
|
||||
- "0140"
|
||||
- "0141"
|
||||
- "0142"
|
||||
- "0143"
|
||||
related:
|
||||
- "0069"
|
||||
related_flows:
|
||||
- "0009"
|
||||
created: 2026-05-24
|
||||
updated: 2026-05-24
|
||||
tags: [mesh, wireguard, matrix, e2ee, ed25519, manifest, audit-chain, security, spec, agents, devices]
|
||||
flow: "0009"
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# 0134 — Mesh protocol spec
|
||||
|
||||
**Status:** pending
|
||||
|
||||
## Por que
|
||||
|
||||
Flow 0009 (`agentes-dispositivos-mesh`) introduce un bus de control multi-device sobre WireGuard + Matrix donde cada dispositivo (PC, movil, raspberry, container Docker) ejecuta capabilities firmadas por el operador. Sin un protocolo formal compartido, cada implementacion (device_agent en Go, bot dispatcher en agents_and_robots, panel Mesh en agents_dashboard, hub en wg_hub) va a derivar.
|
||||
|
||||
Este issue cierra Fase B del flow: define el contrato exacto que **toda** implementacion debe respetar — wire format, firmas, replay protection, approval flow, audit chain, error model, threat model. Las issues 0135-0143 implementan lo que aqui se define.
|
||||
|
||||
Una vez aceptado este spec, ningun cambio en el wire format se acepta sin un nuevo issue + bump de `protocol_version`.
|
||||
|
||||
## Anti-scope
|
||||
|
||||
- NO define como provision el WG hub (ver 0136).
|
||||
- NO define UI del panel Mesh (ver 0138).
|
||||
- NO define implementacion concreta del bot Matrix (ver 0142).
|
||||
- NO entra en como se persiste `pass operator/ed25519` mas alla de su uso.
|
||||
- NO define schema de la `operations.db` de cada app — solo el subset estrictamente compartido (`audit_log`, `room_devices`, `seen_nonces`).
|
||||
|
||||
## Conventions
|
||||
|
||||
- `protocol_version` (string): **"mesh/1"** — incluido en todo envelope.
|
||||
- Todo timestamp es Unix epoch **segundos** (`int64`).
|
||||
- Todo `*_id` es `[a-z0-9_-]+` lowercase, 4-64 chars.
|
||||
- Todo nonce es 16 bytes random (`crypto/rand`), serializado como **base64url sin padding**.
|
||||
- Todo hash es SHA-256, serializado como **hex lowercase** (64 chars).
|
||||
- Toda firma ed25519 es 64 bytes, serializada como **base64url sin padding** (86 chars).
|
||||
- Toda clave publica ed25519 es 32 bytes, serializada como **base64url sin padding** (43 chars).
|
||||
- Fingerprint de clave publica = primeros 16 bytes hex de `SHA-256(pubkey_raw_32_bytes)`.
|
||||
- JSON canonical: claves ordenadas alfabeticamente, sin espacios, UTF-8, sin BOM. Para firmas siempre usar la forma canonica.
|
||||
|
||||
---
|
||||
|
||||
## 1. JSON envelope
|
||||
|
||||
Toda invocacion de capability viaja como par request/response, ya sea sobre Matrix (eventos `m.room.message` con `msgtype = m.capability.*`) o sobre HTTP dentro del mesh WG (`POST /capability`).
|
||||
|
||||
### 1.1 Request
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol_version": "mesh/1",
|
||||
"request_id": "req_01J9XYZABCDEF",
|
||||
"manifest_id": "manifest_home-wsl_v3",
|
||||
"capability": "fs.read",
|
||||
"args": {
|
||||
"path": "/var/log/syslog",
|
||||
"max_bytes": 4096
|
||||
},
|
||||
"ts": 1748131200,
|
||||
"nonce": "Yk9p6Xs_3hZQk4mB7lWcvA",
|
||||
"signature": "u2vh...QkA"
|
||||
}
|
||||
```
|
||||
|
||||
- `request_id`: ULID generado por el caller (agents_and_robots o el operador). Idempotency key — si la misma request llega 2x, device_agent debe devolver el mismo response sin re-ejecutar.
|
||||
- `manifest_id`: id del capability manifest contra el cual se evalua. El device debe tener este manifest activo o rechazar `manifest_invalid`.
|
||||
- `capability`: dotted name, ej. `shell.exec`, `fs.read`, `docker.container.list`. Debe estar en `manifest.capabilities[].name`.
|
||||
- `args`: objeto JSON especifico de la capability. Schema validado por device_agent contra el manifest.
|
||||
- `ts`: Unix seconds. Edad maxima 60s (ver §5).
|
||||
- `nonce`: 16 bytes random, base64url. Unico por request (ver §5).
|
||||
- `signature`: ed25519 sobre canonical bytes (ver 1.3).
|
||||
|
||||
### 1.2 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol_version": "mesh/1",
|
||||
"request_id": "req_01J9XYZABCDEF",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"stdout": "May 24 12:00:00 localhost systemd[1]: Started session-1.scope.\n",
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"duration_ms": 42,
|
||||
"audit_hash": "a3f5...09bc"
|
||||
}
|
||||
```
|
||||
|
||||
- `ok`: boolean. Si false, `result` ausente y `error` poblado.
|
||||
- `error`: objeto `{code, message, details?}` cuando `ok=false`. `code` debe estar en §10.
|
||||
- `duration_ms`: tiempo de ejecucion en device_agent (no incluye latencia Matrix).
|
||||
- `audit_hash`: `this_hash` del registro en `audit_log` (ver §7). Permite al caller verificar la cadena.
|
||||
|
||||
Response NO va firmado por defecto — viaja sobre canal autenticado (Matrix E2EE o WG mesh). Si en el futuro se requiere firma de response (audit externo), se anade campo opcional `response_signature` con el mismo esquema canonical.
|
||||
|
||||
### 1.3 Canonical bytes para firma del request
|
||||
|
||||
```
|
||||
canonical = "mesh/1\n" +
|
||||
request_id + "\n" +
|
||||
manifest_id + "\n" +
|
||||
capability + "\n" +
|
||||
sha256_hex(json_canonical(args)) + "\n" +
|
||||
int_to_string(ts) + "\n" +
|
||||
nonce
|
||||
```
|
||||
|
||||
Bytes UTF-8, separador `\n` (0x0A). No trailing newline. Hash del args para no exponer args grandes a la firma (la firma se valida contra el hash, y `args` se reentrega tal cual; cualquier modificacion rompe la firma).
|
||||
|
||||
`json_canonical(args)`:
|
||||
1. Si `args` es null o ausente, `json_canonical = "null"`.
|
||||
2. Si `args` es objeto, recursivo: ordenar claves alfabeticamente, valores serializados sin espacios, strings con escape JSON estandar.
|
||||
3. Si `args` es array, recursivo sobre cada elemento, sin reordenar.
|
||||
|
||||
### 1.4 Transport binding
|
||||
|
||||
| Transport | Request encoding | Response encoding |
|
||||
|---|---|---|
|
||||
| Matrix room event | `content.body` = JSON string, `msgtype` = `m.capability.request` | `m.capability.response` event en el mismo room |
|
||||
| HTTP intra-mesh (`https://10.42.0.10:7777/capability`) | `POST` body JSON | response body JSON |
|
||||
| SSE (streaming logs, `docker.logs --follow`) | request via POST | response inicial JSON `{ok, result: {stream_id}}` + SSE `event: chunk` |
|
||||
|
||||
Matrix es default. HTTP solo lo activa el operador con `mesh_http=true` en el manifest para casos de baja latencia (`docker.logs` tail interactivo, transferencias >1MB que Matrix limita).
|
||||
|
||||
---
|
||||
|
||||
## 2. Capability manifest
|
||||
|
||||
Documento firmado por el operador que autoriza a un device a ejecutar un set acotado de capabilities. Sin manifest valido, device_agent rechaza todo.
|
||||
|
||||
### 2.1 Schema YAML (legible) — fuente de verdad
|
||||
|
||||
```yaml
|
||||
# manifest_home-wsl_v3.yaml
|
||||
protocol_version: mesh/1
|
||||
manifest_id: manifest_home-wsl_v3
|
||||
device_id: home-wsl
|
||||
operator: egutierrez@aurgi.com
|
||||
operator_pubkey_fingerprint: "a1b2c3d4e5f60718"
|
||||
issued_at: 1748131200
|
||||
expires_at: 1779667200 # 1 year later
|
||||
capabilities:
|
||||
- name: shell.exec
|
||||
requires_approval: false
|
||||
constraints:
|
||||
binaries_whitelist: [ls, cat, head, tail, grep, ps, df, du, uname, uptime]
|
||||
max_duration_s: 10
|
||||
max_output_bytes: 65536
|
||||
cwd_allowed: ["/home/lucas", "/tmp"]
|
||||
- name: fs.read
|
||||
requires_approval: false
|
||||
constraints:
|
||||
paths_allowed: ["/home/lucas/**", "/var/log/syslog", "/etc/os-release"]
|
||||
paths_denied: ["/home/lucas/.ssh/**", "/home/lucas/.password-store/**"]
|
||||
max_bytes: 1048576
|
||||
- name: fs.write
|
||||
requires_approval: true
|
||||
constraints:
|
||||
paths_allowed: ["/home/lucas/inbox/**"]
|
||||
max_bytes: 1048576
|
||||
- name: docker.container.list
|
||||
requires_approval: false
|
||||
- name: docker.container.exec
|
||||
requires_approval: true
|
||||
constraints:
|
||||
containers_allowed: ["agents_and_robots", "registry_api"]
|
||||
binaries_whitelist: [ls, cat, ps]
|
||||
max_duration_s: 30
|
||||
```
|
||||
|
||||
### 2.2 JSON canonical (lo que se firma)
|
||||
|
||||
```json
|
||||
{
|
||||
"capabilities": [
|
||||
{"constraints": {"binaries_whitelist": ["ls","cat","head","tail","grep","ps","df","du","uname","uptime"], "cwd_allowed":["/home/lucas","/tmp"], "max_duration_s": 10, "max_output_bytes": 65536}, "name": "shell.exec", "requires_approval": false},
|
||||
{"constraints": {"max_bytes": 1048576, "paths_allowed": ["/home/lucas/**","/var/log/syslog","/etc/os-release"], "paths_denied": ["/home/lucas/.ssh/**","/home/lucas/.password-store/**"]}, "name": "fs.read", "requires_approval": false},
|
||||
{"constraints": {"max_bytes": 1048576, "paths_allowed":["/home/lucas/inbox/**"]}, "name": "fs.write", "requires_approval": true},
|
||||
{"name": "docker.container.list", "requires_approval": false},
|
||||
{"constraints": {"binaries_whitelist": ["ls","cat","ps"], "containers_allowed": ["agents_and_robots","registry_api"], "max_duration_s": 30}, "name": "docker.container.exec", "requires_approval": true}
|
||||
],
|
||||
"device_id": "home-wsl",
|
||||
"expires_at": 1779667200,
|
||||
"issued_at": 1748131200,
|
||||
"manifest_id": "manifest_home-wsl_v3",
|
||||
"operator": "egutierrez@aurgi.com",
|
||||
"operator_pubkey_fingerprint": "a1b2c3d4e5f60718",
|
||||
"protocol_version": "mesh/1"
|
||||
}
|
||||
```
|
||||
|
||||
Producido por `capability_manifest_canonicalize_go_infra` (function 0135 entrega).
|
||||
|
||||
### 2.3 Canonical bytes para firma del manifest
|
||||
|
||||
```
|
||||
manifest_canonical = "mesh/1/manifest\n" + json_canonical(manifest_without_signature)
|
||||
```
|
||||
|
||||
Donde `manifest_without_signature` es el JSON canonical de §2.2. El prefijo `mesh/1/manifest\n` es domain separator — evita que una firma de manifest pueda interpretarse como firma de envelope.
|
||||
|
||||
### 2.4 Manifest signed envelope (lo que se entrega al device)
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest": { /* §2.2 */ },
|
||||
"signature": "k7Yp...QwE"
|
||||
}
|
||||
```
|
||||
|
||||
Persistido en device como `~/.config/device_agent/manifests/manifest_home-wsl_v3.json`.
|
||||
|
||||
### 2.5 Reglas de verificacion (device_agent al arrancar y al recibir request)
|
||||
|
||||
1. Parsear `manifest` y `signature`.
|
||||
2. Computar `manifest_canonical`.
|
||||
3. Verificar `ed25519.Verify(operator_pubkey, manifest_canonical, signature)`.
|
||||
4. Rechazar si `expires_at < now()` → `manifest_invalid` con `details.reason = "expired"`.
|
||||
5. Rechazar si `issued_at > now() + 300` (clock skew) → `manifest_invalid` con `details.reason = "future_issued"`.
|
||||
6. Rechazar si `device_id` no coincide con `~/.config/device_agent/device_id` → `manifest_invalid`.
|
||||
7. Rechazar si `operator_pubkey_fingerprint` no coincide con la pubkey conocida → `manifest_invalid`.
|
||||
|
||||
### 2.6 Rotacion
|
||||
|
||||
Un manifest nuevo coexiste con el anterior hasta su `expires_at`. Para forzar revocacion inmediata: el hub publica un evento `manifest_revoked` (en room `#operator-broadcast`) firmado por el operador con la lista de `manifest_id` revocados. device_agent mantiene `~/.config/device_agent/revoked_manifests.json` y lo consulta antes de aceptar.
|
||||
|
||||
---
|
||||
|
||||
## 3. ed25519 signing flow
|
||||
|
||||
### 3.1 Keypair
|
||||
|
||||
- **Privada** del operador: `pass operator/ed25519`. Linea 1 = base64url de los 32 bytes del seed ed25519. Lineas siguientes = metadata (operador email, created_at, fingerprint).
|
||||
- **Publica** del operador: `~/.fn_operator.pub`. Contenido = base64url de los 32 bytes raw + `\n`. Distribuida a cada device en su `~/.config/device_agent/operator.pub` durante enrollment.
|
||||
|
||||
### 3.2 Generacion (una sola vez en la vida del operador, idempotente)
|
||||
|
||||
```bash
|
||||
# Funcion del registry: operator_keygen_bash_infra (0135 entrega)
|
||||
operator_keygen() {
|
||||
if pass show operator/ed25519 >/dev/null 2>&1; then
|
||||
echo "operator key already exists; skipping"
|
||||
return 0
|
||||
fi
|
||||
local seed pub fp
|
||||
seed=$(openssl rand 32 | base64 -w0 | tr '+/' '-_' | tr -d '=')
|
||||
pub=$(echo "$seed" | go_ed25519_derive_pub) # helper Go: seed → pub
|
||||
fp=$(echo -n "$(echo "$pub" | base64url -d)" | sha256sum | cut -c1-32)
|
||||
pass insert -m operator/ed25519 <<EOF
|
||||
$seed
|
||||
operator: $(git config user.email)
|
||||
created_at: $(date +%s)
|
||||
fingerprint: $fp
|
||||
pubkey: $pub
|
||||
EOF
|
||||
echo "$pub" > ~/.fn_operator.pub
|
||||
chmod 600 ~/.fn_operator.pub
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Sign
|
||||
|
||||
```go
|
||||
// capability_manifest_sign_go_infra (issue 0135)
|
||||
func SignManifest(m Manifest, seed []byte) ([]byte, error) {
|
||||
if len(seed) != ed25519.SeedSize {
|
||||
return nil, ErrInvalidSeed
|
||||
}
|
||||
canonical, err := canonicalizeManifest(m)
|
||||
if err != nil { return nil, err }
|
||||
msg := append([]byte("mesh/1/manifest\n"), canonical...)
|
||||
priv := ed25519.NewKeyFromSeed(seed)
|
||||
return ed25519.Sign(priv, msg), nil
|
||||
}
|
||||
```
|
||||
|
||||
Para envelopes:
|
||||
|
||||
```go
|
||||
func SignRequest(r Request, seed []byte) ([]byte, error) {
|
||||
canonical, err := canonicalRequestBytes(r)
|
||||
if err != nil { return nil, err }
|
||||
priv := ed25519.NewKeyFromSeed(seed)
|
||||
return ed25519.Sign(priv, canonical), nil
|
||||
}
|
||||
```
|
||||
|
||||
`canonicalRequestBytes` implementa §1.3.
|
||||
|
||||
### 3.4 Verify (device_agent)
|
||||
|
||||
```go
|
||||
// capability_manifest_verify_go_infra (issue 0135)
|
||||
func VerifyManifest(signed SignedManifest, pubkey []byte) error {
|
||||
if len(pubkey) != ed25519.PublicKeySize {
|
||||
return ErrInvalidPubkey
|
||||
}
|
||||
canonical, err := canonicalizeManifest(signed.Manifest)
|
||||
if err != nil { return err }
|
||||
msg := append([]byte("mesh/1/manifest\n"), canonical...)
|
||||
if !ed25519.Verify(pubkey, msg, signed.Signature) {
|
||||
return ErrSignatureInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 Domain separators (criticos)
|
||||
|
||||
Cada tipo de firma usa prefix unico para evitar cross-protocol attacks:
|
||||
|
||||
| Tipo | Prefix |
|
||||
|---|---|
|
||||
| Manifest | `"mesh/1/manifest\n"` |
|
||||
| Request envelope | `"mesh/1/request\n"` (implicito en §1.3 — usa el `\n` join) |
|
||||
| Enrollment token | `"mesh/1/enroll\n"` |
|
||||
| Approval token | `"mesh/1/approval\n"` |
|
||||
| Manifest revocation | `"mesh/1/revoke\n"` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Enrollment token
|
||||
|
||||
Token corto firmado por el operador que un device usa **una sola vez** para registrarse contra `wg_hub` y obtener su config WireGuard + manifest inicial.
|
||||
|
||||
### 4.1 Payload JSON (canonical)
|
||||
|
||||
```json
|
||||
{
|
||||
"allowed_capabilities": ["shell.exec", "fs.read", "docker.container.list"],
|
||||
"device_id": "home-wsl",
|
||||
"expires_at": 1748131800,
|
||||
"issued_at": 1748131200,
|
||||
"nonce": "Tk9NbjVxV3JLcF9j",
|
||||
"operator_pubkey_fingerprint": "a1b2c3d4e5f60718",
|
||||
"protocol_version": "mesh/1",
|
||||
"purpose": "enroll"
|
||||
}
|
||||
```
|
||||
|
||||
- `expires_at - issued_at` <= 600s (10 min). Hub rechaza si excede.
|
||||
- `allowed_capabilities`: subset que el operador autoriza a este enrollment. El manifest final puede ser mas restrictivo pero no mas amplio.
|
||||
- `nonce` previene replay incluso si el operador reusa el token por error.
|
||||
- `purpose: "enroll"` es discriminator interno; los otros valores reservados son `"approval"` (§6) y `"revoke"`.
|
||||
|
||||
### 4.2 Wire format
|
||||
|
||||
```
|
||||
base64url(json_canonical(payload)) + "." + base64url(ed25519_signature)
|
||||
```
|
||||
|
||||
Ejemplo (truncado):
|
||||
|
||||
```
|
||||
eyJhbGxvd2VkX2NhcGFiaWxpdGllcyI6WyJzaGVsbC5leGVjIiwiZnMucmVhZCJdLCJkZXZpY2VfaWQiOiJob21lLXdzbCIsLi4u.k7YpQwE9Vh...
|
||||
```
|
||||
|
||||
Aproximadamente 280-320 bytes. Cabe en un QR Code v6 (error correction M).
|
||||
|
||||
### 4.3 Generacion (operador)
|
||||
|
||||
```bash
|
||||
# enroll_device pipeline (issue 0139) llama:
|
||||
./fn run enrollment_token_create home-wsl \
|
||||
--capabilities "shell.exec,fs.read,docker.container.list" \
|
||||
--ttl 600
|
||||
# stdout: token base64url
|
||||
```
|
||||
|
||||
### 4.4 Verificacion (wg_hub `POST /enroll`)
|
||||
|
||||
```go
|
||||
// enrollment_token_verify_go_infra (issue 0135)
|
||||
func VerifyEnrollToken(raw string, pubkey []byte) (*EnrollPayload, error) {
|
||||
parts := strings.Split(raw, ".")
|
||||
if len(parts) != 2 { return nil, ErrTokenMalformed }
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil { return nil, ErrTokenMalformed }
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil { return nil, ErrTokenMalformed }
|
||||
msg := append([]byte("mesh/1/enroll\n"), payloadBytes...)
|
||||
if !ed25519.Verify(pubkey, msg, sig) {
|
||||
return nil, ErrSignatureInvalid
|
||||
}
|
||||
var p EnrollPayload
|
||||
if err := json.Unmarshal(payloadBytes, &p); err != nil {
|
||||
return nil, ErrTokenMalformed
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if p.ExpiresAt < now { return nil, ErrTokenExpired }
|
||||
if p.IssuedAt > now + 300 { return nil, ErrTokenFutureIssued }
|
||||
if p.Purpose != "enroll" { return nil, ErrTokenWrongPurpose }
|
||||
return &p, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 POST /enroll (wg_hub)
|
||||
|
||||
```
|
||||
POST https://organic-machine.com/enroll
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"enrollment_token": "eyJhbGxv...QwE",
|
||||
"device_pubkey_wg": "K3v8...j0c=", // WG public key del device, generada por wg_keygen
|
||||
"device_hostname": "home-wsl",
|
||||
"device_os": "linux-wsl2",
|
||||
"device_arch": "x86_64"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"wg_config": "[Interface]\nPrivateKey = <keep on device>\nAddress = 10.42.0.10/24\n[Peer]\nPublicKey = ...\nEndpoint = organic-machine.com:51820\nAllowedIPs = 10.42.0.0/24\nPersistentKeepalive = 25\n",
|
||||
"manifest": { /* signed manifest */ },
|
||||
"matrix_room": "!abc123:organic-machine.com",
|
||||
"matrix_invite_url": "https://matrix.to/#/!abc123:organic-machine.com?via=organic-machine.com"
|
||||
}
|
||||
```
|
||||
|
||||
Hub marca el token como `consumed` en `wg_enrollment_tokens` (token_nonce as PK) — segundo uso rechazado con `nonce_replay`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Replay protection
|
||||
|
||||
### 5.1 Nonces
|
||||
|
||||
- 16 bytes `crypto/rand` por request.
|
||||
- Server (device_agent O hub) mantiene tabla `seen_nonces`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS seen_nonces (
|
||||
nonce TEXT PRIMARY KEY, -- base64url
|
||||
seen_at INTEGER NOT NULL, -- unix seconds
|
||||
request_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL -- seen_at + 300
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_seen_nonces_expires ON seen_nonces(expires_at);
|
||||
```
|
||||
|
||||
- TTL = 300s. Job periodico (cada 60s) borra entradas con `expires_at < now()`.
|
||||
|
||||
### 5.2 Timestamp
|
||||
|
||||
- `ts` debe estar en `[now-60, now+30]`. Mas viejo → `nonce_replay`. Mas futuro → `signature_invalid` con `details.reason="clock_skew"`.
|
||||
- Asume devices con NTP sync (`chrony`/`systemd-timesyncd`). Si un device tiene clock drift >30s, el operador recibe alerta en `#operator-approvals`.
|
||||
|
||||
### 5.3 Algoritmo
|
||||
|
||||
```go
|
||||
// Pseudo
|
||||
func AcceptNonce(db *sql.DB, nonce string, ts int64, requestID string) error {
|
||||
now := time.Now().Unix()
|
||||
if ts < now - 60 { return ErrNonceReplay }
|
||||
if ts > now + 30 { return ErrSignatureInvalid /* clock_skew */ }
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO seen_nonces(nonce, seen_at, request_id, expires_at) VALUES(?,?,?,?)`,
|
||||
nonce, now, requestID, now+300,
|
||||
)
|
||||
if isUniqueViolation(err) { return ErrNonceReplay }
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
`AcceptNonce` se llama **antes** de ejecutar la capability, despues de verificar la firma. Si la firma es invalida pero el nonce es nuevo, NO se inserta (evita amplificar log spam).
|
||||
|
||||
---
|
||||
|
||||
## 6. Approval flow
|
||||
|
||||
Para capabilities con `requires_approval: true`, device_agent NO ejecuta sin recibir un approval token firmado por el operador.
|
||||
|
||||
### 6.1 Secuencia
|
||||
|
||||
```
|
||||
[operator] [agents_and_robots] [device_agent] [#operator-approvals]
|
||||
| | | |
|
||||
| !exec rm -rf /tmp/x in #dev-home-wsl | |
|
||||
|------------------->| | |
|
||||
| |--- request envelope ->| |
|
||||
| | |--- decide: approval needed
|
||||
| | | |
|
||||
| |<--- approval_request -| |
|
||||
| |--- post to #op-approvals ----------------->|
|
||||
| | | |
|
||||
|<--- notification --| | |
|
||||
| |
|
||||
|--- reacts 👍 OR posts !approve req_01J9... ------------------->|
|
||||
| |
|
||||
| |<--- captures reaction/cmd -----------------|
|
||||
| |--- signs approval_token (via operator key)
|
||||
| |--- posts approval_token to #dev-home-wsl
|
||||
| | | |
|
||||
| |--- approval_token --->| |
|
||||
| | |--- verifies token |
|
||||
| | |--- executes |
|
||||
| |<--- response ---------| |
|
||||
|<-- output in #dev-home-wsl |
|
||||
```
|
||||
|
||||
### 6.2 Approval token payload
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol_version": "mesh/1",
|
||||
"purpose": "approval",
|
||||
"request_id": "req_01J9XYZABCDEF",
|
||||
"manifest_id": "manifest_home-wsl_v3",
|
||||
"capability": "shell.exec",
|
||||
"args_hash": "a3f5...09bc",
|
||||
"approver": "egutierrez@aurgi.com",
|
||||
"approved_at": 1748131245,
|
||||
"expires_at": 1748131305,
|
||||
"nonce": "Yk9p6Xs_3hZQk4mB7lWcvA"
|
||||
}
|
||||
```
|
||||
|
||||
`args_hash` debe coincidir con `sha256_hex(json_canonical(args))` del request original — evita que el operador apruebe `ls /tmp` y el bot reemplace por `rm -rf /tmp`.
|
||||
|
||||
### 6.3 Wire format
|
||||
|
||||
Igual que enrollment token: `base64url(payload) + "." + base64url(signature)`, domain separator `"mesh/1/approval\n"`.
|
||||
|
||||
### 6.4 Captura por agents_and_robots
|
||||
|
||||
`agents_and_robots` corre como bot Matrix con la operator key cargada (a traves de `pass operator/ed25519` montado en `/etc/agents_and_robots/operator.key` con permisos 400, owned by service user). Cuando detecta:
|
||||
|
||||
- Reaccion `m.reaction` con key `👍` (U+1F44D) sobre el evento `approval_request` en `#operator-approvals`, **emitida por el matrix_id del operador** (configurado en `apps/agents_and_robots/config.yaml::operator_matrix_id`).
|
||||
- O mensaje `!approve <request_id>` en `#operator-approvals` desde el mismo matrix_id.
|
||||
|
||||
Entonces firma el approval token y lo envia a device_agent (via Matrix event `m.capability.approval` en el room del device).
|
||||
|
||||
### 6.5 Timeout
|
||||
|
||||
Si device_agent no recibe approval token en 60s tras enviar approval_request, responde al room con error `approval_timeout`. El operador puede re-emitir el comando original (genera nuevo `request_id`, nuevo nonce, nueva approval).
|
||||
|
||||
### 6.6 Approval denegada
|
||||
|
||||
Reaccion `👎` (U+1F44E) o comando `!deny <request_id>` → bot firma un `approval_denied_token` (mismo formato + `denied=true`). device_agent responde `approval_denied`.
|
||||
|
||||
### 6.7 Verificacion en device_agent
|
||||
|
||||
```go
|
||||
// Pseudo
|
||||
func VerifyApproval(token string, req Request, pubkey []byte) error {
|
||||
payload, err := decodeApprovalToken(token, pubkey)
|
||||
if err != nil { return err }
|
||||
if payload.RequestID != req.RequestID { return ErrApprovalMismatch }
|
||||
if payload.Capability != req.Capability { return ErrApprovalMismatch }
|
||||
if payload.ArgsHash != sha256Hex(canonicalJSON(req.Args)) { return ErrApprovalMismatch }
|
||||
if payload.ExpiresAt < time.Now().Unix() { return ErrApprovalExpired }
|
||||
if payload.Denied { return ErrApprovalDenied }
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Audit log hash chain
|
||||
|
||||
Append-only log local a cada device_agent con hash chain que detecta tampering. Replicado periodicamente al hub para archivo tamper-evident off-device.
|
||||
|
||||
### 7.1 Schema (`apps/device_agent/audit.db`)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
manifest_id TEXT NOT NULL,
|
||||
capability TEXT NOT NULL,
|
||||
args_hash TEXT NOT NULL,
|
||||
approval_id TEXT, -- nullable, request_id del approval token usado
|
||||
exit_code INTEGER, -- nullable mientras no haya respuesta
|
||||
ok INTEGER NOT NULL, -- 0/1
|
||||
error_code TEXT, -- nullable
|
||||
duration_ms INTEGER NOT NULL,
|
||||
prev_hash TEXT NOT NULL, -- hex 64 chars
|
||||
this_hash TEXT NOT NULL, -- hex 64 chars
|
||||
UNIQUE(request_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts);
|
||||
```
|
||||
|
||||
Migracion vive en `apps/device_agent/migrations/001_init.sql`. Regla `db_migrations.md`.
|
||||
|
||||
### 7.2 Hash chain
|
||||
|
||||
```
|
||||
record_canonical = ts + "|" + request_id + "|" + manifest_id + "|" +
|
||||
capability + "|" + args_hash + "|" + approval_id_or_empty + "|" +
|
||||
exit_code_str + "|" + ok_str + "|" + error_code_or_empty + "|" +
|
||||
duration_ms_str
|
||||
|
||||
this_hash = sha256_hex(prev_hash + "\n" + record_canonical)
|
||||
```
|
||||
|
||||
Para el primer registro `prev_hash = "0000...0000"` (64 zeros).
|
||||
|
||||
`wg_peer_revoke_go_infra` (ya existente) hace algo similar para revocations; este spec usa el mismo patron para todas las invocaciones.
|
||||
|
||||
### 7.3 Append helper
|
||||
|
||||
```go
|
||||
// device_audit_append_go_infra (issue 0135)
|
||||
func AppendAudit(db *sql.DB, rec Record) (string, error) {
|
||||
var prev string
|
||||
err := db.QueryRow(`SELECT this_hash FROM audit_log ORDER BY id DESC LIMIT 1`).Scan(&prev)
|
||||
if err == sql.ErrNoRows {
|
||||
prev = strings.Repeat("0", 64)
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
canonical := canonicalRecord(rec)
|
||||
h := sha256.Sum256([]byte(prev + "\n" + canonical))
|
||||
this := hex.EncodeToString(h[:])
|
||||
_, err = db.Exec(`INSERT INTO audit_log(...) VALUES(...)`, /* fields, prev, this */)
|
||||
if err != nil { return "", err }
|
||||
return this, nil
|
||||
}
|
||||
```
|
||||
|
||||
Transaccion con `BEGIN IMMEDIATE` para evitar carrera entre prev_hash select y insert.
|
||||
|
||||
### 7.4 Verificacion (cualquiera con copia del db)
|
||||
|
||||
```go
|
||||
// device_audit_verify_go_infra (issue 0135)
|
||||
func VerifyChain(db *sql.DB) error {
|
||||
rows, _ := db.Query(`SELECT id, prev_hash, this_hash, /* fields */ FROM audit_log ORDER BY id`)
|
||||
expected := strings.Repeat("0", 64)
|
||||
for rows.Next() {
|
||||
var rec Record
|
||||
rows.Scan(&rec.ID, &rec.PrevHash, &rec.ThisHash /* ... */)
|
||||
if rec.PrevHash != expected { return fmt.Errorf("chain broken at id %d", rec.ID) }
|
||||
canonical := canonicalRecord(rec)
|
||||
h := sha256.Sum256([]byte(rec.PrevHash + "\n" + canonical))
|
||||
if hex.EncodeToString(h[:]) != rec.ThisHash {
|
||||
return fmt.Errorf("hash mismatch at id %d", rec.ID)
|
||||
}
|
||||
expected = rec.ThisHash
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 7.5 Replicacion al hub
|
||||
|
||||
Cada 60s device_agent hace `POST /audit/replicate` al hub con el bloque de registros nuevos (delta sobre el ultimo replicado). El hub valida la cadena, anade su propio `replicated_at`, y almacena en `apps/wg_hub/operations.db::device_audit` (tabla espejo + meta `last_replicated_id` por device).
|
||||
|
||||
Si el hub detecta `chain_broken` o `hash_mismatch`, emite evento a `#operator-approvals` con severity=critical y marca device como `status='compromised'` en `wg_peers`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Room ↔ device mapping
|
||||
|
||||
### 8.1 Schema (`apps/agents_and_robots/operations.db`)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS room_devices (
|
||||
room_id TEXT PRIMARY KEY, -- !abc123:organic-machine.com
|
||||
device_id TEXT NOT NULL,
|
||||
manifest_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL, -- 'device' | 'container' | 'approval' | 'broadcast'
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_room_devices_device_id ON room_devices(device_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_room_devices_role ON room_devices(role);
|
||||
```
|
||||
|
||||
Migracion: `apps/agents_and_robots/migrations/NNN_room_devices.sql`.
|
||||
|
||||
### 8.2 Roles especiales
|
||||
|
||||
- `role='approval'`: hay exactamente UN room con este rol, default alias `#operator-approvals:organic-machine.com`. Bot publica `approval_request` aqui y escucha reacciones del operador.
|
||||
- `role='broadcast'`: alias `#operator-broadcast:organic-machine.com`. Bot publica eventos de control firmados (revocations, manifest rotations).
|
||||
- `role='device'`: room 1:1 por device. Alias por convencion `#dev-<device_id>:organic-machine.com`.
|
||||
- `role='container'`: para modo "deep" docker (containers como peers WG). Alias `#cont-<container_id>:organic-machine.com`.
|
||||
|
||||
### 8.3 Resolucion al dispatchar
|
||||
|
||||
Cuando el bot recibe un `!cmd` en cualquier room:
|
||||
|
||||
1. Busca `SELECT device_id, manifest_id, role FROM room_devices WHERE room_id=? AND active=1`.
|
||||
2. Si no existe → ignora (o responde "this room is not bound to a device").
|
||||
3. Si `role='device'`, dispatcha al `device_agent` correspondiente.
|
||||
4. Si `role='approval'` o `role='broadcast'`, NO acepta `!exec`/`!fs.*`/`!docker.*` — solo `!approve`, `!deny`, `!revoke`, `!help`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Element commands
|
||||
|
||||
Comandos que el bot de `agents_and_robots` parsea y traduce a envelopes. Estos viven en rooms `role='device'` o `role='container'` (excepto `!approve`/`!deny` que viven en `role='approval'`).
|
||||
|
||||
| Command | Capability | Args | Notas |
|
||||
|---|---|---|---|
|
||||
| `!help` | (meta) | none | Bot responde con capability matrix del manifest del device del room |
|
||||
| `!exec <argv...>` | `shell.exec` | `{argv: [...], cwd?: string}` | argv splits por shlex, sin shell wrapping |
|
||||
| `!fs.read <path> [bytes]` | `fs.read` | `{path, max_bytes?}` | default max_bytes = manifest.max_bytes |
|
||||
| `!fs.write <path> <<<content` | `fs.write` | `{path, content_base64}` | content viene en heredoc o quoted; bot codifica base64 |
|
||||
| `!fs.ls <path>` | `fs.list` | `{path}` | output: array de {name,type,size} |
|
||||
| `!docker exec <container> <argv...>` | `docker.container.exec` | `{container, argv}` | container debe estar en `containers_allowed` |
|
||||
| `!docker logs <container> [tail]` | `docker.container.logs` | `{container, tail?, follow?}` | `--follow` activa SSE |
|
||||
| `!docker ps` | `docker.container.list` | `{}` | output: tabla containers vivos |
|
||||
| `!approve <req_id>` | (meta) | `{request_id}` | solo en `role='approval'`, solo del operator_matrix_id |
|
||||
| `!deny <req_id>` | (meta) | `{request_id, reason?}` | idem |
|
||||
| `!revoke <device_id>` | (meta) | `{device_id, reason?}` | solo del operator_matrix_id; emite `manifest_revoked` + `wg_peer_revoke` |
|
||||
| `!status` | (meta) | none | bot responde: device IP WG, last_handshake, manifest_id activo, capabilities count |
|
||||
|
||||
### 9.1 Parsing rules
|
||||
|
||||
- Lexer shlex-style (`shlex.split` Python o equivalente). Quoted strings respetan espacios.
|
||||
- Si parsing falla → `!help` corto en la misma linea + abort.
|
||||
- Args desconocidos para una capability → `manifest_invalid` con `details.unknown_args: [...]`.
|
||||
|
||||
### 9.2 Output rendering
|
||||
|
||||
El bot formatea responses para Matrix:
|
||||
|
||||
- `ok=true`, output corto (<2KB): formatted text con `<pre><code>...</code></pre>` (Matrix `formatted_body`).
|
||||
- `ok=true`, output largo: trim a 2KB + link al artifact subido (Matrix media repo si el homeserver lo permite, sino paste a `paste.organic-machine.com`).
|
||||
- `ok=false`: render como `[ERROR error_code] message\n<details>` con codigo de color rojo en clientes que soportan colored text.
|
||||
|
||||
---
|
||||
|
||||
## 10. Error model
|
||||
|
||||
Todos los `error.code` son strings snake_case. El cliente NO debe parsear `message` — solo `code`. `details` es objeto libre por code.
|
||||
|
||||
| code | meaning | details fields | retry? |
|
||||
|---|---|---|---|
|
||||
| `manifest_invalid` | Manifest no firma, expirado, device_id mismatch, o no tiene la capability | `reason`, `manifest_id`, `expires_at?` | no — pedir manifest nuevo |
|
||||
| `capability_denied` | Capability esta en manifest pero los args violan constraints | `constraint_violated`, `value` | no — ajustar args |
|
||||
| `binary_not_whitelisted` | shell.exec con binario fuera de `binaries_whitelist` | `binary`, `whitelist` | no |
|
||||
| `path_not_allowed` | fs.* con path fuera de `paths_allowed` o en `paths_denied` | `path`, `allowed_globs`, `denied_globs` | no |
|
||||
| `container_not_allowed` | docker.* sobre container fuera de `containers_allowed` | `container`, `allowed_list` | no |
|
||||
| `approval_timeout` | requires_approval=true y no llego token en 60s | `waited_s` | si — re-enviar |
|
||||
| `approval_denied` | operador denego | `approver`, `reason?` | no |
|
||||
| `approval_mismatch` | approval token args_hash != request args_hash | `expected_hash`, `got_hash` | no — posible MITM |
|
||||
| `nonce_replay` | nonce ya visto en ventana TTL=300s | `nonce`, `first_seen_at` | no — generar nonce nuevo |
|
||||
| `signature_invalid` | firma ed25519 no verifica | `reason` (e.g. `clock_skew`, `bad_pubkey`, `corrupted`) | no |
|
||||
| `token_expired` | enrollment o approval token expirado | `expires_at`, `now` | no |
|
||||
| `token_consumed` | enrollment token ya usado (nonce en `wg_enrollment_tokens`) | `first_use_at` | no |
|
||||
| `device_revoked` | device esta en revocation list | `revoked_at`, `reason` | no |
|
||||
| `capability_not_found` | capability name no existe en device_agent | `name`, `available` | no |
|
||||
| `execution_failed` | la capability ejecuto y devolvio exit != 0 | `exit_code`, `stderr` (trimmed) | depende — semantica de la capability |
|
||||
| `output_too_large` | output > `max_output_bytes` | `bytes`, `limit` | no — pedir con tail/head |
|
||||
| `duration_exceeded` | timeout `max_duration_s` excedido | `limit_s`, `killed_signal` | no |
|
||||
| `transport_error` | error de Matrix/HTTP transport debajo del envelope | `transport`, `inner_error` | si con backoff |
|
||||
| `internal` | bug en device_agent/hub/bot; NO leakear stack al room | `incident_id` | no — operator debe ver logs |
|
||||
|
||||
### 10.1 Mapping a HTTP status (transport HTTP intra-mesh)
|
||||
|
||||
| code | HTTP |
|
||||
|---|---|
|
||||
| `manifest_invalid`, `signature_invalid`, `token_*` | 401 |
|
||||
| `capability_denied`, `binary_not_whitelisted`, `path_not_allowed`, `container_not_allowed`, `device_revoked` | 403 |
|
||||
| `capability_not_found` | 404 |
|
||||
| `nonce_replay`, `approval_mismatch` | 409 |
|
||||
| `approval_timeout`, `duration_exceeded` | 408 |
|
||||
| `output_too_large` | 413 |
|
||||
| `approval_denied` | 403 |
|
||||
| `execution_failed` | 200 (con ok=false, exit_code en body) |
|
||||
| `transport_error`, `internal` | 500 |
|
||||
|
||||
Matrix transport ignora HTTP status — el `ok=false` y `error.code` son suficientes.
|
||||
|
||||
---
|
||||
|
||||
## 11. Security threat model
|
||||
|
||||
Top 10 ataques + mitigaciones. Listadas por probabilidad x impacto. Cada item refiere al control que lo mitiga.
|
||||
|
||||
### T1. Operator ed25519 key leak
|
||||
|
||||
- **Attack**: laptop comprometida, `pass operator/ed25519` exfiltrado.
|
||||
- **Impact**: atacante firma manifests + approvals — control total de devices.
|
||||
- **Mitigation**:
|
||||
- GPG-encrypted at rest via `pass` (depende de GPG subkey).
|
||||
- Rotacion forzada: `!revoke-all` en `#operator-broadcast` → todos los devices reciben `manifest_revoked` con `device_id="*"` → entran en modo enroll.
|
||||
- Hardware-backed key (YubiKey, ssh-agent con touch policy) — out of scope v1, candidato a issue futuro.
|
||||
- Detection: hub registra todas las firmas (mensaje `signed_by_operator_at`); operador revisa diariamente.
|
||||
|
||||
### T2. Compromised device executes unauthorized capabilities
|
||||
|
||||
- **Attack**: device_agent comprometido, atacante quiere ejecutar capabilities fuera del manifest.
|
||||
- **Impact**: limitado a las capabilities del manifest (asumiendo verify es correcto).
|
||||
- **Mitigation**:
|
||||
- Manifest verify obligatorio antes de cada request (§2.5).
|
||||
- Sandbox: `firejail` (Linux) o equivalente — ver issue 0140.
|
||||
- Whitelist binarios + paths + containers (§2).
|
||||
- Audit chain replicado a hub (§7.5) — atacante no puede borrar audit.
|
||||
|
||||
### T3. Replay attack
|
||||
|
||||
- **Attack**: atacante captura request firmado valido (de un log, de Matrix federation leak), lo reenvia.
|
||||
- **Impact**: capability ejecutada 2x.
|
||||
- **Mitigation**: §5. Nonce TTL=300s + ts window 60s. SQLite UNIQUE constraint.
|
||||
|
||||
### T4. MITM despite WireGuard
|
||||
|
||||
- **Attack**: alguien dentro del mesh WG (otro device comprometido) intercepta requests entre bot y device.
|
||||
- **Impact**: leer args/output; modificar args si firma se ignora.
|
||||
- **Mitigation**:
|
||||
- HTTP intra-mesh sobre TLS (cert auto-firmado mesh-only, pinned).
|
||||
- Matrix transport: E2EE via Olm/Megolm — bot debe verificar device keys del room antes de aceptar.
|
||||
- Firma del envelope (§1.3) — args modificados → `signature_invalid`.
|
||||
|
||||
### T5. Container escape
|
||||
|
||||
- **Attack**: container con `docker.container.exec` activado escapa a host.
|
||||
- **Impact**: host comprometido, no mas que T2.
|
||||
- **Mitigation**:
|
||||
- `binaries_whitelist` estricta en `docker.container.exec` (sin `bash`, `sh`, `nsenter`, `unshare`).
|
||||
- Modo "deep" (container con WG-peer propio) solo para containers de propia infra (`agents_and_robots`, `registry_api`).
|
||||
- Docker socket NUNCA expuesto via capability (capability solo via `docker_container_exec_go_infra` que NO usa `--privileged` en exec).
|
||||
- Detection: container con syscalls anomalas → logged por seccomp profile (out of scope v1).
|
||||
|
||||
### T6. Malicious manifest with crafted globs
|
||||
|
||||
- **Attack**: operador firma manifest con `paths_allowed: ["/home/lucas/**"]` pero device_agent tiene bug en glob matcher que permite `..` traversal.
|
||||
- **Impact**: fs.read fuera del directorio.
|
||||
- **Mitigation**:
|
||||
- Implementacion glob: `filepath.Match` Go + canonicalizar path con `filepath.Clean` + verificar `strings.HasPrefix(cleaned, allowed_prefix)`.
|
||||
- Reject paths con `..` antes de glob match.
|
||||
- Test suite con corpus de path traversal (`../../etc/passwd`, `/home/lucas/../etc/passwd`, symlinks).
|
||||
|
||||
### T7. Enrollment token theft
|
||||
|
||||
- **Attack**: token QR fotografiado por tercero.
|
||||
- **Impact**: tercero hace POST /enroll con su propia WG pubkey → device fantasma en la mesh.
|
||||
- **Mitigation**:
|
||||
- TTL=600s.
|
||||
- Single-use (nonce consumed en hub).
|
||||
- Operador recibe alerta en `#operator-approvals` cada vez que un device hace POST /enroll exitoso — si no esperabas un enroll, `!revoke` inmediato.
|
||||
|
||||
### T8. Matrix homeserver compromise
|
||||
|
||||
- **Attack**: atacante root en `organic-machine.com` modifica eventos Matrix.
|
||||
- **Impact**: si E2EE roto, todo el contenido leak. Si E2EE OK, solo metadata.
|
||||
- **Mitigation**:
|
||||
- Megolm E2EE entre operador y bot — keys nunca en disco del homeserver.
|
||||
- Envelope firmado (§1.3) — atacante no puede inyectar requests sin operator key.
|
||||
- Hub WG segregado: `wg_hub` corre en mismo VPS pero NO confia en Matrix para autorizacion (solo para transport).
|
||||
|
||||
### T9. Clock skew abuse
|
||||
|
||||
- **Attack**: device con clock muy adelantado firma requests con `ts` futuro grande, los almacena, los reenvia cuando `ts` cae en ventana.
|
||||
- **Impact**: replay extendido mas alla de TTL.
|
||||
- **Mitigation**:
|
||||
- Ventana ts: `[now-60, now+30]` — clock skew tolerado pequeño.
|
||||
- Devices forzados a sync NTP (chrony) — el provision check verifica `chronyc tracking` reporta `Leap status: Normal`.
|
||||
- Hub alerta a `#operator-approvals` si recibe replicacion de audit con `ts` que difiere >30s del `received_at`.
|
||||
|
||||
### T10. Denial of service via approval flooding
|
||||
|
||||
- **Attack**: atacante con manifest valido pero capabilities limitadas spam `requires_approval=true` requests para inundar `#operator-approvals`.
|
||||
- **Impact**: operador pierde signal en noise; legitimo approval enterrado.
|
||||
- **Mitigation**:
|
||||
- Rate limit en agents_and_robots: por device_id, max 10 approval_requests / 5min.
|
||||
- Excedente → silently dropped + audit entry + `#operator-approvals` recibe resumen agregado (`device home-wsl: 47 approval requests in 5min, throttled`).
|
||||
- Si pattern repetido, operador `!revoke home-wsl`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Implementation order
|
||||
|
||||
Las issues 0135-0143 implementan lo definido en este spec. Dependencias y orden:
|
||||
|
||||
| # | Issue | Que entrega | Depende de | Bloquea |
|
||||
|---|---|---|---|---|
|
||||
| 0135 | capability manifest sign/verify funcs | `capability_manifest_sign_go_infra`, `capability_manifest_verify_go_infra`, `enrollment_token_create_go_infra`, `enrollment_token_verify_go_infra`, `device_audit_append_go_infra`, `device_audit_verify_go_infra`, `operator_keygen_bash_infra` | 0134 (spec) | 0136, 0137, 0140, 0142 |
|
||||
| 0136 | provision_wg_hub pipeline | `provision_wg_hub_bash_pipelines` que compone las 9 funciones `wg_*` (ver flow 0009 Fase C) | 0135 (audit funcs solamente; resto independiente) | 0137 |
|
||||
| 0137 | wg_hub Go service | `apps/wg_hub/` con endpoints `POST /enroll`, `GET /peers`, `POST /peers/:id/revoke`, `POST /audit/replicate`, SSE `/events` | 0135, 0136 | 0138, 0139 |
|
||||
| 0138 | agents_dashboard Mesh panel | Panel ImGui en `apps/agents_dashboard/` con lista de peers, last_handshake, bytes rx/tx, approval queue, boton revoke | 0137 | — |
|
||||
| 0139 | enroll_device pipeline | `enroll_device_bash_pipelines` que el operador corre en su laptop para enroll un device nuevo (genera token, lo muestra como QR, hace POST /enroll en nombre del device si tiene SSH) | 0135, 0137 | 0140, 0141 |
|
||||
| 0140 | device_agent Go binary | `apps/device_agent/` — Matrix client + capability dispatcher + sandbox firejail + audit chain. Cross-compile linux/amd64, linux/arm64, windows/amd64 | 0135, 0139 | 0142 |
|
||||
| 0141 | Android Termux variant | `apps/device_agent_android/` — variante para Termux con WG via wg-go (userspace) y capabilities limitadas (no firejail) | 0140 | — |
|
||||
| 0142 | Matrix bot dispatcher routes | Extender `apps/agents_and_robots/` con dispatcher `m.capability.*` → device, parse de comandos §9, room_devices table | 0135, 0137, 0140 | 0143 |
|
||||
| 0143 | Operator approval flow | Capturar reactions en `#operator-approvals`, firmar approval tokens, enviar a device, registrar timeout. En `apps/agents_and_robots/` | 0142 | — |
|
||||
|
||||
### 12.1 Paralelismo
|
||||
|
||||
- 0135 secuencial (todo lo demas depende).
|
||||
- 0136 + 0140 paralelos tras 0135.
|
||||
- 0137 espera 0136 (necesita las funciones `wg_*`).
|
||||
- 0138 + 0139 + 0140 paralelos tras 0137.
|
||||
- 0141 tras 0140.
|
||||
- 0142 + 0143 ultimo bloque.
|
||||
|
||||
Skill `parallel-fix-issues` puede orquestar 0136/0140 y 0138/0139 en worktrees aislados (ojo: 0140 crea sub-repo `apps/device_agent/`, requiere `git init` dentro como dice `apps_subrepo.md`).
|
||||
|
||||
### 12.2 Acceptance gate para cerrar 0134
|
||||
|
||||
- [ ] Este documento mergeado en `dev/issues/`.
|
||||
- [ ] Issues 0135-0143 creados con frontmatter coherente (`dependencies` apuntando aqui, `related_flows: [0009]`).
|
||||
- [ ] Capabilities groups `wireguard`, `device-agent`, `docker-agent` con stubs en `docs/capabilities/` referenciando este spec.
|
||||
- [ ] No changes en wire format hasta que todos los issues 0135-0143 cierren — cambios posteriores requieren nuevo issue + bump `protocol_version`.
|
||||
|
||||
---
|
||||
|
||||
## Notas
|
||||
|
||||
### Wire format evolution policy
|
||||
|
||||
`protocol_version: mesh/1` es immutable durante el ciclo de vida de las issues 0135-0143. Cualquier cambio breaking (renombrar campo, cambiar canonical bytes, anadir campo obligatorio) requiere bump a `mesh/2` con un issue nuevo que documente migracion y compat layer.
|
||||
|
||||
Cambios non-breaking aceptados sin bump:
|
||||
- Anadir nuevos `error.code` (clientes los manejan via fallback a `internal`).
|
||||
- Anadir nuevas capabilities (devices viejos las rechazan con `capability_not_found`).
|
||||
- Anadir campos opcionales con default backwards-compatible.
|
||||
|
||||
### Test corpus
|
||||
|
||||
Cada funcion de §3 y §4 entrega test fixtures en `cpp/functions/*/testdata/` o equivalente:
|
||||
|
||||
- `manifest_valid.json` + `manifest_valid.sig` — par validable.
|
||||
- `manifest_expired.json` — para test de §2.5 paso 4.
|
||||
- `manifest_tampered.json` + sig original — para test de signature_invalid.
|
||||
- `enroll_token_valid.txt`, `enroll_token_expired.txt`, `enroll_token_wrong_purpose.txt`.
|
||||
- `path_traversal_corpus.txt` con 50+ paths maliciosos para test T6.
|
||||
|
||||
### Capability groups stubs
|
||||
|
||||
Como parte de este issue se crean stubs minimos en:
|
||||
|
||||
- `docs/capabilities/wireguard.md` — lista de las 9 funciones `wg_*` (referenciadas desde flow 0009).
|
||||
- `docs/capabilities/device-agent.md` — capability dispatcher + sandbox + audit chain.
|
||||
- `docs/capabilities/docker-agent.md` — capabilities sobre containers.
|
||||
|
||||
Cada uno con seccion `## Ejemplo canonico` + `## Fronteras` segun regla `capability_groups.md`. Los stubs se llenan a medida que las funciones se crean en 0135-0142.
|
||||
|
||||
### Observabilidad
|
||||
|
||||
- Cada envelope request/response loggea en `call_monitor.calls` con `function_id = capability_<name>_<lang>_<domain>` cuando la implementacion exista. Si la capability es solo metadata (`!help`), no se loggea.
|
||||
- `audit_log` (§7) es separado de `call_monitor` — el primero es tamper-evident del operator, el segundo es telemetria del agente Claude.
|
||||
- Panel "Mesh" de `agents_dashboard` (issue 0138) consume:
|
||||
- `wg_hub::wg_peers` (peers vivos + last_handshake + tx/rx).
|
||||
- `wg_hub::device_audit` (replica de audit chains — para verificacion offline).
|
||||
- `agents_and_robots::room_devices` (mapping rooms ↔ devices).
|
||||
- `agents_and_robots::approvals_pending` (queue de approvals pendientes).
|
||||
|
||||
### Capability growth log
|
||||
|
||||
`v0.1.0 (2026-05-24)` — initial spec mesh/1.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
||||
---
|
||||
id: "0146"
|
||||
title: "add-pc one-shot: añade PC al mesh + agente LLM en <2min desde movil"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0009"]
|
||||
related_issues: ["0134", "0144", "0145"]
|
||||
dependencies: []
|
||||
tags: [mesh, wireguard, ssh, scaffolder, agents, llm, scaling, dx]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Reducir de 8 pasos manuales (~15min) a **1 comando (<2min)** el flujo de añadir un PC nuevo al mesh con su propio agente LLM conversacional. Goal final: chatear desde Element movil con cualquier PC del usuario tras un `./fn run add_pc <name>`.
|
||||
|
||||
## Estado actual (post-0145)
|
||||
|
||||
Pipeline manual funcional pero verboso:
|
||||
1. Instalar wireguard en PC nuevo.
|
||||
2. wg_keygen.
|
||||
3. wg_peer_add en hub (organic-machine.com).
|
||||
4. wg_client_config + wg_client_install.
|
||||
5. Build/scp device_agent binario.
|
||||
6. Manifest YAML local.
|
||||
7. systemd unit.
|
||||
8. provision-agent-user.sh + edit launcher main.go + rebuild + restart agents_and_robots.
|
||||
|
||||
Solo agent-wsl-lucas existe. Bloqueado por friccion de pasos para escalar a aurgi-pc, windows-lucas, raspberry, etc.
|
||||
|
||||
## Vision
|
||||
|
||||
```
|
||||
operador$ ./fn run add_pc aurgi-pc --via wg
|
||||
[1/9] generating WG keypair...
|
||||
[2/9] enrolling peer at hub (10.42.0.21)...
|
||||
[3/9] cross-compiling device_agent for linux/amd64...
|
||||
[4/9] uploading binary + manifest + systemd unit via SSH...
|
||||
[5/9] starting WG + device_agent on remote...
|
||||
[6/9] provisioning Matrix user @agent-aurgi-pc...
|
||||
[7/9] generating agent config + system prompt...
|
||||
[8/9] wiring launcher + rebuild...
|
||||
[9/9] restarting agents_and_robots.service...
|
||||
✓ agent-aurgi-pc live. Send a DM from your Matrix client.
|
||||
```
|
||||
|
||||
Y para hosts sin posibilidad de instalar binary:
|
||||
```
|
||||
operador$ ./fn run add_pc customer-vps-01 --via ssh --ssh-alias customer-prod
|
||||
✓ agent-customer-vps-01 live (ssh-backed). Send a DM.
|
||||
```
|
||||
|
||||
## Arquitectura
|
||||
|
||||
Dos backends para un mismo UX:
|
||||
|
||||
### Backend A — WG + device_agent (mesh nativo)
|
||||
- PC tiene WG client + binary device_agent corriendo.
|
||||
- Comandos viajan VPS → WG → device_agent → exec local → audit chain LOCAL.
|
||||
- 14 capabilities completas (fs.*, git.*, docker.*, pkg.*, proc.*, shell.exec, shell.eval).
|
||||
- Para tus PCs (laptop, desktop, raspberry, mac, movil rooted).
|
||||
|
||||
### Backend B — SSH-only (sin binary remoto)
|
||||
- PC tiene solo SSH server. VPS tiene SSH key autorizada.
|
||||
- Comandos viajan VPS → ssh.Executor → exec remoto → audit en VPS.
|
||||
- Tools reducidos: `ssh_exec(argv)`, `ssh_fs_read`, `ssh_fs_list`. Sin docker/git/pkg salvo wrapper.
|
||||
- Para customer servers, VPS terceros, throwaway boxes.
|
||||
|
||||
LLM agent ve diferentes tool sets segun backend. Mismo system prompt template.
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1 — Pipeline `add_pc_wg_bash_pipelines`
|
||||
|
||||
1.1. Cross-compile device_agent matrices:
|
||||
- `GOOS=linux GOARCH=amd64` (default)
|
||||
- `GOOS=linux GOARCH=arm64` (raspberry pi4+, mac M-series via Linux)
|
||||
- `GOOS=windows GOARCH=amd64`
|
||||
- `GOOS=darwin GOARCH=arm64`
|
||||
- Reusa `nohup` cgo-free build (swap mattn/go-sqlite3 → modernc.org/sqlite si no esta hecho ya).
|
||||
- Output: `cpp/build/cross/device_agent.<os>-<arch>`.
|
||||
|
||||
1.2. Funcion `cross_compile_device_agent_bash_infra(target_os, target_arch)` que devuelve path al binario.
|
||||
|
||||
1.3. Funcion `add_pc_wg_bash_pipelines(name, ssh_alias, target_os?, target_arch?)`. Compone:
|
||||
- wg_keygen_go_infra (hub side: priv hub + psk; client side: priv cliente)
|
||||
- wg_peer_add_go_infra (en hub via SSH al VPS)
|
||||
- wg_client_config_go_infra (genera client.conf)
|
||||
- ensure_remote_wireguard_installed (SSH al target, apt/dnf install wireguard si falta)
|
||||
- wg_client_install_bash_infra (en target via SSH push)
|
||||
- cross_compile_device_agent_bash_infra (local)
|
||||
- rsync_device_agent_bundle (binary + manifest template + systemd unit → target ~/.local/bin/ + ~/.config/device_agent/)
|
||||
- start_device_agent_service (systemctl --user enable --now device_agent)
|
||||
- provision_agent_user (ssh al VPS, ejecuta dev-scripts/agent/provision-agent-user.sh con --mode user)
|
||||
- wire_launcher_import (edita cmd/launcher/main.go en VPS, anade blank import, git commit + rebuild + restart service)
|
||||
- assert_dm_received (espera 30s a que el bot mande "hola" via notify-developer.sh)
|
||||
|
||||
1.4. Manifest template Y matrix per-OS: paths_allowed difieren (`/home/<user>/**` en Linux, `C:\Users\<user>\**` en Windows). Templates en `dev-scripts/agent/templates/manifest.<os>.yaml.tmpl`.
|
||||
|
||||
1.5. Idempotente: re-run con mismo name → no-op + verificar state. Si peer existe pero device_agent caido, restart.
|
||||
|
||||
1.6. Rollback: si paso N falla, deshacer 1..N-1. Estado parcial NO debe quedar (peer huerfano, Matrix user sin agent, etc).
|
||||
|
||||
### Fase 2 — Pipeline `add_pc_ssh_bash_pipelines` (backend B)
|
||||
|
||||
2.1. Funcion `ssh_exec_capability_go_infra` — wrapper que recibe `{argv, host}` y hace `ssh <host> -- <argv...>`. Whitelist binaries opcional. Audit en VPS (`apps/agents_and_robots/ssh_audit.db` o similar).
|
||||
|
||||
2.2. Funcion `ssh_fs_read_capability_go_infra`, `ssh_fs_list_capability_go_infra` (read-only, no write para evitar accidentes en customer boxes).
|
||||
|
||||
2.3. Tool registry adapter: cuando agent config tiene `device_mesh.backend: ssh`, el adapter no apunta a HTTP device_agent — apunta a las funciones `ssh_*` directamente. Mantener interface ToolRegistry pero swap implementation.
|
||||
|
||||
2.4. `add_pc_ssh_bash_pipelines(name, ssh_alias)` compone:
|
||||
- assert_ssh_reachable (BatchMode yes connect test)
|
||||
- provision_agent_user --mode user --backend ssh
|
||||
- generate agent config con `device_mesh.backend: ssh, ssh_alias: <alias>`
|
||||
- wire launcher + restart
|
||||
|
||||
NO toca el remote — solo VPS.
|
||||
|
||||
### Fase 3 — Cross-compile device_agent CGO-free
|
||||
|
||||
3.1. Swap mattn/go-sqlite3 (CGO) → modernc.org/sqlite (pure Go) en device_agent. Tests verde tras swap.
|
||||
|
||||
3.2. `cross_compile_device_agent_bash_infra` produce 4 binarios en <30s.
|
||||
|
||||
3.3. Bundle script `make-bundle.sh <os> <arch>` empaqueta zip con binario + manifest.template + systemd-unit/launchd-plist/Task-Scheduler.xml segun OS.
|
||||
|
||||
### Fase 4 — agents_dashboard "Add device" panel C++
|
||||
|
||||
4.1. Modal nuevo en panel "Devices" con:
|
||||
- Input: nombre del PC.
|
||||
- Dropdown backend: WG mesh / SSH-only.
|
||||
- Si WG: SSH alias para upload + OS/arch detect via uname remote.
|
||||
- Si SSH: solo alias.
|
||||
- Boton "Add". Spawn pipeline en background. Stream logs en TextLog.
|
||||
|
||||
4.2. Grid de status: device_id, IP mesh, last handshake, capabilities count, last command ts, audit chain integrity.
|
||||
|
||||
4.3. Boton "Revoke" por device → llama wg_peer_revoke + deactivate Matrix user + remove launcher import + restart. Confirmacion doble.
|
||||
|
||||
### Fase 5 — Health monitor cron + alertas
|
||||
|
||||
5.1. Cron 5min `monitor_mesh_health_bash_pipelines`:
|
||||
- wg_status → cada peer con last_handshake > 600s → mark stale.
|
||||
- HTTP GET /health a cada device_agent IP del mesh → si falla → mark unreachable.
|
||||
- verify_hash_chain por device → si rota → mark corrupted.
|
||||
|
||||
5.2. Alertas Matrix a `#operator-alerts` (room a crear) con mensaje formato:
|
||||
```
|
||||
[ALERT] device_id=aurgi-pc status=stale (handshake 8min ago)
|
||||
[ALERT] device_id=home-wsl status=hash_chain_corrupted (id=47 broken)
|
||||
```
|
||||
|
||||
5.3. Dashboard tab "Health" muestra el feed SSE.
|
||||
|
||||
### Fase 6 — Movil UX validation
|
||||
|
||||
6.1. Test en Element movil iOS/Android:
|
||||
- Lista de rooms con 1 per device.
|
||||
- Notifications activas → push cuando agent responde.
|
||||
- Smoke tests de capabilities mas comunes via voice-to-text.
|
||||
|
||||
6.2. Documentar `docs/mobile-control.md` con flujo recomendado:
|
||||
- Como agrupar rooms por device en Element favorites.
|
||||
- Comandos comunes ("status", "deploy X", "que esta caido").
|
||||
- Tiempos esperados (claude-code latency 3-5s + tool exec 0.1-2s).
|
||||
|
||||
## Aceptacion (DoD triada)
|
||||
|
||||
### Mecanica
|
||||
- `./fn run add_pc <name> --via wg` exit 0 + agent live en <2min en wallclock.
|
||||
- `./fn run add_pc <name> --via ssh` exit 0 + agent live en <30s.
|
||||
- Tests unit + integration verde en `bash/functions/pipelines/add_pc_*`.
|
||||
|
||||
### Cobertura
|
||||
- Smoke matrix: 4 target OS (linux/amd64, linux/arm64, windows/amd64, darwin/arm64) cada uno con add_pc_wg flujo end-to-end.
|
||||
- Rollback: simular falla en paso 5 (binary upload corrupted) → assert estado limpio (no peer huerfano, no Matrix user, no entry en launcher).
|
||||
- SSH backend: target solo con SSH + sin sudo → agent funciona con tools ssh_exec read-only.
|
||||
- Anti-criterio A3 (heredado de 0009): tras add_pc, smoke real via Matrix → audit DB en device tiene entries reales (no bot hallucination).
|
||||
|
||||
### Vida util
|
||||
- 5 PCs reales añadidos durante 7 dias.
|
||||
- 0 revokes manuales por error de provision.
|
||||
- Operador usa Element movil >=1 sesion/dia interactuando con >=2 devices distintos.
|
||||
- Health monitor detecta peer caido en <10min (test con `wg-quick down` aleatorio).
|
||||
|
||||
### Anti-criterios
|
||||
- Si add_pc deja estado parcial (peer en wg0 + no agent en launcher) → invalida.
|
||||
- Si SSH backend ejecuta comandos sin audit en VPS → invalida (no fake "ssh OK" sin log).
|
||||
- Si dashboard muestra device "online" pero ultimo handshake >24h → invalida (false positive grave).
|
||||
|
||||
## Sub-issues planificados
|
||||
|
||||
| ID | Titulo | Esfuerzo |
|
||||
|---|---|---|
|
||||
| 0146a | cross_compile_device_agent + CGO-free swap a modernc.org/sqlite | 2h ✅ |
|
||||
| 0146b | add_pc_wg_bash_pipelines (Fase 1) | 4h |
|
||||
| 0146c | add_pc_ssh_bash_pipelines + ssh_exec_capability (Fase 2) | 3h |
|
||||
| 0146d | Bundle script multi-OS + manifest templates (Fase 3) | 2h |
|
||||
| 0146e | agents_dashboard panel "Add device" + status grid (Fase 4) | 4h |
|
||||
| 0146f | monitor_mesh_health pipeline + alertas Matrix (Fase 5) | 1.5h |
|
||||
| 0146g | Movil UX doc + smoke real con 4 devices fisicos (Fase 6) | 1h+observacion 7d |
|
||||
|
||||
Total: ~17h dev + 7d observacion.
|
||||
|
||||
## Decisiones de diseño
|
||||
|
||||
1. **Pipeline en bash compose funciones del registry**, no codigo Go monolitico. Permite que cada paso sea trazable + reusable individualmente.
|
||||
|
||||
2. **modernc.org/sqlite** vs mattn/go-sqlite3: pure Go elimina CGO + cross-compile trivial. Performance es comparable (modernc benchmarks dentro del 10% para nuestro workload de audit append).
|
||||
|
||||
3. **Backend SSH NO replica el manifest enforcement remoto** — el manifest vive en VPS y filtra antes de SSH. Trade-off aceptable: SSH backend = "trust the VPS sudo enforcement". Para PCs propios usa WG backend.
|
||||
|
||||
4. **Cada device = un agent Matrix separado** (NO un agent multi-device). Razon: aislamiento blast radius + room por device = UX claro en Element + capability manifest distinto por device. Coste: mas Matrix users + mas claude-code subprocesses.
|
||||
|
||||
5. **NO usar Ansible/Terraform** para este flujo. Pipeline bash + funciones del registry es suficiente y evita la dep externa. Si crece a >50 PCs, reconsiderar.
|
||||
|
||||
## Riesgos
|
||||
|
||||
- **Cross-compile + CGO-free**: el swap a modernc puede romper audit en runtime si schemas no migran. Mitigar con test golden DB + WAL mode check.
|
||||
- **Windows systemd equivalente**: Task Scheduler es feo. Considera nssm.exe para autostart fiable. Documentar bien en bundle.
|
||||
- **SSH key trust amplification**: backend B requiere SSH agent del VPS confiable a TODOS los target hosts. Si VPS comprometido → todos los SSH targets caen. Reforzar con SSH key per-host + revocacion centralizada.
|
||||
- **Mac iCloud signing**: device_agent.app necesitaria notarization para auto-launch en macOS reciente. Skip para POC, abordar si añadimos Mac al mesh real.
|
||||
- **Movil notifications**: Element push depends on FCM (Android) / APNs (iOS). Sin push, el operador puede perderse approvals time-sensitive. Doc sobre alternativas (NTFY, Gotify).
|
||||
|
||||
## Notas
|
||||
|
||||
- **2026-05-24 — 0146a done**: swap mattn/go-sqlite3 → modernc.org/sqlite v1.50.1 (pure Go). 4 binarios cross-compile OK (linux-amd64 11MB, linux-arm64 10MB, windows-amd64 11MB, darwin-arm64 10MB), todos stripped + statically linked. Build script idempotente en `apps/device_agent/build_all.sh`. Self-test pass en linux-amd64 nativo. Quedan smoke tests reales en windows/darwin/arm cuando 0146b despliegue a peers fisicos.
|
||||
- `./fn run add_pc` deberia llamar via mcp__registry__fn_run para que telemetria de issue 0085 quede registrada.
|
||||
- Aprovechar 0144b provision-agent-user.sh que ya esta hecho — solo compone, no reescribe.
|
||||
- Sub-issue 0146g (UX movil) cierra el flow 0009 completo al fin: "humano controla N maquinas desde movil".
|
||||
- Si esto funciona, abrir issue 0147 para "voice control" — Element soporta voice messages; usar transcripcion (Whisper local en VPS) → inyectar como texto al agent.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
id: "0147"
|
||||
title: "matrix-client-pc scaffold: Wails + React+Mantine + login MAS"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0010"]
|
||||
related_issues: ["0148", "0162"]
|
||||
dependencies: ["0162"]
|
||||
tags: [matrix, wails, react, mantine, mas, oidc, scaffold]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Crear el esqueleto de la app `projects/element_agents/apps/matrix_client_pc/` con Wails v2 (Go) + React+Vite+Mantine+`@fn_library` y dejar funcionando el login MAS OIDC contra `mas-...organic-machine.com`. Resultado: arrancar binario -> redirect navegador a MAS -> volver con token -> mostrar perfil del usuario.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. `wails init -n matrix_client_pc -t react-ts` dentro de `projects/element_agents/apps/`.
|
||||
2. Sub-repo Gitea: `git init -b master` + crear repo `dataforge/matrix_client_pc` + push inicial.
|
||||
3. `app.md` con frontmatter (lang=go, framework=wails, tags incluyen `matrix` + `service`? — NO, es app cliente, sin tag service).
|
||||
4. `go.mod` con deps: `wails/v2`, `mautrix-go`, `keyring`.
|
||||
5. Reemplazar template frontend por React+Mantine+`@fn_library`. Symlink `frontend/src/fn_library` -> `../../../../../frontend/functions/ui/` (o copia si symlink no funciona en build).
|
||||
6. Backend Go (`backend/`):
|
||||
- `wails.json` con `bindings` para `MatrixService`.
|
||||
- `MatrixService.Login() -> URL` (devuelve URL MAS OIDC).
|
||||
- `MatrixService.HandleCallback(code) -> User`.
|
||||
- `MatrixService.GetSession() -> *Session` (lee de keyring).
|
||||
- `MatrixService.Logout()`.
|
||||
7. Frontend React: layout `AppShell` Mantine, pagina `Login.tsx` con boton "Sign in with Matrix" -> abre URL MAS en navegador del SO.
|
||||
8. Persistencia tokens en keyring SO (`github.com/zalando/go-keyring`).
|
||||
9. Loopback HTTP local (`127.0.0.1:0`, puerto libre aleatorio) para recibir callback OIDC.
|
||||
10. Test e2e basico: arrancar app, login con `@dev-pc:matrix-af2f3d.organic-machine.com`, ver perfil.
|
||||
|
||||
## Funciones del registry a crear (delegar a fn-constructor)
|
||||
|
||||
- `matrix_client_init_go_infra` — `mautrix.NewClient(homeserver, userID, accessToken) -> *Client, error`. Wrapper que configura SQLite store + crypto store.
|
||||
- `mas_oidc_flow_go_infra` — `StartFlow(masURL) -> authURL, codeVerifier, state`. `ExchangeCode(code, codeVerifier) -> *Token`.
|
||||
- `keyring_save_token_go_infra` / `keyring_load_token_go_infra` — wrappers `go-keyring`.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Binario Wails compila para linux/amd64 + windows/amd64.
|
||||
- [ ] `wails dev` arranca con hot-reload.
|
||||
- [ ] Login MAS OIDC end-to-end: boton -> navegador -> consent -> callback -> perfil visible.
|
||||
- [ ] Token persistido entre re-arranques (no re-login si token vigente).
|
||||
- [ ] `app.md` con `uses_functions` que apunta a las 3 funciones nuevas.
|
||||
- [ ] Sub-repo `dataforge/matrix_client_pc` creado con commit inicial.
|
||||
|
||||
## Notas
|
||||
|
||||
- MAS URL: leerla de `.well-known/matrix/client` del homeserver para no hardcodear.
|
||||
- Refresh token: MAS usa OAuth 2.0 estandar — implementar refresh proactivo (~5min antes de expiry).
|
||||
- Gotcha: en Windows, `wails dev` requiere WebView2 instalado.
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
id: "0148"
|
||||
title: "matrix-client-pc rooms list + timeline con sync incremental"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0010"]
|
||||
related_issues: ["0147", "0149"]
|
||||
dependencies: ["0147"]
|
||||
tags: [matrix, sync, timeline, rooms, react, mantine, sse]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Sidebar con rooms (DMs + spaces + grupos) + panel central con timeline del room activo. Sync incremental con Synapse via long-poll `/sync`. Stream eventos backend -> frontend via SSE (`http_sse_server_go_infra`). Pagination scroll-up (cargar mensajes anteriores). Optimistic UI al enviar.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Backend Go:
|
||||
- `MatrixService.StartSync()` — long-poll `/sync` con since token persistido.
|
||||
- `MatrixService.SubscribeEvents() -> chan Event` — broadcaster events a frontend.
|
||||
- SSE endpoint `http://127.0.0.1:<puerto>/events` (autenticado con cookie session local).
|
||||
- Persistir state en SQLite (`store.db`): rooms, members, last_event_id por room.
|
||||
2. Frontend React:
|
||||
- Hook `useMatrixRooms()` — devuelve `Room[]` ordenadas por last_activity.
|
||||
- Hook `useMatrixTimeline(roomId, limit=50)` — devuelve eventos + `loadMore()`.
|
||||
- Componente `RoomList` (sidebar con avatar, nombre, last_msg preview, unread badge).
|
||||
- Componente `Timeline` con `react-virtuoso` para scroll perf con miles de msgs.
|
||||
- Componente `EventBubble` (text, image, file, redacted, reaction agregada).
|
||||
- Reconnect automatico si SSE/sync cae (exponential backoff).
|
||||
3. Tests:
|
||||
- `e2e/test_sync_basic.sh` — login + verificar que 3 rooms aparecen en sidebar.
|
||||
- `e2e/test_pagination.sh` — scroll-up carga mensajes anteriores sin gap.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `matrix_room_subscribe_go_infra` — SSE wrapper: subscribe events de Synapse y push a clientes.
|
||||
- `useMatrixTimeline_ts_ui` — hook React con dedupe + pagination + optimistic.
|
||||
- `useMatrixRooms_ts_ui` — hook React rooms list.
|
||||
- `RoomList_ts_ui` — componente sidebar Mantine.
|
||||
- `EventBubble_ts_ui` — componente burbuja msg.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Sidebar lista rooms del usuario test, ordenados por actividad.
|
||||
- [ ] Click en room muestra timeline ultimos 50 msgs.
|
||||
- [ ] Scroll arriba carga msgs anteriores sin duplicar.
|
||||
- [ ] Mensaje enviado desde Element Web aparece en <2s en la timeline.
|
||||
- [ ] Cerrar app + abrir: state restaurado desde SQLite, no re-sync completo.
|
||||
- [ ] Network kill + restore: sync se reanuda sin perder mensajes.
|
||||
|
||||
## Notas
|
||||
|
||||
- DMs vs rooms grupales: detectar via `m.direct` account data.
|
||||
- Spaces (`m.space`): mostrar como grupos colapsables en sidebar.
|
||||
- Edits + redactions: aplicar in-place, no duplicar bubble.
|
||||
- Read receipts: TBD en otro issue, no bloquea este.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
id: "0149"
|
||||
title: "matrix-client-pc composer: markdown, reply, edit, reactions, media"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0010"]
|
||||
related_issues: ["0148", "0150"]
|
||||
dependencies: ["0148"]
|
||||
tags: [matrix, composer, markdown, media, reactions, threads]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Composer del room: markdown rendering, replies con quote, edits, reactions emoji, threads (Matrix MSC3440), upload de media (imagenes, files, voice msg). Drag&drop archivos. Slash commands placeholder (`/me`, `/shrug`, `/widget` — este ultimo para issue 0152).
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Backend Go:
|
||||
- `MatrixService.SendMessage(roomID, body, format)` — text + markdown -> HTML via `goldmark`.
|
||||
- `MatrixService.SendReply(roomID, parentEventID, body)`.
|
||||
- `MatrixService.EditMessage(roomID, eventID, newBody)`.
|
||||
- `MatrixService.SendReaction(roomID, eventID, key)`.
|
||||
- `MatrixService.UploadMedia(roomID, filePath) -> mxc://`.
|
||||
- `MatrixService.SendThreadReply(roomID, threadRootID, body)`.
|
||||
2. Frontend React:
|
||||
- Componente `Composer` con Mantine `Textarea` + toolbar markdown.
|
||||
- Hotkeys: Cmd+B/I/K, Cmd+Enter para enviar, Esc cancel edit.
|
||||
- Drag&drop zone over Composer + paste image desde clipboard.
|
||||
- `EmojiPicker` (reusar `@emoji-mart/react` o componente propio `@fn_library`).
|
||||
- `ReactionBar` debajo de EventBubble con aggregates.
|
||||
- Thread panel lateral (abrir click en evento "X replies").
|
||||
- Voice messages: graba con `MediaRecorder` (opus codec), upload + send con `org.matrix.msc3245.voice` flag.
|
||||
3. Tests:
|
||||
- `e2e/test_send_markdown.sh` — `**bold**` aparece negrita en otro cliente.
|
||||
- `e2e/test_edit_message.sh` — edicion aparece in-place en Element Web.
|
||||
- `e2e/test_reaction.sh` — reaccion emoji propagada bidireccional.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `markdown_to_matrix_html_go_core` — `goldmark` con sanitizer Matrix-compatible.
|
||||
- `Composer_ts_ui` — componente Mantine + dropzone.
|
||||
- `EmojiPicker_ts_ui` — wrapper picker emoji.
|
||||
- `ReactionBar_ts_ui` — componente reactions aggregadas.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Mensaje markdown `**negrita** _cursiva_` se ve formateado en Element Web.
|
||||
- [ ] Reply quote aparece referenciando el msg padre.
|
||||
- [ ] Edit cambia el msg in-place en ambos clientes.
|
||||
- [ ] Reaccion emoji con click aparece como counter agregado.
|
||||
- [ ] Upload imagen (PNG 2MB) se ve thumbnail + click abre full.
|
||||
- [ ] Voice msg grabado 5s reproduce OK en Element Web.
|
||||
- [ ] Thread: 5 replies anidados se muestran en panel lateral.
|
||||
|
||||
## Notas
|
||||
|
||||
- Sanitizer HTML: usar allowlist Matrix (b, i, em, strong, a[href], code, pre, blockquote, ul, ol, li, br, p, h1-h6). NO permitir `<script>`, `<iframe>`, event handlers.
|
||||
- mxc:// uploads: validar size limit (Synapse default 50MB).
|
||||
- Voice msg: encode opus 32kbps, max 5min.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
id: "0150"
|
||||
title: "matrix-client-pc E2EE: cross-signing, SAS verification, recovery"
|
||||
status: pending
|
||||
priority: critical
|
||||
created: 2026-05-24
|
||||
related_flows: ["0010"]
|
||||
related_issues: ["0149", "0151"]
|
||||
dependencies: ["0149"]
|
||||
tags: [matrix, e2ee, olm, megolm, cross-signing, recovery, security]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Encriptacion end-to-end con `mautrix-go` (Olm/Megolm). Cross-signing keys (master/self-signing/user-signing), SAS verification de devices (emoji + decimal), recovery passphrase + key backup en Synapse, manejo de devices no verificados con warning visible. Mensajes en rooms encriptados se envian y descifran correctamente.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Backend Go:
|
||||
- `MatrixService.BootstrapCrossSigning(passphrase)` — genera master/self/user keys, sube a Synapse cifradas con passphrase-derived key.
|
||||
- `MatrixService.RecoverFromPassphrase(passphrase)` — descarga keys de Synapse y descifra.
|
||||
- `MatrixService.StartVerification(userID, deviceID) -> *VerificationSession`.
|
||||
- `MatrixService.VerifyEmoji(sessionID, accepted bool)`.
|
||||
- `MatrixService.ListDevices() -> []Device` (con verified flag).
|
||||
- `MatrixService.BackupMegolmKeys()` — key backup server-side.
|
||||
- Crypto store SQLite separado del state store (mejor para integridad).
|
||||
2. Frontend React:
|
||||
- Wizard onboarding E2EE: pasos (1) generar passphrase, (2) backup, (3) verificar device.
|
||||
- Panel `Settings > Security & Privacy`:
|
||||
- Lista devices propios con verified state.
|
||||
- Boton "Verify new device" + dialog SAS con emoji grid.
|
||||
- "Reset cross-signing" (destructive, requiere confirmacion).
|
||||
- "Restore from passphrase" (login en device nuevo).
|
||||
- `EventBubble` muestra shield: green (verified), amber (encrypted, device unverified), red (decryption failed).
|
||||
- Banner room: "X devices are not verified" si algun miembro tiene devices unverified.
|
||||
3. Tests:
|
||||
- `e2e/test_e2ee_send_receive.sh` — msg enviado en room encriptado se descifra en Element Web.
|
||||
- `e2e/test_cross_signing.sh` — bootstrap + verificar device desde Element Web.
|
||||
- `e2e/test_recovery.sh` — login en device nuevo + recover keys con passphrase.
|
||||
- `e2e/test_unverified_warning.sh` — device nuevo aparece como warning en otros clientes.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `matrix_e2ee_bootstrap_go_infra` — wrapper cross-signing bootstrap.
|
||||
- `matrix_device_verify_go_infra` — SAS verification flow.
|
||||
- `matrix_key_backup_go_infra` — server-side key backup wrapper.
|
||||
- `passphrase_derive_key_go_infra` — PBKDF2/scrypt para derivar key de passphrase.
|
||||
- `VerificationDialog_ts_ui` — componente emoji grid SAS.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Bootstrap cross-signing crea 3 keys + las sube a Synapse cifradas.
|
||||
- [ ] Msg enviado a room encriptado se descifra en Element Web (y al reves).
|
||||
- [ ] SAS verification con emoji grid funciona contra Element Web (ambos lados muestran 7 emojis iguales).
|
||||
- [ ] Login en device nuevo + restore con passphrase recupera msgs historicos.
|
||||
- [ ] Device no verificado dispara shield amber en EventBubble.
|
||||
- [ ] Decryption failure (key no disponible) muestra shield rojo + boton "Request key".
|
||||
|
||||
## Notas
|
||||
|
||||
**Critico — anti-criterio:**
|
||||
- NO marcar done si E2EE silent-falla (msg muestra "** Unable to decrypt **" sin shield rojo claro).
|
||||
- NO marcar done si recovery passphrase queda en plain text en disco (debe vivir solo en keyring/memoria).
|
||||
|
||||
**Decisiones:**
|
||||
- Olm/Megolm via `mautrix-go/crypto` (Go port estable de libolm).
|
||||
- Alternativa rust-crypto via CGo: descartada, mantiene complejidad build.
|
||||
- Passphrase format: 4 palabras Diceware o 12-byte base32. Usuario elige al bootstrap.
|
||||
|
||||
**Gotchas:**
|
||||
- Key rotation: rooms encriptados rotan megolm cada 1 semana o 100 msgs (default). Manejar refresh.
|
||||
- Olm sessions max 100 mensajes: rotar prekey bundles automaticamente.
|
||||
- Cuando arrancas device nuevo sin passphrase, los msgs pre-existentes NO se descifran — UI debe ser clara.
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
id: "0151"
|
||||
title: "matrix-client-pc calls LiveKit: 1:1 + grupales, mic/cam/screen"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0010"]
|
||||
related_issues: ["0150", "0152"]
|
||||
dependencies: ["0150"]
|
||||
tags: [matrix, livekit, calls, webrtc, video, audio, screen-share]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Llamadas via LiveKit SFU (ya activo en `organic-machine.com:7880-7882`). Backend Go genera JWT con `livekit-server-sdk-go`. Frontend React usa `livekit-client` JS para join room, manejar tracks (mic/cam/screen), UI con tiles participantes, controles. Soporta 1:1 + grupales hasta 16 (limite config actual).
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Backend Go:
|
||||
- `MatrixService.RequestCallToken(matrixRoomID) -> (token, livekitRoomURL)`.
|
||||
- Mapea Matrix roomID -> LiveKit room name (hash determinista).
|
||||
- Genera JWT con claim `room`, `identity` (matrix userID), `ttl 30min`.
|
||||
- Permisos: `canPublish=true, canSubscribe=true, canPublishData=true`.
|
||||
- Publicar event Matrix `m.call.member` para sincronizar quien esta en call (MSC3401).
|
||||
2. Frontend React:
|
||||
- Hook `useLiveKitCall(matrixRoomID)`:
|
||||
- Pide token al backend.
|
||||
- Conecta `Room` de `livekit-client`.
|
||||
- Expone participants, tracks, localTracks, state.
|
||||
- Auto-publish microfono on connect (mute default).
|
||||
- Componente `CallPanel`:
|
||||
- Grid tiles participantes (1, 2, 4, 9, 16 layout).
|
||||
- Tile principal con speaker activo (active-speaker detection del SDK).
|
||||
- Controles bottom: mic, cam, screen share, raise hand, leave.
|
||||
- PiP mode: cuando minimizado, tile flotante en esquina.
|
||||
- Boton "Start call" en header del room (icono telefono).
|
||||
- Boton "Join call" si hay call activa (segun `m.call.member` events).
|
||||
- Notifs ring incoming call: audio + desktop notif.
|
||||
3. Backend ICE/TURN:
|
||||
- Verificar LiveKit config tiene TURN configurado (NAT traversal). Si no, anadir coturn container.
|
||||
4. Tests:
|
||||
- `e2e/test_call_1to1.sh` — 2 clientes (Wails + Element Web), 30s call, audio+video flow.
|
||||
- `e2e/test_call_screen_share.sh` — compartir pantalla, otro cliente ve el track.
|
||||
- `e2e/test_call_4_participants.sh` — 4 clientes simultaneos, no crash.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `livekit_token_gen_go_infra` — JWT generator con `livekit-server-sdk-go`.
|
||||
- `matrix_call_member_go_infra` — wrapper para publicar/leer `m.call.member` state events.
|
||||
- `useLiveKitCall_ts_ui` — hook React.
|
||||
- `CallPanel_ts_ui` — componente UI completo de call.
|
||||
- `CallTile_ts_ui` — tile individual con video + nombre + speaker indicator.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Boton "Start call" en room DM con otro user.
|
||||
- [ ] Otro cliente (Element Web) ve ring + acepta -> 2 tiles con video+audio.
|
||||
- [ ] Mute mic + apagar cam funciona y se refleja en el otro lado.
|
||||
- [ ] Screen share: tile separado aparece para todos los participantes.
|
||||
- [ ] 4 participantes simultaneos sin crash ni audio cortado.
|
||||
- [ ] Hangup limpia recursos (no tracks fantasma, no peer connections abiertas).
|
||||
|
||||
## Notas
|
||||
|
||||
- LiveKit room name: `sha256(matrix_room_id + secret)` truncado a 32 chars. Asi cualquier cliente que conozca el matrix_room_id puede computar el room name (no es secret).
|
||||
- Token TTL 30min, refresh proactivo a los 25min.
|
||||
- Codecs: H.264 + VP8 fallback para compatibilidad navegadores. Audio: Opus 32kbps.
|
||||
- E2EE en calls: LiveKit soporta E2EE simetrico (insertable streams API). TBD para version posterior — flow inicial usa SRTP only (cifrado SFU<->client, no e2e).
|
||||
- Sygnal push para incoming calls: enviar VoIP push con TTL bajo para wake-up moviles (relevante para issue 0158 Android).
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
id: "0152"
|
||||
title: "matrix-client-pc mini-webapps embebidas: Matrix Widget API v2"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0010"]
|
||||
related_issues: ["0151", "0153"]
|
||||
dependencies: ["0151"]
|
||||
tags: [matrix, widgets, webapps, iframe, sandbox, agents, postmessage]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Implementar host de widgets segun Matrix Widget API v2 (MSC2762, MSC2871, MSC2974). Cada room puede tener widgets activos publicados como state events `m.widget`. Los widgets son URLs cargadas en iframes sandboxed con bridge postMessage que da capabilities controladas (leer eventos del room, enviar eventos, mostrar UI overlay, etc.). Agentes de `agents_and_robots` pueden publicar widgets en sus rooms (ej. dashboard telemetria, formulario, kanban inline, panel de control del agente).
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Backend Go:
|
||||
- `MatrixService.ListWidgets(roomID) -> []Widget` — lee state events `m.widget` del room.
|
||||
- `MatrixService.AddWidget(roomID, widget Widget)` — publica state event.
|
||||
- `MatrixService.RemoveWidget(roomID, widgetID)`.
|
||||
- `MatrixService.GenerateWidgetURL(widget Widget, userID) -> string` — substituye `$matrix_user_id`, `$matrix_room_id`, `$matrix_display_name`, `$matrix_avatar_url`, `$matrix_widget_id`, `$theme` en la URL del widget.
|
||||
- Slash command `/widget <url>` handler en composer (issue 0149) que crea state event con widget temporal.
|
||||
- `MatrixService.MintWidgetScopedToken(widgetID, userID) -> string` — token efimero con scope reducido (solo el room donde esta el widget).
|
||||
2. Frontend React:
|
||||
- Hook `useWidgets(roomID)` — lista widgets activos.
|
||||
- Componente `WidgetPanel`:
|
||||
- Tabs por widget activo + boton "+" para anadir.
|
||||
- Cada widget en iframe con `sandbox="allow-scripts allow-same-origin allow-forms allow-popups-to-escape-sandbox"`.
|
||||
- `iframe.referrerpolicy="no-referrer"`.
|
||||
- CSP: `frame-src https: data: blob:`.
|
||||
- `WidgetBridge` — clase JS que escucha `postMessage` del iframe e implementa Widget API v2:
|
||||
- `capabilities` handshake: el widget declara que necesita, el host pide consentimiento usuario (dialog Mantine).
|
||||
- `read_events`, `send_event`, `send_to_device`, `get_openid`, `m.always_on_screen`, etc.
|
||||
- Whitelist estricta de capabilities concedidas. Audit log de mensajes en `store.db`.
|
||||
- Layout: widgets se abren en panel lateral derecho (toggleable) o en modal fullscreen.
|
||||
3. Widgets internos primer batch (proof of concept):
|
||||
- `widget-jitsi-fallback` — si LiveKit falla, fallback a Jitsi via widget (URL config).
|
||||
- `widget-agent-panel` — panel de control de agente: estado, ultima ejecucion, restart, view logs. Servido por `agents_and_robots` HTTP API (issue 0113 ya creando agent runner API).
|
||||
- `widget-kanban` — kanban inline embebido para tasks del room. Reusa `apps/kanban` (Go) servido en LAN.
|
||||
- `widget-issue-tracker` — widget que abre issue API (`0109m`).
|
||||
4. Tests:
|
||||
- `e2e/test_widget_capabilities.sh` — widget pide capability, dialog aparece, deniega/acepta funciona.
|
||||
- `e2e/test_widget_send_event.sh` — widget con capability `send_event` envia msg al room.
|
||||
- `e2e/test_widget_sandbox.sh` — widget malicioso (intenta `top.location =`) es bloqueado por sandbox.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `matrix_widget_state_go_infra` — CRUD state events `m.widget`.
|
||||
- `widget_url_template_go_core` — substituye placeholders en URL.
|
||||
- `widget_token_mint_go_infra` — token scoped a un widget+room+user.
|
||||
- `WidgetBridge_ts_ui` — clase postMessage bridge Widget API v2 completa.
|
||||
- `WidgetPanel_ts_ui` — UI tabs + iframes + permisos.
|
||||
- `CapabilityConsentDialog_ts_ui` — dialog Mantine para consentimiento.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] `/widget https://my.app` crea state event y abre iframe.
|
||||
- [ ] Widget declara capability `m.send_event` -> dialog Mantine pide consentimiento.
|
||||
- [ ] Widget concedido envia msg al room que aparece en timeline.
|
||||
- [ ] Widget malicioso `<script>top.location='evil.com'</script>` bloqueado por sandbox.
|
||||
- [ ] `agents_and_robots` publica widget panel y se ve embebido en el room del agente.
|
||||
- [ ] Widget kanban inline funciona: drag&drop card persiste en DB del kanban.
|
||||
|
||||
## Notas
|
||||
|
||||
**Anti-criterios:**
|
||||
- NO permitir `javascript:` ni `data:text/html` URLs (XSS).
|
||||
- NO conceder capabilities sin consentimiento explicito del usuario (auditable).
|
||||
- NO compartir el access_token Matrix del usuario al widget — usar siempre tokens scoped efimeros.
|
||||
|
||||
**Decisiones:**
|
||||
- Widget API v2 (no v1) — soporta capabilities + tokens scoped.
|
||||
- iframe sandbox sin `allow-top-navigation` (previene escape).
|
||||
- CSP `frame-src https:` + permitir `data:`/`blob:` solo para widgets internos firmados.
|
||||
|
||||
**Roadmap post-DoD:**
|
||||
- Widget marketplace interno: `widget-catalog` en `agents_and_robots` con widgets internos descubribles.
|
||||
- Widget templates: un agente publica un widget HTML estatico subido al room (`mxc://`) y el cliente lo renderiza desde la URL `mxc -> http`.
|
||||
- Cross-room widgets: widget que persiste entre rooms (TBD, requiere MSC propio).
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
id: "0153"
|
||||
title: "matrix-client-pc agent integration: paneles para rooms operados por agentes"
|
||||
status: pending
|
||||
priority: medium
|
||||
created: 2026-05-24
|
||||
related_flows: ["0010", "0009"]
|
||||
related_issues: ["0152"]
|
||||
dependencies: ["0152"]
|
||||
tags: [matrix, agents, agents_and_robots, dashboard, sse, device_agent]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Integracion nativa con `agents_and_robots` + `agents_dashboard` + futuro `device_agent` (flow 0009 mesh). Detectar que un room esta operado por un agente Matrix conocido (via state event custom `m.agent.metadata`) y mostrar panel lateral con info del agente: uptime, ultima ejecucion, cola de tasks, last_error, boton restart, view logs en vivo (SSE). Atajos: enviar slash commands del agente (`/agent restart`, `/agent skill <name>`).
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Backend Go:
|
||||
- `MatrixService.GetAgentMetadata(roomID) -> *AgentMetadata` — lee state event `m.agent.metadata` que el agente publica al arrancar.
|
||||
- `MatrixService.SubscribeAgentLogs(agentID) -> chan LogLine` — SSE proxy al endpoint `agents_and_robots /api/agents/<id>/logs` ya existente (issue 0113).
|
||||
- Llamadas REST proxy a `agents_and_robots`: `RestartAgent(agentID)`, `ListSkills(agentID)`, `TriggerSkill(agentID, skill, args)`.
|
||||
2. Frontend React:
|
||||
- Hook `useAgentMetadata(roomID)` — devuelve `null` si no es room de agente.
|
||||
- Componente `AgentPanel` (panel lateral colapsable, solo visible si hay agentMetadata):
|
||||
- Card con avatar, nombre, version, uptime, status (running/stopped/error).
|
||||
- Tabs: "Logs" (live SSE), "Skills" (lista de skills disponibles + boton trigger), "Config" (read-only del config.yaml del agente).
|
||||
- Boton restart con confirmacion.
|
||||
- Componente `LogStream` — termtinal-like log viewer con auto-scroll + filtro grep.
|
||||
- Slash commands custom: `/agent restart`, `/agent skill <name> <args>`, `/agent logs`.
|
||||
3. Cuando flow 0009 (mesh) este vivo:
|
||||
- Detectar `device_agent` rooms (state event `m.device.metadata` con tipo `device_agent`).
|
||||
- Panel especifico `DevicePanel`: hostname, OS, kernel, IP mesh WG, capabilities firmadas, ultimo heartbeat.
|
||||
- Slash commands: `/device shell <cmd>` (si capability permite), `/device fs ls <path>`, `/device camera capture`.
|
||||
4. Tests:
|
||||
- `e2e/test_agent_panel_basic.sh` — entrar a room de `welcome-bot`, panel agente visible con info correcta.
|
||||
- `e2e/test_agent_logs_live.sh` — boton "view logs" stream logs en tiempo real (5s).
|
||||
- `e2e/test_agent_restart.sh` — restart desde panel + verificar agente vuelve online.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `matrix_agent_metadata_go_infra` — leer/publicar state event `m.agent.metadata`.
|
||||
- `agents_and_robots_client_go_infra` — wrapper REST + SSE del API de `agents_and_robots`.
|
||||
- `AgentPanel_ts_ui` — panel lateral Mantine con tabs.
|
||||
- `LogStream_ts_ui` — viewer logs SSE.
|
||||
- `DevicePanel_ts_ui` — panel device_agent (cuando flow 0009 vivo).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Room operado por agente conocido muestra `AgentPanel` automatico.
|
||||
- [ ] Logs en vivo del agente aparecen en panel (SSE).
|
||||
- [ ] Restart desde panel funciona end-to-end.
|
||||
- [ ] Slash `/agent skill greet` ejecuta skill remota y respuesta llega como msg al room.
|
||||
- [ ] Room NO operado por agente: panel oculto (no clutter).
|
||||
|
||||
## Notas
|
||||
|
||||
- State event `m.agent.metadata` format: `{ agent_id, version, capabilities[], owner, repo_url }`. Documentar en `projects/element_agents/docs/agent_metadata.md`.
|
||||
- SSE proxy: el cliente PC habla a `agents_and_robots` via su DNS publica (`agents.organic-machine.com`) con auth Bearer (token del usuario Matrix + scope `agent_panel`).
|
||||
- Permisos: solo el `owner` declarado en el agente puede ejecutar restart/trigger. Otros users del room solo leen.
|
||||
- Gotcha: si el agente se rebuilds y cambia `agent_id`, el state event queda obsoleto — necesita TTL o heartbeat.
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
id: "0154"
|
||||
title: "matrix-client-android scaffold: Kotlin + Compose + login MAS"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0011"]
|
||||
related_issues: ["0155", "0162"]
|
||||
dependencies: ["0162"]
|
||||
tags: [matrix, android, kotlin, compose, mas, oidc, scaffold]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Crear `projects/element_agents/apps/matrix_client_android/` con `init_kotlin_app` (pipeline ya existente del registry). Configurar Compose + Material 3 + tema propio. Implementar login MAS OIDC via Chrome Custom Tabs. Tokens persistidos en EncryptedSharedPreferences. Resultado: APK debug que abre Custom Tab al MAS, retorna con token y muestra perfil del usuario.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. `./fn run init_kotlin_app matrix_client_android` — usa pipeline existente del registry (ver issues completados 0073-0078).
|
||||
2. Sub-repo Gitea: `git init -b master` + crear `dataforge/matrix_client_android` + push inicial. **Antes** de salir del worktree (ver `apps_subrepo.md`).
|
||||
3. `app.md` con frontmatter:
|
||||
- `lang: kotlin`, `framework: jetpack-compose`, `dir_path: projects/element_agents/apps/matrix_client_android`.
|
||||
- `tags: [matrix, android, kotlin, compose]`.
|
||||
- `uses_functions: []` (irlo rellenando issue a issue).
|
||||
4. `build.gradle.kts`:
|
||||
- `compileSdk = 34`, `minSdk = 28`, `targetSdk = 34`.
|
||||
- Compose BOM `2024.x`.
|
||||
- `matrix-rust-sdk` Kotlin bindings (`org.matrix.rustcomponents:sdk-android:0.x`).
|
||||
- `androidx.security:security-crypto` para EncryptedSharedPreferences.
|
||||
- `androidx.browser:browser` para Chrome Custom Tabs.
|
||||
5. Login MAS:
|
||||
- `LoginActivity` con boton "Sign in with Matrix".
|
||||
- Generar PKCE code_verifier + state.
|
||||
- Abrir Chrome Custom Tab a `<mas_url>/oauth/authorize?...`.
|
||||
- `MainActivity` con intent-filter para `matrix-client-android://callback` redirect.
|
||||
- Intercambiar code -> access_token + refresh_token.
|
||||
- Guardar en EncryptedSharedPreferences (`SecurityCryptoUserPrefs`).
|
||||
6. `HomeScreen` Compose con `Text("Hola @<userId>")` + boton Logout.
|
||||
7. Tema Material 3 propio (paleta accent acorde a flow 0010 cliente PC para coherencia).
|
||||
8. Test instrumented: `LoginInstrumentedTest` que mocka MAS y verifica flow callback -> token saved.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `matrix_client_kotlin_infra` — facade sobre `matrix-rust-sdk` (init, login, sync, logout).
|
||||
- `mas_oidc_kotlin_infra` — Chrome Custom Tabs + PKCE + callback handler.
|
||||
- `encrypted_prefs_kotlin_core` — wrapper EncryptedSharedPreferences (idempotente, generic put/get).
|
||||
- `LoginScreen_kotlin_ui` — Compose screen Material 3.
|
||||
- `HomeScreen_kotlin_ui` — Compose screen perfil + logout.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] `./gradlew assembleDebug` produce APK valido.
|
||||
- [ ] APK instala en Android 9+ y arranca.
|
||||
- [ ] Login: boton -> Custom Tab MAS -> consent -> callback -> perfil visible.
|
||||
- [ ] Token persiste entre re-aperturas (no re-login si vigente).
|
||||
- [ ] `app.md` con frontmatter completo + 5 `uses_functions`.
|
||||
- [ ] Sub-repo `dataforge/matrix_client_android` con commit inicial.
|
||||
- [ ] Test instrumented `LoginInstrumentedTest` pasa en emulator API 31.
|
||||
|
||||
## Notas
|
||||
|
||||
- Chrome Custom Tabs > WebView para OAuth (security: comparte cookies con browser principal del user, mejor UX).
|
||||
- Refresh token: implementar refresh proactivo 5min antes de expiry (corutina + WorkManager periodic).
|
||||
- Gotcha conocido (ver issue 0074): `local.properties` con `sdk.dir` obligatorio en setup nuevo. El scaffolder lo crea.
|
||||
- Gotcha (issue 0075): Material 3 sin AppCompat — usar `MaterialTheme` directamente, no `Theme.AppCompat.*`.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
id: "0155"
|
||||
title: "matrix-client-android rooms list + timeline Compose"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0011"]
|
||||
related_issues: ["0154", "0156"]
|
||||
dependencies: ["0154"]
|
||||
tags: [matrix, android, compose, sync, timeline, rooms]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
UI Compose con `Scaffold` que muestre sidebar drawer con rooms y panel principal con timeline. Sync via `matrix-rust-sdk` (corrutinas + Flow). `LazyColumn` virtualizado para timeline (perf con miles de mensajes). Swipe-to-react en mensajes. Optimistic UI al enviar (en issue 0156).
|
||||
|
||||
## Tareas
|
||||
|
||||
1. ViewModels:
|
||||
- `RoomsViewModel(matrixClient)` — expone `StateFlow<List<RoomSummary>>`. Ordenado por `lastActivity`.
|
||||
- `TimelineViewModel(matrixClient, roomId)` — expone `StateFlow<List<TimelineEvent>>` + `loadMore()`.
|
||||
- Persistencia local con Room DB (`androidx.room`) — store rooms + last sync token.
|
||||
2. Compose:
|
||||
- `MainScreen` con `ModalNavigationDrawer`:
|
||||
- Drawer: `RoomList` (LazyColumn con `RoomItem`: avatar, name, last preview, unread badge).
|
||||
- Content: `TimelineScreen(roomId)`.
|
||||
- `TimelineScreen`:
|
||||
- `LazyColumn` con `reverseLayout = true` (mensajes recientes abajo).
|
||||
- `key = { it.eventId }` para evitar re-composiciones.
|
||||
- `LaunchedEffect` con `LazyListState` -> al llegar al top, `viewModel.loadMore()`.
|
||||
- `EventBubble` composables segun tipo (text, image, file, redacted).
|
||||
- `Avatar` composable reusable con cache de imagenes (`Coil`).
|
||||
3. Sync engine:
|
||||
- `MatrixSyncService` (corrutina supervisor scope) que mantiene `client.syncStream()`.
|
||||
- Si pasa a background sin call activa, sync se pausa hasta que vuelve foreground (lifecycle-aware).
|
||||
- Errores de red: backoff exponencial (1s, 2s, 4s ... 60s max).
|
||||
4. Tests:
|
||||
- Instrumented `RoomsListTest` — 3 rooms aparecen en drawer.
|
||||
- Instrumented `TimelinePaginationTest` — scroll-up carga 50 msgs anteriores.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `matrix_room_summary_kotlin_infra` — extract `RoomSummary` de matrix-rust-sdk.
|
||||
- `matrix_timeline_kotlin_infra` — Flow de eventos paginados.
|
||||
- `RoomListScreen_kotlin_ui` — Compose drawer rooms.
|
||||
- `TimelineScreen_kotlin_ui` — Compose timeline virtualizado.
|
||||
- `EventBubble_kotlin_ui` — composable burbuja msg.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Drawer lista rooms del usuario test.
|
||||
- [ ] Click en room muestra timeline ultimos 50 msgs.
|
||||
- [ ] Swipe arriba carga msgs anteriores sin gap.
|
||||
- [ ] Msg enviado desde PC (Wails) aparece en Android en <2s.
|
||||
- [ ] Avion mode + restore: sync resume, no msgs perdidos.
|
||||
- [ ] Cerrar app + reopen: state restaurado desde Room DB, no full re-sync.
|
||||
|
||||
## Notas
|
||||
|
||||
- `matrix-rust-sdk` ya gestiona persistencia interna (SQLite + crypto store). Room DB local solo para datos UI-rapidos (room summaries, unread counters).
|
||||
- Read receipts: TBD otro issue.
|
||||
- DMs detectados via `m.direct` account data.
|
||||
- Spaces: `RoomItem` con icono diferente, colapsable.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
id: "0156"
|
||||
title: "matrix-client-android composer: markdown, replies, edits, reactions, media"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0011"]
|
||||
related_issues: ["0155", "0157"]
|
||||
dependencies: ["0155"]
|
||||
tags: [matrix, android, compose, composer, markdown, media, voice]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Composer Compose con markdown shortcuts, replies, edits, reactions emoji, threads, upload media (camara nativa, galeria, voice msg con `MediaRecorder` opus). Drag&drop archivos compartidos via share sheet Android.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. ViewModel:
|
||||
- `ComposerViewModel(matrixClient, roomId)` — methods `sendText`, `sendReply`, `editMessage`, `sendReaction`, `uploadMedia`, `recordVoice`.
|
||||
2. Compose:
|
||||
- `Composer` con `OutlinedTextField` + toolbar (markdown shortcuts B/I/code).
|
||||
- Hotkeys soft keyboard: Send action en IME.
|
||||
- `AttachmentMenu`: botones camara, galeria, file, voice.
|
||||
- `EmojiPicker` overlay (reusar libreria existente o componente propio).
|
||||
- `ReactionBar` debajo de `EventBubble` con aggregates.
|
||||
- `ThreadScreen` — nueva pantalla full para thread (no panel lateral como en PC, por screen real estate movil).
|
||||
- Voice recording UI: hold-to-record con waveform preview + cancelar al deslizar.
|
||||
3. Backend:
|
||||
- Upload media: comprimir imagenes si >2MB antes de upload (`androidx.exifinterface` para preservar orientacion).
|
||||
- Voice: `MediaRecorder` con OPUS, 32kbps, ogg container.
|
||||
- Markdown -> HTML local con `markwon` library (lightweight, no Goldmark equivalente).
|
||||
4. Share intent:
|
||||
- `IntentFilter` para `android.intent.action.SEND` + tipos image/video/text/file -> abre composer del room seleccionado.
|
||||
5. Tests:
|
||||
- Instrumented `SendMarkdownTest` — `**bold**` formateado en Element Web.
|
||||
- Instrumented `EditMessageTest` — edicion in-place propagada.
|
||||
- Instrumented `VoiceMsgTest` — graba 5s + upload + play en Element Web.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `markdown_to_matrix_html_kotlin_core` — wrapper markwon con sanitizer.
|
||||
- `image_compress_kotlin_core` — resize + recompress JPEG.
|
||||
- `voice_record_kotlin_infra` — MediaRecorder opus wrapper.
|
||||
- `Composer_kotlin_ui` — Compose composer + toolbar + attachment menu.
|
||||
- `ReactionBar_kotlin_ui` — composable reactions.
|
||||
- `ThreadScreen_kotlin_ui` — pantalla thread.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Mensaje markdown se ve formateado en Element Web.
|
||||
- [ ] Reply con quote del msg padre.
|
||||
- [ ] Edit in-place propagado en ambos clientes.
|
||||
- [ ] Reaccion emoji bidireccional.
|
||||
- [ ] Upload imagen 5MB -> compresion a ~1MB -> envio + thumbnail OK.
|
||||
- [ ] Voice msg 5s reproducible en Element Web.
|
||||
- [ ] Share intent desde galeria abre composer con imagen pre-cargada.
|
||||
|
||||
## Notas
|
||||
|
||||
- Sanitizer HTML server-side delegado a matrix-rust-sdk (mismo allowlist que cliente PC).
|
||||
- Voice msg: encode opus 32kbps, max 5min.
|
||||
- Markwon vs goldmark: ambos cumplen el rol equivalente en su stack. Salida HTML compatible Matrix.
|
||||
- Drag&drop: en Android = share sheet o picker, no drag&drop nativo como en PC.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
id: "0157"
|
||||
title: "matrix-client-android E2EE rust-sdk: cross-signing, SAS, recovery"
|
||||
status: pending
|
||||
priority: critical
|
||||
created: 2026-05-24
|
||||
related_flows: ["0011"]
|
||||
related_issues: ["0156", "0158"]
|
||||
dependencies: ["0156"]
|
||||
tags: [matrix, android, e2ee, rust-sdk, cross-signing, sas, security]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Encriptacion end-to-end con `matrix-rust-sdk` Kotlin bindings (mejor impl Olm/Megolm disponible). Cross-signing keys, SAS verification con emoji, recovery passphrase, key backup server-side. UI para verificar otros usuarios + manejar devices propios.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. ViewModel:
|
||||
- `SecurityViewModel(matrixClient)`:
|
||||
- `bootstrapCrossSigning(passphrase)`.
|
||||
- `recoverFromPassphrase(passphrase)`.
|
||||
- `startVerification(userId, deviceId) -> VerificationSession`.
|
||||
- `verifyEmoji(sessionId, accepted)`.
|
||||
- `listOwnDevices() -> Flow<List<Device>>`.
|
||||
- `backupMegolmKeys()`.
|
||||
2. Compose:
|
||||
- `OnboardingE2EEScreen` — wizard 3 pasos: generar passphrase, backup, verify primer device.
|
||||
- `SettingsSecurityScreen`:
|
||||
- Lista devices propios con badge verified/unverified.
|
||||
- Dialog SAS con emoji grid 7x1 cuando hay verificacion en curso.
|
||||
- Boton "Reset cross-signing" (destructive, requiere typing "RESET").
|
||||
- Boton "Restore from passphrase".
|
||||
- `EventBubble` con icono shield (green/amber/red).
|
||||
- Banner room con "X devices not verified" si aplica.
|
||||
3. Crypto store:
|
||||
- `matrix-rust-sdk` gestiona internamente. Solo asegurar que `applicationContext.filesDir` es persistente entre upgrades.
|
||||
- Backup local del store (export encriptado) antes de uninstall: feature opcional via "Export to file" en settings.
|
||||
4. Tests:
|
||||
- Instrumented `BootstrapCrossSigningTest`.
|
||||
- Instrumented `VerificationSASTest` con mock peer.
|
||||
- Instrumented `RecoveryFromPassphraseTest`.
|
||||
- E2E manual con Element Web: enviar/recibir msg E2EE, verificar device cross-platform.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `matrix_e2ee_kotlin_infra` — wrapper rust-sdk encryption module.
|
||||
- `passphrase_derive_key_kotlin_core` — PBKDF2 wrapper.
|
||||
- `VerificationDialog_kotlin_ui` — Compose emoji grid SAS.
|
||||
- `OnboardingE2EEScreen_kotlin_ui` — wizard.
|
||||
- `SettingsSecurityScreen_kotlin_ui` — devices + verification UI.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Bootstrap crea cross-signing keys + sube cifradas.
|
||||
- [ ] Msg enviado en room E2EE se descifra en Element Web + cliente PC Wails (y al reves).
|
||||
- [ ] SAS verification con emoji grid vs Element Web: ambos 7 emojis iguales, accept funciona.
|
||||
- [ ] Login device nuevo + restore passphrase recupera msgs historicos.
|
||||
- [ ] Device no verificado dispara shield amber en EventBubble.
|
||||
- [ ] Decryption failure muestra shield rojo + boton "Request key".
|
||||
|
||||
## Notas
|
||||
|
||||
**Anti-criterios:**
|
||||
- NO marcar done si E2EE silent-falla (mensaje no descifrado pero sin warning visible).
|
||||
- NO marcar done si passphrase queda en plain text en disco.
|
||||
- NO marcar done si cross-signing no funciona contra cliente PC Wails (interop critica).
|
||||
|
||||
**Decisiones:**
|
||||
- `matrix-rust-sdk` >> matrix-android-sdk2 (deprecated). Olm/Megolm en Rust = mejor perf + sin memory leaks.
|
||||
- Passphrase format igual que cliente PC (4 palabras Diceware o 12-byte base32).
|
||||
|
||||
**Gotchas:**
|
||||
- Key rotation Megolm: rust-sdk lo gestiona, pero monitorizar logs en primera semana de uso real.
|
||||
- Olm sessions max: rust-sdk auto-rotate, no accion manual.
|
||||
- Devices nuevos sin passphrase: msgs pre-existentes NO se descifran. UI debe ser clara.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
id: "0158"
|
||||
title: "matrix-client-android calls LiveKit nativo: mic/cam/screen + PiP"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0011"]
|
||||
related_issues: ["0157", "0159", "0161"]
|
||||
dependencies: ["0157"]
|
||||
tags: [matrix, android, livekit, calls, webrtc, pip, audio-focus]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Llamadas nativas via `io.livekit:livekit-android` SDK oficial. Codecs HW (H.264/VP9 hardware decoder), audio focus + AEC/NS nativos, MediaSession para controls en lockscreen, Picture-in-Picture mode Android nativo. Soporta 1:1 + grupales (limite 16 del LiveKit config actual).
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Backend (compartido con cliente PC):
|
||||
- Reusar `livekit_token_gen_go_infra` que esta en flow 0010.
|
||||
- Cliente Android pide token al mismo endpoint `/api/call/token` que el cliente PC.
|
||||
2. ViewModel:
|
||||
- `CallViewModel(matrixClient, roomId)`:
|
||||
- `joinCall()` — pide token + conecta `Room.connect()`.
|
||||
- `toggleMic()`, `toggleCamera()`, `toggleScreenShare()`.
|
||||
- `hangup()`.
|
||||
- `Flow<CallState>` con participants, tracks, connection state.
|
||||
3. Compose:
|
||||
- `CallScreen` fullscreen:
|
||||
- Grid tiles participantes (`Flow` layout responsive 1/2/4/9/16).
|
||||
- Tile principal: active speaker (track audio level del SDK).
|
||||
- Controles bottom: mic, cam, screen, raise hand, hangup.
|
||||
- `IncomingCallScreen` fullscreen con accept/decline (system overlay activity).
|
||||
- `CallTile` composable con `VideoView` (SurfaceViewRenderer del SDK).
|
||||
4. PiP (Picture-in-Picture):
|
||||
- `Activity` con `setPictureInPictureParams()`.
|
||||
- Auto-enter PiP al minimizar la app durante call.
|
||||
- PiP tile: video remoto + boton hangup.
|
||||
5. Audio routing:
|
||||
- `AudioFocusRequest` (Android 8+) — focus exclusivo durante call.
|
||||
- Switch speaker/earpiece/bluetooth via `AudioManager.setSpeakerphoneOn()` + connection state listeners para audifonos BT.
|
||||
- Echo cancellation + noise suppression: SDK los habilita por defecto, verificar.
|
||||
6. ICE/TURN: igual que cliente PC, depende del LiveKit config server-side.
|
||||
7. Tests:
|
||||
- Instrumented `Call1to1Test` con emulator + segundo cliente (PC) — connect, video, hangup.
|
||||
- Manual `ScreenShareTest` con device fisico.
|
||||
- Manual `4ParticipantsTest`.
|
||||
- Manual `PiPTest` — call activa + Home button -> PiP aparece.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `livekit_call_kotlin_infra` — wrapper `Room` SDK + permission helpers.
|
||||
- `audio_routing_kotlin_infra` — speaker/earpiece/BT switching.
|
||||
- `CallScreen_kotlin_ui` — fullscreen call UI.
|
||||
- `CallTile_kotlin_ui` — tile con VideoView.
|
||||
- `IncomingCallScreen_kotlin_ui` — accept/decline overlay activity.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Start call desde Android -> PC Wails recibe y conecta.
|
||||
- [ ] 30s call con video+audio nativo (verificar HW codec via `adb shell dumpsys media.codec`).
|
||||
- [ ] Mute mic + apagar cam refleja en otro cliente.
|
||||
- [ ] Screen share desde Android (con `MediaProjection`) visible en PC.
|
||||
- [ ] PiP: minimizar app durante call -> tile flotante con video remoto.
|
||||
- [ ] Bluetooth headphones: cambio automatico al conectar/desconectar.
|
||||
- [ ] Battery: call 30min con AC + WiFi <15% drain.
|
||||
|
||||
## Notas
|
||||
|
||||
- Permissions runtime: `RECORD_AUDIO`, `CAMERA`, `POST_NOTIFICATIONS` (Android 13+), `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_MEDIA_PROJECTION` (Android 14+).
|
||||
- Foreground service requerido para mantener call con app en background (issue 0161).
|
||||
- E2EE en call (insertable streams): TBD post-DoD, igual que en cliente PC.
|
||||
- Connection service Android (sistema): TBD, opcional. Permite integracion con dialer system + Bluetooth Car. Valorar coste/beneficio.
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
id: "0159"
|
||||
title: "matrix-client-android push FCM via sygnal + Firebase setup"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0011"]
|
||||
related_issues: ["0158", "0160"]
|
||||
dependencies: ["0154"]
|
||||
tags: [matrix, android, push, fcm, firebase, sygnal, infra]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Notificaciones push moviles via FCM (Firebase Cloud Messaging) usando `sygnal` (push gateway oficial de Matrix). Sygnal recibe push events de Synapse, traduce a payload FCM, enviado a Firebase, entregado al device. La app despierta para mostrar notificacion del mensaje, o trigger ringer para incoming calls. App en background o muerta tambien recibe.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Infra (modifica `element_matrix_chat` app):
|
||||
- Anadir container `sygnal` al `docker-compose.yml`. Config en `configs/sygnal.yaml`.
|
||||
- Service account JSON de Firebase en `configs/firebase-sa.json` (gitignored, instalado en VPS via secrets).
|
||||
- Synapse config: pushers habilitados (ya por defecto).
|
||||
- Reverse proxy: `https://push-<hash>.organic-machine.com/_matrix/push/v1/notify` -> sygnal:5000.
|
||||
- Documentar setup en `projects/element_agents/apps/element_matrix_chat/docs/sygnal_setup.md`.
|
||||
2. Firebase:
|
||||
- Crear proyecto `fn-registry-matrix-push` en Firebase console.
|
||||
- Habilitar Cloud Messaging.
|
||||
- Generar service account JSON.
|
||||
- Anadir `google-services.json` al modulo Android (`app/google-services.json`).
|
||||
3. Android app:
|
||||
- `build.gradle`: `com.google.gms:google-services`, `com.google.firebase:firebase-messaging`.
|
||||
- `FirebaseMessagingService` subclass:
|
||||
- `onNewToken(token)` -> registrar en sygnal via Synapse Pusher API `POST /_matrix/client/v3/pushers/set`.
|
||||
- `onMessageReceived(message)` -> parse data payload + mostrar notif.
|
||||
- Notification channels (Android 8+):
|
||||
- `messages` — IMPORTANCE_HIGH, sonido.
|
||||
- `calls` — IMPORTANCE_HIGH, full-screen intent (despertar pantalla).
|
||||
- `silent` — IMPORTANCE_LOW.
|
||||
- VoIP push para calls: payload con `prio=high`, `event_id_only=false` (incluir event para mostrar caller info sin sync completo).
|
||||
4. Tests:
|
||||
- Instrumented `FCMTokenRegistrationTest` — mock Firebase, verificar pusher creado en Synapse.
|
||||
- Manual `PushDeliveryTest` — enviar msg desde Element Web a Android offline -> push aparece <3s.
|
||||
- Manual `PushCallTest` — start call desde PC -> Android offline despierta + ring.
|
||||
- Manual `PushBatterySaverTest` — Android en battery saver + Doze mode + push sigue llegando.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `sygnal_setup_bash_infra` — script setup container sygnal en VPS.
|
||||
- `sygnal_config_template_go_infra` — generador `sygnal.yaml` con Firebase SA.
|
||||
- `fcm_register_kotlin_infra` — onNewToken + register en Synapse Pusher API.
|
||||
- `synapse_pusher_set_go_infra` — Go helper REST `POST /pushers/set` (reutilizable PC + Android).
|
||||
- `NotificationBuilder_kotlin_ui` — helper notification channels + actions.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Container `sygnal` activo en VPS, health check `:5000/_matrix/push/v1/notify` HEAD 200.
|
||||
- [ ] Firebase project creado + SA JSON instalada en VPS.
|
||||
- [ ] App Android registra FCM token + crea pusher en Synapse al primer login.
|
||||
- [ ] Msg desde Element Web a Android (app cerrada por user) -> push notif en <3s.
|
||||
- [ ] Start call desde cliente PC -> Android offline despierta + ring 30s.
|
||||
- [ ] Battery saver activo: push sigue llegando (FCM high priority bypasses Doze).
|
||||
- [ ] Multiple users: pusher por device, no se cruzan.
|
||||
|
||||
## Notas
|
||||
|
||||
**Gotcha critico:** FCM no entrega push si:
|
||||
- App ha sido force-stopped por user (system requirement).
|
||||
- Device tiene "Restricted background usage" en battery settings.
|
||||
- Account Google no esta sincronizada en el device.
|
||||
Documentar en onboarding para que el user lo entienda.
|
||||
|
||||
**Privacy:** payload FCM no debe contener contenido del msg en claro (Synapse E2EE). Solo: `room_id`, `event_id`, `unread_count`, `prio`. App hace sync interno al recibir push para obtener msg cifrado y descifrar local.
|
||||
|
||||
**Coste:** FCM gratis para hosting Firebase. Sygnal CPU/RAM despreciable (<50MB).
|
||||
|
||||
**Alternativas exploradas:**
|
||||
- UnifiedPush + ntfy: open-source, sin Google. Pro: privacy. Con: requiere infraestructura propia + onboarding mas duro. Post-DoD considerar como segunda opcion para users sin Google Play.
|
||||
|
||||
**Decisiones futuras (post-DoD):**
|
||||
- iOS equivalent: APNs via sygnal mismo gateway. Cuando llegue cliente iOS.
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
id: "0160"
|
||||
title: "matrix-client-android mini-webapps: WebView + Widget API v2 bridge"
|
||||
status: pending
|
||||
priority: medium
|
||||
created: 2026-05-24
|
||||
related_flows: ["0011"]
|
||||
related_issues: ["0159", "0161"]
|
||||
dependencies: ["0159"]
|
||||
tags: [matrix, android, webview, widgets, agents, sandbox]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Host de widgets en Android equivalente al cliente PC (issue 0152). Mismo contrato Widget API v2. WebView con sandbox estricto + bridge JS-Kotlin implementa capabilities API. Widgets de los rooms operados por agentes (`agents_and_robots`) se ven embebidos: dashboard, formulario, kanban inline, control del agente.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. ViewModel:
|
||||
- `WidgetsViewModel(matrixClient, roomId)`:
|
||||
- `Flow<List<Widget>>` desde state events `m.widget` del room.
|
||||
- `addWidget(widget)`, `removeWidget(widgetId)`.
|
||||
- `generateUrl(widget) -> String` — substituye placeholders Matrix Widget API.
|
||||
- `mintScopedToken(widgetId) -> String` — token efimero scope room+widget.
|
||||
2. Compose:
|
||||
- `WidgetsPanel` (drawer lateral o bottom sheet en movil):
|
||||
- Tabs con widgets activos del room.
|
||||
- Cada tab = `WidgetView` que envuelve un `WebView`.
|
||||
- `WidgetView` composable:
|
||||
- `WebView` configurado:
|
||||
- `settings.javaScriptEnabled = true`.
|
||||
- `settings.allowFileAccess = false`.
|
||||
- `settings.allowContentAccess = false`.
|
||||
- `settings.allowFileAccessFromFileURLs = false`.
|
||||
- `settings.allowUniversalAccessFromFileURLs = false`.
|
||||
- `settings.mixedContentMode = MIXED_CONTENT_NEVER_ALLOW`.
|
||||
- `webViewClient` con CSP injection + URL allowlist.
|
||||
- `addJavascriptInterface(WidgetBridge, "MatrixWidgetBridge")` — bridge expone Widget API v2.
|
||||
- `CapabilityConsentDialog` Compose — pide consentimiento usuario para capabilities.
|
||||
3. WidgetBridge (Kotlin):
|
||||
- Implementa capabilities handshake postMessage (igual contrato que cliente PC):
|
||||
- `read_events`, `send_event`, `send_to_device`, `get_openid`, `m.always_on_screen`.
|
||||
- Audit log mensajes JS<->Kotlin en local DB.
|
||||
- Whitelist estricta de capabilities concedidas.
|
||||
4. Widgets internos primer batch (compartidos con cliente PC):
|
||||
- `widget-agent-panel` — control del agente.
|
||||
- `widget-kanban` — kanban inline.
|
||||
- `widget-issue-tracker`.
|
||||
5. Tests:
|
||||
- Instrumented `WidgetCapabilitiesTest` — dialog aparece + accept/decline funciona.
|
||||
- Instrumented `WidgetSandboxTest` — widget malicioso (intenta `window.location='file:///etc/passwd'`) bloqueado.
|
||||
- Instrumented `WidgetSendEventTest` — widget con capability envia msg.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `WidgetView_kotlin_ui` — Compose WebView wrapper sandboxed.
|
||||
- `widget_bridge_kotlin_infra` — JavascriptInterface implementando Widget API v2.
|
||||
- `widget_url_template_kotlin_core` — substituyente placeholders (puede compartirse logica con la Go version del PC, contrato identico).
|
||||
- `CapabilityConsentDialog_kotlin_ui` — Compose dialog.
|
||||
- `widget_audit_log_kotlin_infra` — append-only audit log en Room DB.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Widget publicado desde cliente PC se ve embebido en Android (mismo room).
|
||||
- [ ] Capability handshake: widget pide `send_event` -> dialog Compose -> accept -> widget envia msg.
|
||||
- [ ] Sandbox: widget intenta `XMLHttpRequest` a `file:///` -> bloqueado.
|
||||
- [ ] Widget agent-panel funcional: muestra logs en vivo del agente + boton restart.
|
||||
- [ ] Audit log persiste en Room DB con timestamp + capability + accept/deny.
|
||||
|
||||
## Notas
|
||||
|
||||
**Critico:**
|
||||
- Mismo contrato Widget API v2 que cliente PC. Widget HTML escrito una vez funciona en ambos.
|
||||
- WebView Android moderno (Chromium 100+) soporta WebRTC + WebGL + service workers. Suficiente para widgets ricos.
|
||||
|
||||
**Gotcha:**
|
||||
- `WebView.addJavascriptInterface` solo seguro en Android 4.2+ (API 17+, ya minSdk=28). Pero validar todo input desde JS — nunca confiar.
|
||||
- `setAllowFileAccessFromFileURLs(false)` solo aplica si la URL del widget es `file://`. Nuestros widgets son `https://` -> hardcode CSP estricta.
|
||||
- Memory: WebView por tab + 5 widgets activos = ~200MB facil. Limitar a max 3 widgets simultaneos activos.
|
||||
|
||||
**Roadmap post-DoD:**
|
||||
- Widget marketplace catalog accesible via menu.
|
||||
- "Add to home screen" PWA mode para widgets favoritos (Android shortcut + launcher icon dedicado).
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
id: "0161"
|
||||
title: "matrix-client-android foreground service: calls + lifecycle + lockscreen"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0011"]
|
||||
related_issues: ["0158", "0160"]
|
||||
dependencies: ["0158"]
|
||||
tags: [matrix, android, foreground-service, lifecycle, mediasession, wakelock]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
`CallForegroundService` que mantiene call activa con app en background o pantalla bloqueada. Notification ongoing visible mientras dura la call. `MediaSession` para integrar con lockscreen controls + Bluetooth Car (mute, hangup desde audio device). Wakelock controlado para evitar drain excesivo. Notificaciones full-screen intent para incoming calls (despiertan pantalla).
|
||||
|
||||
## Tareas
|
||||
|
||||
1. `CallForegroundService` (`android.app.Service`):
|
||||
- `START_FOREGROUND_SERVICE` con type `MEDIA_PROJECTION` o `PHONE_CALL` (Android 14+ requiere type explicito).
|
||||
- `Notification.Builder` channel `calls` con:
|
||||
- Custom view con caller name, duration, mute/hangup buttons.
|
||||
- `setOngoing(true)`.
|
||||
- `setCategory(CATEGORY_CALL)`.
|
||||
- Lifecycle: `START_STICKY` para reiniciar si OS lo mata (raro con foreground).
|
||||
2. `MediaSession` integration:
|
||||
- `MediaSessionCompat` con play/pause/stop actions mapeados a mute/unmute/hangup.
|
||||
- Bluetooth Car media controls.
|
||||
- Lockscreen controls visibles si dispositivo lo soporta.
|
||||
3. Wakelock:
|
||||
- `PowerManager.PARTIAL_WAKE_LOCK` durante call activa.
|
||||
- `WAKE_LOCK_KEY = "matrix_client:call"` para audit en `dumpsys power`.
|
||||
- Liberar inmediato al hangup.
|
||||
- Proximity wakelock (`PROXIMITY_SCREEN_OFF_WAKE_LOCK`) si call solo audio + telefono pegado a oreja.
|
||||
4. Incoming call full-screen intent:
|
||||
- `Notification` con `setFullScreenIntent(pendingIntent, true)`.
|
||||
- Activity `IncomingCallActivity` con `showWhenLocked(true)` + `turnScreenOn(true)`.
|
||||
- Compose UI fullscreen con accept/decline.
|
||||
5. Doze mode handling:
|
||||
- `ACTION_IGNORE_BATTERY_OPTIMIZATIONS` solicitar al user en onboarding (no obligatorio, solo para calls fiables).
|
||||
- Documentar tradeoff en pantalla onboarding.
|
||||
6. Battery monitoring:
|
||||
- Log custom: call duration + battery_drain_pct al hangup.
|
||||
- Visible en `Settings > Diagnostics` para debug.
|
||||
7. Tests:
|
||||
- Manual `CallBackgroundTest` — start call + Home button -> notif visible + audio sigue.
|
||||
- Manual `CallLockscreenTest` — call + power button -> pantalla apaga + audio sigue + lockscreen controls visibles.
|
||||
- Manual `IncomingFullScreenTest` — device en lockscreen + incoming call -> pantalla despierta + UI accept/decline.
|
||||
- Manual `BluetoothCarTest` — Bluetooth Car connected + call active + mute desde steering wheel funciona.
|
||||
- Manual `BatteryTest` — call 30min en background + WiFi + AC -> drain <15%.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `CallForegroundService_kotlin_infra` — service completo.
|
||||
- `media_session_kotlin_infra` — wrapper MediaSessionCompat.
|
||||
- `wakelock_manager_kotlin_infra` — adquirir/liberar wakelocks de forma idempotente.
|
||||
- `IncomingCallActivity_kotlin_ui` — Compose fullscreen activity.
|
||||
- `battery_monitor_kotlin_infra` — log drain por session.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Call activa + Home -> notif ongoing visible + audio sigue 30s.
|
||||
- [ ] Call + power button -> lockscreen muestra controls + audio sigue.
|
||||
- [ ] Incoming call con pantalla apagada -> despierta + UI accept/decline.
|
||||
- [ ] Bluetooth Car: mute/hangup desde steering wheel funciona.
|
||||
- [ ] Hangup libera wakelocks (verificar con `dumpsys power | grep matrix_client`).
|
||||
- [ ] Battery saver activo: call no se corta (foreground service exempt).
|
||||
- [ ] Call 30min background: drain <15% con WiFi+AC.
|
||||
|
||||
## Notas
|
||||
|
||||
**Anti-criterios:**
|
||||
- NO marcar done si call se corta a los 5min en background (battery optimization kill).
|
||||
- NO marcar done si wakelock queda colgado tras hangup (battery leak).
|
||||
- NO marcar done si lockscreen no muestra controls (UX critico para calls largas).
|
||||
|
||||
**Gotchas Android 14+:**
|
||||
- Foreground service type DEBE declararse en manifest + runtime: `phoneCall|mediaProjection`.
|
||||
- `POST_NOTIFICATIONS` runtime permission (Android 13+).
|
||||
- `USE_FULL_SCREEN_INTENT` runtime permission (Android 14+) — pedir explicito.
|
||||
|
||||
**Decisiones:**
|
||||
- Telecom framework (ConnectionService): NO en esta iteracion. Pro: integracion dialer nativo. Con: bug-prone, requiere CALL_PHONE permission con justificacion Play Store. Post-DoD considerar.
|
||||
- Audio focus exclusivo durante call (issue 0158 ya lo cubre).
|
||||
|
||||
**Battery optimization onboarding:**
|
||||
- Pantalla en primer launch: explicar por que pedimos exempt battery optimization (calls fiables).
|
||||
- Boton "Open settings" -> `Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`.
|
||||
- Si user declina: app funciona pero documentar que calls largas pueden cortarse.
|
||||
@@ -0,0 +1,197 @@
|
||||
---
|
||||
id: "0162"
|
||||
title: "Matrix: migrar Synapse a MAS como unico auth provider (MSC3861)"
|
||||
status: pending
|
||||
priority: critical
|
||||
created: 2026-05-24
|
||||
related_flows: ["0010", "0011"]
|
||||
related_issues: ["0147", "0154", "0163"]
|
||||
dependencies: []
|
||||
tags: [matrix, mas, synapse, msc3861, auth, oidc, migration, infra]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Activar `matrix_authentication_service` en Synapse para que TODO login pase por MAS (Matrix Authentication Service) via MSC3861. Estado actual: MAS corre 6 semanas pero esta en pie sin clients registrados. Synapse usa login password legacy + application_service. Element Web, Synapse-Admin y clientes nuevos (flows 0010 + 0011) deben autenticarse exclusivamente contra MAS via OIDC.
|
||||
|
||||
Bloquea flows 0010 (matrix-client-pc) + 0011 (matrix-client-android) porque ambos asumen MAS funcional.
|
||||
|
||||
## Estado actual
|
||||
|
||||
```yaml
|
||||
# synapse_data/homeserver.yaml — comentado, NO activo:
|
||||
# matrix_authentication_service:
|
||||
# enabled: true
|
||||
# endpoint: "http://mas:8080/"
|
||||
# secret: "<shared_secret>"
|
||||
|
||||
experimental_features:
|
||||
msc3266_enabled: true
|
||||
msc4222_enabled: true
|
||||
msc4354_enabled: true
|
||||
# msc4108_delegation_endpoint: "https://auth-af2f3d.organic-machine.com/_matrix/client/unstable/org.matrix.msc4108/rendezvous"
|
||||
```
|
||||
|
||||
```yaml
|
||||
# mas/config.yaml
|
||||
clients: [] # vacio
|
||||
public_base: https://auth-af2f3d.organic-machine.com/
|
||||
```
|
||||
|
||||
```
|
||||
GET /_matrix/client/v3/login -> {"flows":[{"type":"m.login.password"},{"type":"m.login.application_service"}]}
|
||||
GET /.well-known/matrix/client -> sin org.matrix.msc2965.authentication
|
||||
```
|
||||
|
||||
## Tareas
|
||||
|
||||
1. **Pre-migracion: backup completo**
|
||||
- Snapshot postgres Synapse: `docker exec element_matrix_chat-postgres-1 pg_dump -U synapse synapse > /backup/synapse_$(date +%Y%m%d).sql`.
|
||||
- Snapshot postgres MAS: idem `mas-postgres`.
|
||||
- Snapshot `synapse_data/` + `mas/config.yaml`.
|
||||
- Guardar backups en VPS local + descargar copia a PC.
|
||||
|
||||
2. **Registrar clients en MAS** (`mas/config.yaml`):
|
||||
- Cliente para Synapse (admin/internal): `client_id` + `client_secret` o `client_auth_method: client_secret_basic`.
|
||||
- Cliente para Element Web: `redirect_uris: [https://element-a05ae4.organic-machine.com/]`.
|
||||
- Cliente para nuevo admin panel (issue 0163): `redirect_uris: [<admin_panel_url>]`.
|
||||
- Cliente para matrix_client_pc (flow 0010): `redirect_uris: [http://127.0.0.1:*]` (loopback dinamico).
|
||||
- Cliente para matrix_client_android (flow 0011): `redirect_uris: [matrix-client-android://callback]`.
|
||||
- Aplicar: `docker exec element_matrix_chat-mas-1 mas-cli config sync`.
|
||||
|
||||
3. **Activar MSC3861 en Synapse**:
|
||||
- Editar `synapse_data/homeserver.yaml`:
|
||||
```yaml
|
||||
matrix_authentication_service:
|
||||
enabled: true
|
||||
endpoint: "http://mas:8080/"
|
||||
secret: "<shared_secret_matching_mas_config>"
|
||||
experimental_features:
|
||||
msc3861:
|
||||
enabled: true
|
||||
msc3266_enabled: true
|
||||
msc4222_enabled: true
|
||||
msc4354_enabled: true
|
||||
msc4108_delegation_endpoint: "https://auth-af2f3d.organic-machine.com/_matrix/client/unstable/org.matrix.msc4108/rendezvous"
|
||||
# Disable legacy password login:
|
||||
password_config:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
4. **Migrar usuarios existentes Synapse -> MAS**:
|
||||
- `docker exec element_matrix_chat-mas-1 mas-cli syn2mas --synapse-config /data/homeserver.yaml --dry-run` primero.
|
||||
- Revisar log (conflictos, usuarios huerfanos).
|
||||
- Ejecutar real: `mas-cli syn2mas --synapse-config /data/homeserver.yaml`.
|
||||
- Verificar: contar usuarios `mas-postgres` vs `synapse-postgres`, deben coincidir.
|
||||
|
||||
5. **Actualizar well-known** (`/.well-known/matrix/client`):
|
||||
- Servido por `element_matrix_chat-wellknown-1` (nginx).
|
||||
- Anadir:
|
||||
```json
|
||||
"org.matrix.msc2965.authentication": {
|
||||
"issuer": "https://auth-af2f3d.organic-machine.com/",
|
||||
"account": "https://auth-af2f3d.organic-machine.com/account"
|
||||
}
|
||||
```
|
||||
- Reload nginx.
|
||||
|
||||
6. **Restart ordenado**:
|
||||
- `docker compose restart mas` -> verificar logs sin errores 30s.
|
||||
- `docker compose restart synapse` -> verificar `_matrix/client/v3/login` ahora devuelve `m.login.sso` con `identity_providers` apuntando a MAS.
|
||||
- `docker compose restart element` (recarga config).
|
||||
|
||||
7. **Reconfigurar Element Web** (`element-config.json`):
|
||||
- Activar `oidc_native_flow: true` (Element Web soporta MSC3861 desde v1.11.50+).
|
||||
- Verificar version Element Web (`docker exec element_matrix_chat-element-1 cat /etc/nginx/conf.d/element.json | head` o image tag) >= v1.11.50.
|
||||
- Si version vieja: bump container image.
|
||||
|
||||
8. **Verificar end-to-end**:
|
||||
- Logout completo navegador.
|
||||
- Abrir Element Web -> debe redirigir a MAS para login.
|
||||
- Login con cuenta existente migrada -> redirect back a Element -> sesion activa.
|
||||
- Comprobar rooms historicos siguen visibles + msgs E2EE descifrados (las cross-signing keys NO se re-bootstrappean si la migracion va bien).
|
||||
|
||||
9. **Plan rollback** (escribir en `docs/mas_migration_rollback.md`):
|
||||
- Restaurar postgres Synapse desde dump.
|
||||
- Comentar bloque `matrix_authentication_service:` en homeserver.yaml.
|
||||
- `password_config.enabled: true`.
|
||||
- Restart Synapse.
|
||||
- MAS sigue vivo idle (no destruir).
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `mas_client_register_bash_infra` — `mas-cli config sync` wrapper + validacion idempotente.
|
||||
- `synapse_msc3861_enable_go_infra` — edita `homeserver.yaml` con bloque MAS + experimental_features.
|
||||
- `mas_syn2mas_migration_bash_infra` — wrapper migracion con dry-run obligatorio + log archive.
|
||||
- `wellknown_oidc_patch_go_infra` — anade `org.matrix.msc2965.authentication` al well-known JSON servido por nginx.
|
||||
- `synapse_login_flows_check_go_infra` — health-check post-migracion (espera ver `m.login.sso` en flows).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] `GET /_matrix/client/v3/login` devuelve `m.login.sso` con identity provider MAS.
|
||||
- [ ] `GET /.well-known/matrix/client` contiene `org.matrix.msc2965.authentication.issuer`.
|
||||
- [ ] Element Web redirige a MAS para login (no muestra form propio).
|
||||
- [ ] Login con cuenta existente funciona post-migracion.
|
||||
- [ ] Rooms historicos + msgs E2EE siguen visibles tras re-login.
|
||||
- [ ] `password_config.enabled: false` no rompe nada (todo va por MAS).
|
||||
- [ ] Backup pre-migracion subido + documentado.
|
||||
- [ ] `docs/mas_migration_rollback.md` escrito + probado en staging (ver Notas).
|
||||
|
||||
## Definition of Done
|
||||
|
||||
### Mecanica
|
||||
- `docker compose ps` muestra todos los containers healthy.
|
||||
- `mas-cli config check` exit 0.
|
||||
- `synapse curl /health` 200.
|
||||
- Tests humo: login + send msg + recibe msg propagado a otra cuenta.
|
||||
|
||||
### Cobertura
|
||||
|
||||
| Escenario | Comando / evidencia | Resultado |
|
||||
|---|---|---|
|
||||
| Golden: login Element Web via MAS | navegador Incognito -> ` element-a05ae4.organic-machine.com` | redirect MAS -> login -> sesion activa |
|
||||
| Edge: usuario migrado con E2EE setup previo | post-login en Element Web | rooms cifrados se descifran sin re-bootstrap |
|
||||
| Edge: app servicio (bot) usa application_service token | bot envia msg | sigue funcionando (AS no pasa por MAS) |
|
||||
| Edge: device verification cross-platform | Element Web verifica device PC Wails (post flow 0010) | OK |
|
||||
| Error: token MAS expira mid-session | esperar TTL (default 5min refresh) | refresh automatico, no logout |
|
||||
| Error: MAS cae (kill container) | matar `mas-1` 60s | Synapse rechaza nuevos logins; sessiones activas siguen (access_token cached); restart MAS -> recovery |
|
||||
|
||||
### Vida util validada (7 dias post-migracion)
|
||||
|
||||
| Metrica | Umbral | Donde | Ventana |
|
||||
|---|---|---|---|
|
||||
| Login failures (causa MAS) | `< 1%` | `mas` logs + sentry-like | 7 dias |
|
||||
| Latency `/oauth2/token` | `p95 < 500ms` | nginx access log VPS | 7 dias |
|
||||
| Crashes MAS / Synapse | `0` | `docker logs --since` | 7 dias |
|
||||
| Users migrados activos | `>= 95%` | `mas-cli admin user list` vs sesiones activas | 7 dias |
|
||||
|
||||
### Anti-criterios
|
||||
- NO marcar done si algun usuario migrado pierde acceso a rooms cifrados.
|
||||
- NO marcar done si Element Web sigue mostrando form de password (legacy flow).
|
||||
- NO marcar done si rollback documentado no se ha probado al menos una vez en staging.
|
||||
|
||||
## Notas
|
||||
|
||||
**Staging recomendado:** levantar stack identico en VPS test o WSL local con docker-compose + datos fake antes de tocar prod. organic-machine.com lleva 6 semanas viva.
|
||||
|
||||
**Element Call (LiveKit):** ya usa OIDC del homeserver para tokens via `livekit-jwt` container -> migracion debe verificar que tokens siguen emitiendose contra el MAS auth.
|
||||
|
||||
**Synapse-Admin compat:** synapse-admin v0.10+ soporta MSC3861. Verificar version corriendo. Si vieja, bump O reemplazar por panel propio (issue 0163).
|
||||
|
||||
**Gotcha critico — shared_secret:**
|
||||
- `mas/config.yaml` tiene `matrix.secret` que debe matchear `homeserver.yaml.matrix_authentication_service.secret`.
|
||||
- Generar con `openssl rand -hex 32` si no existe.
|
||||
- Si no matchean: Synapse rechaza requests MAS con 401.
|
||||
|
||||
**Gotcha — application_service tokens:**
|
||||
- Los AS (bridges, bots) NO pasan por MAS. Siguen usando `as_token`/`hs_token` de su registration.
|
||||
- `agents_and_robots` usa application_service? Verificar antes — si SI, no afecta. Si usa password login normal, tendra que pasar por MAS (re-config).
|
||||
|
||||
**Roadmap post-DoD:**
|
||||
- Habilitar `device_code` grant en MAS para login CLI futuro.
|
||||
- Habilitar QR-code login (MSC4108) ya pre-config con `msc4108_delegation_endpoint`.
|
||||
- Multi-factor (TOTP) en MAS — config available.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v0.1.0 (2026-05-24) — issue creada.
|
||||
@@ -0,0 +1,189 @@
|
||||
---
|
||||
id: "0163"
|
||||
title: "Matrix admin panel propio: users, rooms, devices, sessions (sustituye synapse-admin)"
|
||||
status: pending
|
||||
priority: medium
|
||||
created: 2026-05-24
|
||||
related_flows: ["0010", "0011"]
|
||||
related_issues: ["0162", "0147"]
|
||||
dependencies: ["0162"]
|
||||
tags: [matrix, admin, panel, react, mantine, mas, synapse, infra]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Panel admin propio que reemplaza `https://admin-0cc4d3.organic-machine.com/#/users` (synapse-admin actual). Funciones equivalentes: gestionar usuarios (crear, deactivate, reset password, list devices, list rooms), gestionar rooms (list, members, kick, force-leave, delete), ver sesiones activas + revoke, ver media (storage usage por user). Auth via MAS OIDC con scope admin. Stack: React+Vite+Mantine+`@fn_library` (consistente con flows 0010/0011 + resto del registry).
|
||||
|
||||
## Por que reemplazar synapse-admin
|
||||
|
||||
- **Auth legacy**: synapse-admin usa admin token + password admin directo. Tras issue 0162 (MAS obligatorio) esto chirria. Mejor consume MAS OIDC + Synapse Admin API.
|
||||
- **UI ajena**: stack distinto al resto del registry. Sin theming propio, sin `@fn_library`, sin coherencia visual con cliente PC (flow 0010).
|
||||
- **Sin agentes**: no podemos integrar paneles especiales para `agents_and_robots`, devices del mesh (flow 0009), policies de widgets.
|
||||
- **No extensible**: anadir "ver telemetria de calls LiveKit" o "audit log MAS" requiere fork pesado.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. **Scaffold app**:
|
||||
- `projects/element_agents/apps/matrix_admin_panel/`.
|
||||
- Stack: React+Vite+TS+Mantine+`@fn_library`+`@tabler/icons-react`.
|
||||
- Backend: Go con `mautrix-go` admin client + MAS OIDC client + `livekit-server-sdk-go` (para sesiones de call).
|
||||
- Empaquetado: backend Go sirve frontend estatico embebido (`embed.FS`).
|
||||
- Deploy: container Docker en `element_matrix_chat` stack o como service standalone via `deploy_server`.
|
||||
|
||||
2. **Auth flow MAS**:
|
||||
- Cliente registrado en MAS (issue 0162 paso 2) con scope `urn:synapse:admin:*`.
|
||||
- Login Web: OIDC redirect a MAS.
|
||||
- Token guardado en httpOnly cookie + CSRF token.
|
||||
|
||||
3. **Modulos UI**:
|
||||
- **Users**:
|
||||
- Tabla virtualizada con `data-table` (cuando exista TS equivalente) o `mantine-react-table`.
|
||||
- Columnas: localpart, displayname, avatar, admin, deactivated, last_seen, device_count.
|
||||
- Acciones por row: view detail, deactivate/reactivate, reset password (force MAS link), list devices.
|
||||
- Filtros: deactivated, admin, search.
|
||||
- **User detail**:
|
||||
- Sub-tabs: Profile, Devices (list + revoke individual), Rooms (membership list), Media (uploads + size), Sessions (MAS active sessions + revoke), Audit log (MAS).
|
||||
- **Rooms**:
|
||||
- Tabla: room_id, name, alias, members_count, encrypted, public, federated, state_events.
|
||||
- Acciones: view detail, force-leave usuarios, delete room (purge), shutdown notif.
|
||||
- **Room detail**:
|
||||
- Members + roles, state events viewer (read-only JSON), media in room, widgets activos (interop con flow 0010 widget API).
|
||||
- **Sessions** (MAS):
|
||||
- Lista sesiones activas global.
|
||||
- Filtro por user, IP, device, last_used.
|
||||
- Revoke individual o bulk.
|
||||
- **Federation**:
|
||||
- Estado federation (Synapse `federation_handler`).
|
||||
- Allowlist/blocklist servers.
|
||||
- **Stats**:
|
||||
- Resumen: users count, rooms count, mensajes/dia (ultima semana), media storage, calls activas (via LiveKit `RoomService.ListRooms`).
|
||||
- Graficas con `@mantine/charts` o `recharts`.
|
||||
|
||||
4. **Capability groups en panel**:
|
||||
- Reusa `AgentPanel` (flow 0010 issue 0153) para mostrar info de agentes registrados.
|
||||
- Reusa `DevicePanel` (cuando flow 0009 vivo) para devices del mesh.
|
||||
- Slot "Widgets policy": ver/aprobar capabilities concedidas globalmente, audit log.
|
||||
|
||||
5. **API endpoints backend Go**:
|
||||
- `GET /api/users` -> proxy a Synapse `/_synapse/admin/v2/users` con auth MAS.
|
||||
- `POST /api/users/<id>/deactivate`.
|
||||
- `GET /api/rooms`, `POST /api/rooms/<id>/delete`.
|
||||
- `GET /api/mas/sessions`, `POST /api/mas/sessions/<id>/revoke` (MAS admin API).
|
||||
- `GET /api/livekit/rooms` (active calls).
|
||||
- `GET /api/stats/summary`.
|
||||
|
||||
6. **Permisos**:
|
||||
- Solo users con flag `admin: true` (Synapse) o scope MAS admin claim.
|
||||
- Backend valida claim/flag en cada request.
|
||||
- UI muestra "Access denied" si user logueado no es admin.
|
||||
|
||||
7. **Deploy**:
|
||||
- Anadir container al `docker-compose.yml` de `element_matrix_chat`.
|
||||
- O bien standalone via `deploy_server` (registry function existente).
|
||||
- URL: `admin-af2f3d.organic-machine.com` o reusar `admin-0cc4d3.organic-machine.com` cuando se retire synapse-admin.
|
||||
|
||||
8. **Migracion synapse-admin -> panel propio**:
|
||||
- Coexistencia 2 semanas: ambos vivos, MAS audita uso de cada uno.
|
||||
- Cuando uso de synapse-admin = 0 durante 7 dias seguidos: detener container.
|
||||
- Documentar en `docs/admin_panel_migration.md`.
|
||||
|
||||
9. **Tests**:
|
||||
- `e2e/test_admin_login.sh` — MAS OIDC + scope admin valido -> acceso.
|
||||
- `e2e/test_admin_login_denied.sh` — user no-admin recibe 403.
|
||||
- `e2e/test_user_deactivate.sh` — flow completo deactivate + verify can't login.
|
||||
- `e2e/test_room_purge.sh` — purge room + verify gone en Synapse.
|
||||
- `e2e/test_session_revoke.sh` — revoke sesion MAS + user perdiendo acceso en <30s.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `synapse_admin_client_go_infra` — wrapper Synapse Admin API.
|
||||
- `mas_admin_client_go_infra` — wrapper MAS admin API (`/api/admin/v1/...`).
|
||||
- `livekit_admin_client_go_infra` — `RoomService.ListRooms`, kick participant, etc.
|
||||
- `oidc_admin_middleware_go_infra` — middleware Go que valida scope admin en cookie/Bearer.
|
||||
- `UsersTable_ts_ui` — componente Mantine con virtualization + filtros.
|
||||
- `RoomDetail_ts_ui` — componente con tabs Members/State/Media/Widgets.
|
||||
- `SessionsList_ts_ui` — lista sesiones + revoke action.
|
||||
- `StatsSummary_ts_ui` — componente con `@mantine/charts`.
|
||||
- `FederationStatusPanel_ts_ui` — componente federation diag.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] App compila + arranca como container Docker.
|
||||
- [ ] Login via MAS OIDC con scope admin funciona.
|
||||
- [ ] User no-admin recibe 403 al intentar entrar.
|
||||
- [ ] Tabla users con 50+ rows + filtros + actions.
|
||||
- [ ] Deactivate user end-to-end (verify cannot login despues).
|
||||
- [ ] Room detail muestra members + state events JSON.
|
||||
- [ ] Sessions MAS listadas + revoke individual.
|
||||
- [ ] Stats: counts + media usage + active calls visibles.
|
||||
- [ ] Tema visual coherente con cliente PC (flow 0010).
|
||||
|
||||
## Definition of Done
|
||||
|
||||
### Mecanica
|
||||
- `go build` + `pnpm build` verde.
|
||||
- Container Docker `<150MB` (Alpine + binary + static).
|
||||
- Health endpoint `/health` 200.
|
||||
- E2E suite pasa.
|
||||
|
||||
### Cobertura
|
||||
|
||||
| Escenario | Evidencia | Resultado |
|
||||
|---|---|---|
|
||||
| Golden: admin login + ver users | `e2e/test_admin_full_flow.sh` | tabla con users reales, actions visibles |
|
||||
| Edge: 5000 users en tabla | benchmark scroll | 60fps, <300MB RAM |
|
||||
| Edge: user sin admin entra | request directo | 403 + audit log |
|
||||
| Edge: room con 200 members | view detail | render < 1s, paginacion OK |
|
||||
| Error: Synapse Admin API caida | mock 500 | UI muestra error claro, no crash |
|
||||
| Error: MAS session revoke fails | mock 500 | retry + toast error |
|
||||
|
||||
### Vida util (>=7 dias)
|
||||
|
||||
| Metrica | Umbral | Donde | Ventana |
|
||||
|---|---|---|---|
|
||||
| Crashes container | `0` | docker logs | 7 dias |
|
||||
| Uso real | `>= 2 sesiones/semana` (operador) | nginx access log | 7 dias |
|
||||
| Latency p95 endpoint /api/users | `< 800ms` (Synapse Admin paginado) | metrics | 7 dias |
|
||||
| Acciones destructivas auditadas | `100%` (cada delete/revoke con audit row) | local audit DB | continuo |
|
||||
|
||||
### Anti-criterios
|
||||
- NO marcar done si admin panel acepta token sin claim/flag admin.
|
||||
- NO marcar done si delete room no purga media en DB Synapse.
|
||||
- NO marcar done si UI deja al operador sin confirmacion en acciones destructivas (deactivate, purge, revoke).
|
||||
- NO marcar done si lookalike de synapse-admin sin features propias (mejor mantener synapse-admin entonces).
|
||||
|
||||
## Notas
|
||||
|
||||
**Ventajas reales sobre synapse-admin:**
|
||||
1. Coherencia visual + Mantine + theme propio.
|
||||
2. Integracion con `agents_and_robots` (panel agente embedded).
|
||||
3. Integracion con widgets policy (audit + override capabilities).
|
||||
4. Integracion con LiveKit calls (ver rooms activos, force-end).
|
||||
5. Audit log local SQLite con todas las acciones admin (synapse-admin no lo tiene).
|
||||
6. Extensible — anadir tabs para mesh devices (flow 0009), telemetria, etc.
|
||||
|
||||
**Onboarding:**
|
||||
1. `cd projects/element_agents/apps/matrix_admin_panel`.
|
||||
2. `make dev` (Go backend + Vite frontend hot reload).
|
||||
3. Visitar `http://127.0.0.1:8090` -> login MAS dev.
|
||||
4. Deploy prod: ver `deploy/README.md`.
|
||||
|
||||
**Decisiones:**
|
||||
- Backend Go > Python/Node: alinea con `mautrix-go` + reusa funciones del registry. Binario pequeno, deploy facil.
|
||||
- Embedded static (Go `embed.FS`): un binario, sin docker multi-stage compleja.
|
||||
- Audit log local SQLite > Postgres: panel admin no necesita HA, suficiente con SQLite local + backup periodico.
|
||||
|
||||
**Gotchas:**
|
||||
- Synapse Admin API requiere `Bearer <admin_token>` — el panel intercambia OIDC token + admin claim por admin_token (con MAS admin API o con cuenta admin shared).
|
||||
- MAS admin API esta en `/api/admin/v1/` — version unstable, monitorizar breaking changes.
|
||||
- Federation tab: si federation deshabilitada (caso actual, ver `homeserver.yaml`), tab muestra "disabled" en vez de error.
|
||||
|
||||
**Roadmap post-DoD:**
|
||||
- Bulk actions (mass deactivate, mass invite).
|
||||
- Export reports CSV.
|
||||
- Slack/email alerts en eventos criticos (server cae, MAS down, federation block).
|
||||
- Multi-tenancy si llegan mas homeservers.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v0.1.0 (2026-05-24) — issue creada.
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
id: "0164"
|
||||
title: "Bots agents_and_robots: cryptohelper.Init() cuelga al habilitar encryption=true"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0009"]
|
||||
related_issues: ["0144", "0162"]
|
||||
dependencies: ["0162"]
|
||||
tags: [matrix, e2ee, mautrix, cryptohelper, agents, hang, debug]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Que los agents de `agents_and_robots` (`agent-wsl-lucas`, `agent-windows-lucas`, y futuros) puedan operar con `encryption.enabled=true` en su `config.yaml` y **leer + responder en DMs encrypted** (megolm) con el operator. Hoy todos corren con `enabled=false` para no colgarse; consecuencia: bot puede ENVIAR a room encrypted (cleartext que Element marca como warning) pero NO LEE replies del operator (megolm cifra, bot no descifra) → chat bidireccional roto.
|
||||
|
||||
Bloquea Flow 0009 DoD ("Element → PC interaction working") en el camino encrypted.
|
||||
|
||||
## Contexto
|
||||
|
||||
- mautrix-go v0.21.1 con cryptohelper (tag `goolm` pure-Go).
|
||||
- Synapse en VPS organic-machine.com con MSC3861/MAS activo (issue 0162 done 2026-05-24).
|
||||
- `encryption_enabled_by_default_for_room_type` activo en Synapse → TODA DM nueva nace con `m.megolm.v1.aes-sha2` (no override client-side).
|
||||
- Bots usan password tokens (no application_service). Tokens emitidos pre-migracion siguen validos (verificado: `/account/whoami` OK con bot token post-MAS).
|
||||
- `verify.sh agent-windows-lucas` corrio OK: genero crypto.db, upload cross-signing keys, escribio `SSSS_RECOVERY_KEY_AGENT_WINDOWS_LUCAS` en `.env`.
|
||||
|
||||
## Reproduccion
|
||||
|
||||
```bash
|
||||
# En VPS, agent-windows-lucas:
|
||||
sudo sed -i 's/enabled: false/enabled: true/' agents/agent-windows-lucas/config.yaml
|
||||
sudo systemctl restart agents_and_robots
|
||||
sleep 30
|
||||
# Bot stuck:
|
||||
sudo tail logs/agent-windows-lucas/2026-05-24.jsonl
|
||||
# Last line forever: "initializing e2ee" — runner nunca llega a "starting matrix sync"
|
||||
# /agents API endpoint reports running=false
|
||||
```
|
||||
|
||||
## Diagnostico actual (incompleto)
|
||||
|
||||
SIGQUIT al proceso launcher revelo bots NO-encrypted en `Listener.Run → SyncWithContext` (normal). NO se pudo aislar la stack de **windows-lucas** durante hang — necesita pprof targeted o log adicional dentro de `InitCrypto`.
|
||||
|
||||
Hipotesis (ordenadas):
|
||||
|
||||
| ID | Hipotesis | Evidencia que la apoya | Como confirmar |
|
||||
|---|---|---|---|
|
||||
| H1 | `cryptohelper.Init()` bloquea en primer `/keys/device_signing/upload` por UIA — MAS no acepta el formato auth heredado | MAS recien activo, password_config disabled, mautrix-go usa UIA password flow | inyectar log antes/despues de cada llamada en `cryptohelper.Init` |
|
||||
| H2 | `cryptohelper.Init()` bloquea en `OlmMachine.Load` por `crypto.db` schema mismatch | crypto.db generado por `cmd/verify` puede tener schema distinto al que cryptohelper espera | reset crypto.db + dejar que cryptohelper bootstrap solo (sin verify.sh) |
|
||||
| H3 | El listener trata de hacer initial sync ANTES de e2ee init terminar, deadlock en mutex | "starting matrix sync" NUNCA aparece post-`initializing e2ee` | revisar order en `devagents/runtime.go` |
|
||||
| H4 | Pickle key mismatch entre verify.sh (lo recibe en hex) y runtime (lo decodifica diferente) | Provision-script genero base64; nosotros pusimos hex; runtime acepta hex? | log de pickle key length en runtime |
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1 — Diagnostico
|
||||
|
||||
1.1. Inyectar logging EN `shell/matrix/client.go::InitCrypto` antes/despues de cada paso (cryptohelper construct, Init, OlmMachine.Load, etc) para identificar la linea que bloquea.
|
||||
|
||||
1.2. Reproducir hang en agent test aislado (`agent-e2ee-test`):
|
||||
- Crear bot fresh con provision-agent-user.sh
|
||||
- Activar encryption=true
|
||||
- Restart launcher
|
||||
- Capturar stack
|
||||
|
||||
1.3. Con stack identificado, decidir cual hipotesis (H1-H4) aplica.
|
||||
|
||||
### Fase 2 — Fix segun hipotesis
|
||||
|
||||
- **Si H1 (MAS UIA)**: investigar si mautrix-go v0.21.1 soporta MSC3861 UIA. Si no: bump a v0.22+ que soporta o usar `device_signing/upload` con SSSS-protected path.
|
||||
- **Si H2 (schema mismatch)**: dejar cryptohelper bootstrap solo, NO usar verify.sh primero. Verify.sh queda como "post-bootstrap repair".
|
||||
- **Si H3 (sync deadlock)**: refactor `devagents/runtime.go` para que e2ee init complete antes de spawn listener.
|
||||
- **Si H4 (pickle key)**: arreglar provision-agent-user.sh para generar pickle key como hex.
|
||||
|
||||
### Fase 3 — Validacion (DoD triada)
|
||||
|
||||
#### Mecanica
|
||||
- Bot con `encryption.enabled=true` start OK (running=true en /agents API).
|
||||
- No hang en logs (paso de "initializing e2ee" → "starting matrix sync" en < 30s).
|
||||
- Build limpio `go build -tags goolm`.
|
||||
|
||||
#### Cobertura
|
||||
|
||||
| Escenario | Cmd / evidencia | Resultado |
|
||||
|---|---|---|
|
||||
| Golden: operator envia mensaje encrypted en DM, bot lee + responde encrypted | Element web → `#agent-windows-lucas` DM → "hola" | bot responde en < 15s, log muestra decrypted msg + claude_code_response + encrypted send |
|
||||
| Edge: bot reinicia, crypto.db persiste, re-key OK | `sudo systemctl restart agents_and_robots` mid-conversation | bot continua descifrando mensajes anteriores + nuevos sin re-bootstrap |
|
||||
| Edge: operator reverify device | Element → device list → forget device → re-verify | bot detecta cambio, sigue cifrando OK |
|
||||
| Error: crypto.db corrupto | `rm crypto.db` mid-run | bot detecta + auto-recovery (per `docs/e2ee.md`) + re-bootstrap < 60s |
|
||||
| Error: token revoked | revocar via admin API | bot logout limpio + restart picks up nuevo token |
|
||||
|
||||
#### Vida util validada (7 dias)
|
||||
|
||||
| Metrica | Umbral | Donde | Ventana |
|
||||
|---|---|---|---|
|
||||
| Bot uptime con encryption=true | `> 99%` | `/agents/<id>` API | 7 dias |
|
||||
| Mensajes encrypted leidos | `>= 10` real conversation | `logs/agent-*/...jsonl` decrypted lines | 7 dias |
|
||||
| Crashes cryptohelper | `0` | journalctl `agents_and_robots` | 7 dias |
|
||||
| Latency decrypt msg | `p95 < 2s` | log timestamps | 7 dias |
|
||||
|
||||
### Anti-criterios
|
||||
|
||||
- NO marcar done si bot solo escribe pero no lee.
|
||||
- NO marcar done si hang reaparece tras reinicio del servicio.
|
||||
- NO marcar done si solo funciona en 1 bot (debe replicarse: wsl-lucas + windows-lucas + 1 mas).
|
||||
|
||||
## Estado actual workaround
|
||||
|
||||
- `agent-wsl-lucas`: `encryption.enabled=false`. DM con operator es UNencrypted (probablemente porque fue creada antes de Synapse activar default-encrypt). Funciona bidireccional.
|
||||
- `agent-windows-lucas`: `encryption.enabled=false`. DM con operator (room `!ymFSupZVqYpOWunuHI` o `!qeuqopdkeYHWdAfMaN`) es ENCRYPTED (Synapse forced). Bot envia clear-text → operator ve mensaje + warning. Operator reply encrypted → bot NO lee.
|
||||
|
||||
## Funciones del registry candidatas (post-fix)
|
||||
|
||||
- `mautrix_cryptohelper_init_with_timeout_go_infra` — wrapper con context.WithTimeout para evitar hang infinito.
|
||||
- `agent_e2ee_bootstrap_bash_pipelines` — pipeline: provision agent → set encryption=true → verify.sh → restart + wait healthy.
|
||||
|
||||
## Notas
|
||||
|
||||
**Pickle key format bug**: `provision-agent-user.sh` genera base64 (`openssl rand -base64 32`). `cmd/verify` espera hex. Fix in scope de este issue o nuevo issue (`0165-provision-pickle-key-hex.md`).
|
||||
|
||||
**Subagent investigation report** (2026-05-24) confirmo:
|
||||
- E2EE machinery YA existe end-to-end (InitCrypto, FetchCrossSigningKeys, SignOwnDevice, verify.sh).
|
||||
- docs/e2ee.md cubre failure modes conocidos.
|
||||
- mautrix-go v0.21.1 puede tener bug pre-MSC3861-aware con MAS.
|
||||
|
||||
**Pendiente upstream check**: mautrix-go release notes v0.22+ para MSC3861 support. Si esta soportado, bump version es probablemente el fix.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v0.1.0 (2026-05-24) — issue creado tras reproducir hang post-MAS migration con verify.sh OK pero cryptohelper.Init aun cuelga.
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
id: "0165"
|
||||
title: "Cifrar media_store/ Synapse con LUKS at-rest"
|
||||
status: pendiente
|
||||
type: infra
|
||||
domain:
|
||||
- matrix
|
||||
scope: app:element_matrix_chat
|
||||
priority: media
|
||||
depends: []
|
||||
blocks: []
|
||||
related: ["0162"]
|
||||
created: 2026-05-24
|
||||
updated: 2026-05-24
|
||||
tags: [matrix, synapse, encryption, security, luks]
|
||||
---
|
||||
# 0165 — Cifrar media_store/ Synapse con LUKS at-rest
|
||||
|
||||
**Status:** pendiente
|
||||
**Created:** 2026-05-24
|
||||
**Type:** infra
|
||||
**Priority:** media
|
||||
**Domain:** matrix
|
||||
**Scope:** app:element_matrix_chat
|
||||
**Depends:** —
|
||||
**Blocks:** —
|
||||
|
||||
## Problema
|
||||
|
||||
`synapse_data/media_store/` contiene archivos subidos (fotos, voice messages, attachments) + thumbnails. Rooms NO-E2EE: media cleartext en disco. Tabla `media_repository` Postgres: filename/mime/uploader/room_id siempre cleartext. Riesgo: VPS provider snapshot disk, backups desencriptados, disco fisico.
|
||||
|
||||
## Objetivo
|
||||
|
||||
`media_store/` cifrado at-rest. Synapse arranca y sirve media normal. Decrypt automatico via keyfile en TPM o passphrase al boot.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Decidir estrategia: LUKS container file-based (loop device) vs LUKS sobre volumen Docker dedicado.
|
||||
2. Crear LUKS container 50GB (ajustar segun crecimiento previsto).
|
||||
3. Montar como `/home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/media_store_encrypted/`.
|
||||
4. Stop Synapse → rsync `media_store/` → `media_store_encrypted/` → swap mountpoint.
|
||||
5. Verificar Synapse sirve thumbnails + uploads OK.
|
||||
6. Configurar auto-unlock via keyfile en `/root/.luks-media.key` con permisos 0400.
|
||||
7. Documentar recovery passphrase en `pass` (entry `matrix/luks-media-passphrase`).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] `media_store/` montado sobre LUKS, `lsblk -f` muestra crypto_LUKS.
|
||||
- [ ] Synapse arranca tras reboot completo del VPS sin intervencion manual.
|
||||
- [ ] Test: subir imagen via Element, verificar thumb generado.
|
||||
- [ ] Test: leer media_store via `dd if=/dev/sdX` directo retorna basura cifrada.
|
||||
- [ ] Passphrase backed up en `pass`.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [ ] Repetibilidad: reboot VPS, media accesible sin intervencion.
|
||||
- [ ] Observabilidad: log entry en `journalctl -u systemd-cryptsetup@*`.
|
||||
- [ ] User-facing: clientes Element no notan diferencia.
|
||||
- [ ] Recovery probado: detach LUKS y reattach con passphrase.
|
||||
|
||||
## Notas
|
||||
|
||||
LUKS solo protege at-rest. VPS provider con acceso a RAM viva ve plaintext via memory dump. Sin TPM atestado, utilidad real = anti-snapshot/anti-backup-leak/anti-physical-theft.
|
||||
|
||||
Caveat: si keyfile vive en mismo disco que LUKS device, no protege contra disk theft. Mover keyfile a USB removible o TPM2 (`systemd-cryptenroll`).
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
id: "0166"
|
||||
title: "Rastrear dependencias entre apps (app→app) para clonado/build reproducible"
|
||||
status: pendiente
|
||||
type: enhancement
|
||||
domain:
|
||||
- registry-quality
|
||||
- build
|
||||
scope: registry-only
|
||||
priority: media
|
||||
depends: []
|
||||
blocks: []
|
||||
related: []
|
||||
created: 2026-05-31
|
||||
updated: 2026-05-31
|
||||
tags: [build, apps, dependencies, clone, migration]
|
||||
---
|
||||
# 0166 — Rastrear dependencias entre apps (app→app)
|
||||
|
||||
## APP Metadata
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **ID** | 0166 |
|
||||
| **Estado** | pendiente |
|
||||
| **Prioridad** | media |
|
||||
| **Tipo** | enhancement — metadata de apps + `fn app clone` / `fn doctor` |
|
||||
|
||||
## Contexto
|
||||
|
||||
Durante la migración a Linux nativo (PC `lucas-linux`, 2026-05-31) al levantar los
|
||||
servicios systemd hubo que clonar+compilar los apps de servicio. El build de
|
||||
**`sqlite_api`** (`projects/fn_monitoring/apps/sqlite_api`) falló con:
|
||||
|
||||
```
|
||||
handlers.go:13:2: package fn-registry/apps/data_factory/datafactory is not in std
|
||||
```
|
||||
|
||||
`sqlite_api` **importa un paquete de otro app** (`apps/data_factory/datafactory`).
|
||||
Como `data_factory` no estaba clonado, no compilaba. Hubo que clonarlo a mano para
|
||||
desbloquear el build. No hay forma declarada de saber, antes de clonar/compilar un app,
|
||||
**qué otros apps necesita**.
|
||||
|
||||
## Problema
|
||||
|
||||
- `fn app clone <id>` clona solo el repo pedido; no arrastra los apps de los que depende.
|
||||
- `fn sync locations` lista paths por PC pero no relaciones app→app.
|
||||
- El grafo de dependencias entre apps es implícito (vive solo en los `import` de Go),
|
||||
así que clonar el subset correcto en una máquina nueva es ensayo-error.
|
||||
|
||||
## Objetivo
|
||||
|
||||
Hacer explícitas y consultables las dependencias **app→app**, para que:
|
||||
|
||||
1. Se pueda **saber** qué apps dependen de qué apps (consulta / reporte).
|
||||
2. `fn app clone <id>` pueda **arrastrar** los apps-dependencia (o al menos avisarlos).
|
||||
3. `fn doctor` detecte clones incompletos (app presente pero su dep ausente).
|
||||
|
||||
## Propuesta (a concretar)
|
||||
|
||||
1. **Metadata declarada** en `app.md`: añadir campo `depends_on_apps: [<id>...]`
|
||||
en el frontmatter (análogo a `uses_functions`/`uses_types` de las funciones).
|
||||
2. **Detección automática** (validación): un audit que parsee los `import` de cada app,
|
||||
detecte imports de la forma `fn-registry/apps/<X>/...` o `.../projects/.../apps/<X>/...`
|
||||
y compare con `depends_on_apps` (igual que `audit_uses_functions` para funciones).
|
||||
- Caso semilla confirmado: `sqlite_api` → `data_factory`.
|
||||
3. **`fn app clone --with-deps <id>`**: resuelve el cierre transitivo de `depends_on_apps`
|
||||
y clona todo lo necesario a las rutas de `pc_locations`.
|
||||
4. **`fn doctor`**: marcar `[incomplete-clone]` si un app está clonado pero falta un
|
||||
`depends_on_apps`.
|
||||
|
||||
## Notas
|
||||
|
||||
- Relacionado (mismo arrastre de migración, issues aparte si procede):
|
||||
- Los `repo_url` de varios apps de servicio (`registry_api`, `services_api`) apuntan al
|
||||
alias `gitea.organic-machine.com` que **no resuelve**; el host real es
|
||||
`gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com`. `fn app clone` falla por eso.
|
||||
- Los `go.mod` de los apps de servicio requieren `go mod tidy` antes de compilar
|
||||
(deps faltantes con go 1.26).
|
||||
|
||||
## Criterio de hecho
|
||||
|
||||
- [ ] Campo `depends_on_apps` documentado y poblado al menos en `sqlite_api`.
|
||||
- [ ] Audit que detecta imports app→app y reporta drift vs `depends_on_apps`.
|
||||
- [ ] `fn app clone` resuelve dependencias (o las avisa).
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
id: "0126"
|
||||
title: "pipeline_launcher: aplicar migracion 003_logs a operations.db"
|
||||
status: completado
|
||||
type: bugfix
|
||||
domain:
|
||||
- apps-infra
|
||||
scope: app
|
||||
priority: baja
|
||||
depends: []
|
||||
blocks: []
|
||||
related:
|
||||
- "0121a"
|
||||
created: 2026-05-19
|
||||
updated: 2026-05-27
|
||||
tags: [pipeline_launcher, migrations, db]
|
||||
---
|
||||
|
||||
# 0126 — pipeline_launcher migracion 003_logs
|
||||
|
||||
Origen: detectado lateral por `fn-recopilador design-e2e apps/pipeline_launcher` en 0121a.
|
||||
|
||||
## Problema
|
||||
|
||||
`apps/pipeline_launcher/operations.db` tiene migraciones 001+002 aplicadas pero falta 003_logs (definida en `fn_operations/migrations/003_logs.sql`). La tabla `logs` no existe → cualquier feature futuro de logging in-app falla silencioso.
|
||||
|
||||
Investigacion necesaria: por que no aplico? Probable que pipeline_launcher use version vieja del codigo `fn_operations` o tenga su propio applier que no lee la migracion 003.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Diagnosticar por que 003 no aplico (busca `applyMigrations` en codigo de pipeline_launcher o si usa la libreria `fn_operations`).
|
||||
2. Aplicar 003 a la BD existente preservando datos.
|
||||
3. Si pipeline_launcher tiene applier custom, hacerlo consumir las migraciones del registry padre via `embed.FS`.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Inspeccionar `apps/pipeline_launcher/{main.go, db.go, store.go}` para localizar applier.
|
||||
2. Aplicar `003_logs.sql` manualmente: `sqlite3 apps/pipeline_launcher/operations.db < fn_operations/migrations/003_logs.sql`.
|
||||
3. Si custom applier: refactor para consumir migraciones del padre.
|
||||
4. Verificar con `PRAGMA table_info(logs);` que la tabla existe.
|
||||
5. Actualizar propuesta 0121a `pipeline_launcher.yaml` removiendo check `ops_schema_complete` (ya no aplica).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] `sqlite3 apps/pipeline_launcher/operations.db "PRAGMA table_info(logs);"` devuelve columnas esperadas.
|
||||
- [ ] Reaplicar 003 sobre BD ya migrada NO falla (idempotente — `CREATE TABLE IF NOT EXISTS`).
|
||||
- [ ] Tests de pipeline_launcher pasan (si existen).
|
||||
|
||||
## DoD
|
||||
|
||||
- **Donde**: sqlite3 introspeccion + log de la app si tiene.
|
||||
- **Latencia**: invisible al usuario.
|
||||
- **Onboarding**: "Si una app tiene operations.db, las migraciones del registry padre se aplican al arrancar — verificar con `PRAGMA table_info`."
|
||||
|
||||
## Resolucion (2026-05-27)
|
||||
|
||||
Diagnostico (desde aurgi-pc; BD afectada vive en home-wsl):
|
||||
|
||||
1. `apps/pipeline_launcher` importa `fn-registry/fn_operations` y abre la BD via `ops.Open()` (ver `apps/pipeline_launcher/app/model.go:44`).
|
||||
2. `fn_operations.Open` (`fn_operations/db.go:35`) llama a `migrate()` que delega en `ApplyVersionedMigrations` (`fn_operations/migrate.go:17`).
|
||||
3. `ApplyVersionedMigrations` (`functions/infra/sqlite_apply_versioned_migrations.go`) lee `schema_migrations`, ordena por version numerica y aplica las pendientes en transaccion. NO existe applier custom en pipeline_launcher.
|
||||
|
||||
Conclusion: el codigo es correcto. La BD afectada quedo en version=2 porque pipeline_launcher no se ha vuelto a abrir desde que se anadieron 003-006 al registry padre. En la proxima ejecucion en home-wsl, `ops.Open()` aplicara 003_logs, 004_e2e_tests, 005_e2e_runs, 006_task_runs automaticamente.
|
||||
|
||||
Verificacion del comportamiento: `TestMigrations` en `fn_operations/operations_test.go` pasa, y `fn ops` sobre BD fresca recientemente compilada incluye `logs` en `schema_migrations` (versiones 1..5 — la stale del template de `fn ops init` es separado, no bloquea pipeline_launcher porque este usa `ops.Open` directo, no el template).
|
||||
|
||||
Acciones tomadas:
|
||||
- Removido el check `ops_schema_complete` de `dev/proposals_e2e_checks_0121/pipeline_launcher.yaml` (queda obsoleto al ser auto-resuelto por `ApplyVersionedMigrations`). `ops_audit` sigue cubriendo la integridad de schema/datos.
|
||||
- Clonado `apps/pipeline_launcher` desde Gitea en aurgi-pc para la investigacion; `pc_locations` pasa de `missing` a `active` tras `fn sync` futuro.
|
||||
|
||||
Pendiente fuera de scope:
|
||||
- `apps/pipeline_launcher/go.mod` tiene `replace fn-registry => /home/lucas/fn_registry` hardcoded — el build solo funciona en home-wsl. Issue aparte si se quiere cross-PC build.
|
||||
- `fn_operations/project_template/operations.db` tiene migraciones aplicadas hasta v5, falta v6. Stale template — issue aparte.
|
||||
|
||||
Acceptance:
|
||||
- Tabla `logs` se creara automaticamente al reabrir la app en home-wsl (verificable con `sqlite3 apps/pipeline_launcher/operations.db "PRAGMA table_info(logs);"` tras el primer lanzamiento).
|
||||
- Reaplicar 003 es idempotente: tracking por version en `schema_migrations` salta versiones ya aplicadas.
|
||||
- pipeline_launcher no tiene tests propios; los tests de `fn_operations` cubren la logica de migracion.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user