28 Commits

Author SHA1 Message Date
egutierrez 7eef442444 chore: auto-commit (7 archivos)
- python/functions/core/__init__.py
- python/functions/pipelines/metabase_bulk_add_users_to_group.md
- python/functions/pipelines/metabase_bulk_add_users_to_group.py
- cpp/apps/
- python/functions/core/clean_email.md
- python/functions/core/clean_email.py
- python/functions/core/clean_email_test.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 11:39:08 +02:00
egutierrez 876020addf chore: auto-commit (7 archivos)
- .mcp.json
- bash/functions/pipelines/full_git_push.sh
- python/pyproject.toml
- python/uv.lock
- bash/functions/infra/jupyter_mcp_serve.md
- bash/functions/infra/jupyter_mcp_serve.sh
- dev/issues/0166-app-to-app-dependencies-tracking.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 01:29:45 +02:00
egutierrez 469e40ba40 fix(infra): full_git_push auto-init handles .git without origin
Auto-init step skipped any dir with .git present, even when it lacked
an origin remote. Such dirs fell through to push step and failed with
"'origin' does not appear to be a git repository". Now skip only when
.git AND origin exist; otherwise run ensure_repo_synced to create the
Gitea repo + add origin + push.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 17:34:21 +02:00
egutierrez fce88032ca chore: auto-commit (43 archivos)
- .mcp.json
- bash/functions/infra/write_mcp_jupyter_config.md
- bash/functions/infra/write_mcp_jupyter_config.sh
- cpp/CMakeLists.txt
- cpp/apps/chart_demo
- cpp/apps/shaders_lab
- cpp/functions/gfx/gl_framebuffer.cpp
- cpp/functions/gfx/gl_framebuffer.h
- cpp/functions/gfx/gl_framebuffer.md
- cpp/functions/gfx/mesh_gpu.md
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 17:28:47 +02:00
egutierrez fec8ebd4ec merge: origin/master into local 2026-05-27 18:48:28 +02:00
egutierrez f5f05e4624 chore: auto-commit (2 archivos)
- dev/issues/completed/0126-pipeline-launcher-migration-003.md
- dev/proposals_e2e_checks_0121/pipeline_launcher.yaml

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:48:14 +02:00
egutierrez 532f3d0ea8 issue(0128): kanban file attachments — PR draft en dataforge/kanban#1 2026-05-27 10:53:13 +02:00
egutierrez fe65c5e527 feat(infra): auto-commit con 86 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:38:15 +02:00
egutierrez de9bfec498 feat(compile): Wails support in /compile skill
- New helper: deploy_wails_exe_to_windows_bash_infra
  - taskkill + cp build/bin/<app>.exe to Desktop/apps/<app>/
  - cmd.exe /c start RELAUNCHES the app post-deploy (key diff vs cpp)
  - preserves local_files/, copies appicon.ico if present
- New pipeline: compile_wails_app_bash_pipelines
  - resolve_cpp_app_dir (reused) + wails build -platform windows/amd64
  - auto -tags goolm if app declares matrix_crypto_init
  - delegates deploy + relaunch to deploy_wails_exe_to_windows
- /compile skill dispatches by framework:
  - wails.json present -> compile_wails_app (relaunches)
  - CMakeLists.txt present -> compile_cpp_app (no relaunch)

Refs: matrix_client_pc + matrix_admin_panel (issues 0147, 0163)
2026-05-25 03:11:09 +02:00
egutierrez e9c64a4687 chore(issues): close 0166 livekit TURN deploy
Integrated LiveKit TURN deployed on organic-machine.com:
- UDP 3478 + TCP 5349 (not 443 — Traefik HTTP/3 owns it)
- Wildcard cert *.organic-machine.com extracted from Traefik acme.json
- Subdomain turn-matrix-rtc-320bd4.organic-machine.com (wildcard DNS+cert)
- VPS commit f7f5303 in egutierrez/element_matrix_chat

DoD acceptance items requiring real-world CGNAT call testing
deferred to operator (no agent way to test mobile 4G NAT).
2026-05-25 00:46:43 +02:00
egutierrez 70ec825e32 chore(issues): close 0167+0168+0169+0170 livekit hardening bundle
VPS commit: 8eef89b (egutierrez/element_matrix_chat)

- 0167: STUN leak fixed (use_external_ip:false + node_ip hardcoded)
- 0168: UDP range expanded 50000-50200 -> 50000-50500
- 0169: API secret rotated (old key LK44e009c6e92b -> new LK5f6b38bb)
- 0170: livekit.example.yaml refreshed + header comments cleaned

Verification:
- 0 STUN packets to Google during restart (tcpdump 60s window)
- Endpoint /livekit/sfu/ HTTP 200
- LiveKit logs: nodeIP=135.125.201.30, portICERange=[50000,50500]
- Containers livekit + livekit-jwt healthy

New secret stored in pass: matrix/livekit-secret-rotation-2026-05-25
2026-05-25 00:44:15 +02:00
egutierrez 22692c1ed2 feat(matrix): 4 synapse quick wins applied + 6 follow-up issues
Server-side homeserver.yaml on organic-machine VPS:
- encryption_enabled_by_default_for_room_type: invite -> all
- presence.enabled: false (block EDU metadata leak)
- url_preview_enabled: false (block SSRF + IP leak)
- msc4108 rendezvous endpoint uncommented (QR login)

Synapse restarted, /versions shows e2ee_forced.* + msc4108 unstable
features active. Backup at synapse_data/homeserver.yaml.bak.1779659423.

Issues opened for remaining gaps:
- 0165 LUKS for media_store (at-rest encryption)
- 0166 LiveKit TURN deploy (NAT traversal gap)
- 0167 STUN leak to Google (hardcode external_ip)
- 0168 UDP range expand 200 -> 500
- 0169 LIVEKIT_SECRET rotation (audit exposure)
- 0170 livekit.example.yaml rename hygiene

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:53:37 +02:00
egutierrez d128ad89ac feat(matrix-mas): 3 helpers for matrix_client_pc (issue 0147)
- mas_oidc_loopback_go_infra: OAuth2 PKCE + loopback HTTP for desktop login
- keyring_token_store_go_infra: persist OAuth tokens in SO keychain
- matrix_client_init_go_infra: init mautrix.Client from access_token + whoami

Plus go.work workspace including matrix_client_pc sub-repo for shared
import path during dev. All 3 fns tagged matrix-mas capability group.

Tests: TestMasOidcLoopback (15 cases), TestKeyringTokenStore (5 cases,
SKIP on headless), TestMatrixClientInit (6 cases) — all green/skip.

Refs: dev/issues/0147-matrix-client-pc-scaffold.md
Refs: dataforge/matrix_client_pc commit f28c2b1
2026-05-24 23:23:49 +02:00
egutierrez bd9f0d8437 feat(matrix): MAS migration helpers + 2 flows + 15 issues + capability group
Helper functions (matrix-mas capability group):
- mas_client_register_bash_infra: register/sync OAuth clients via mas-cli
- mas_syn2mas_migration_bash_infra: dry-run + apply user migration to MAS
- synapse_msc3861_enable_go_infra: edit homeserver.yaml MSC3861 block (with diff)
- wellknown_oidc_patch_go_infra: patch well-known JSON with msc2965.authentication
- synapse_login_flows_check_go_infra: health-check post-migration login flows

Flows + issues for custom Matrix clients (PC + Android):
- 0010 matrix-client-pc: Wails + React+Mantine (issues 0147-0153)
- 0011 matrix-client-android: Kotlin + Compose (issues 0154-0161)
- 0162 enable MAS as auth provider (Synapse delegate) — EXECUTED on VPS
- 0163 custom admin panel propio (sustituye synapse-admin)

Production state (organic-machine.com):
- Synapse migrated SQLite -> Postgres
- MSC3861 active, password_config disabled
- 21 users + 41 access_tokens migrated via syn2mas
- 4 MAS clients registered (element, matrix_pc, matrix_android, admin_panel)
- synapse-admin container removed + Coolify route deleted
- well-known patched with org.matrix.msc2965.authentication

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:53:33 +02:00
egutierrez 207c08c3b7 merge(0133): columnar snapshot + string pool + reader rewire (1+2+3)
Foundation (ea5c94fc) + reader rewire (01bc2aeb).

- ColumnSnapshot per col (i64/f64/str_ids) + StringPool per-State
- compute_visible_rows filter/sort uses snapshot direct numeric/id compare
- StringPool realloc-crash fix (reserve before emplace_back)
- Pool staleness sentinel (rebuild when string_pool.size() drift)
- High-cardinality cap (>2048 unique → skip interning, fallback raw)

API publica intacta. Bench 100k sort_numeric +131% vs baseline.
text_editor_smoke RED preexisting unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:22:38 +02:00
egutierrez 01bc2aeb14 feat(0133-3): wire filter/sort readers to columnar snapshot
Change 3 of issue 0133 — rewire compute_visible_rows, filter eval,
and sort comparators to read from the SnapshotCache when available.

Hot paths rewired:
- compute_visible_rows (overload with snap): filter eval uses
  compare_snap (fast i64/f64 numeric compare for Int/Float cols;
  id-compare for low-cardinality string Eq/Neq; raw cells fallback
  for Contains/StartsWith/EndsWith).
- Sort comparators: direct i64/f64 array compare for Int/Float cols
  (goto sort_done skips string fallback); string sort uses uint32_t
  id compare with pool lookup only on mismatch.
- Stage>0 filter/sort: same snapshot overload.

Materialization paths (build_so, s0_backing, mat_backing, config popup)
kept on raw cells — they copy into std::string anyway, no benefit from
snapshot and snprintf-per-cell was 2M extra calls per frame.

Bug fixes (required for correctness):
1. StringPool::intern() realloc safety: force reserve before
   emplace_back so string_view keys in the map never go dangling.
2. SnapshotCache::pool_size_built sentinel: detects when a new State
   is created with an empty pool but same cells pointer (begin_scenario
   pattern). Prevents str_ids from indexing into an empty pool (SIGSEGV).
3. Cardinality cap (2048 uniques / 25% sample): high-cardinality string
   cols (timestamps-as-strings, UUIDs, names) skip interning — str_ids
   stays empty and compare_snap falls back to raw cells. Prevents 30MB+
   pool bloat that hurt cache for filter/sort on other cols.

Bench delta vs baseline (100k rows, LIBGL_ALWAYS_SOFTWARE=1):
  linear_scroll: 16.0 -> 15.5 fps p50  (-3%, baseline already FAIL)
  filter_like:   59.7 -> 56.0 fps p50  (-6%, still PASS at 56fps)
  sort_numeric:   3.9 ->  9.0 fps p50 (+131%, snapshot i64 sort)
  color_rule:    15.2 -> 14.8 fps p50  (-3%, baseline already FAIL)

Build: green for all 10 available Linux consumers (text_editor_smoke
linker failure is preexisting, not caused by this change).

API public intact. TableEvent.row indexing TableInput preserved.
Pointer-identity invalidation preserved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 00:21:09 +02:00
egutierrez 9ec7751f6f docs(0133): MIGRATION.md + growth log placeholder + drift fix
- modules/data_table/MIGRATION.md: porting guide + release checklist 1.0.0-stable
- data_table.md: growth log entry commented for post-gate bump
- data_table.md: fix error_type Go remnant ("error_go_core" -> "") in C++ module
- cpp/CMakeLists.txt: SQLite3 optional dep for data_table_bench (cross-windows)
- agent_cleanup_worktree.go: !windows build tag (uses unix-only syscalls)
- dev/issues/0133-cpp-data-table-10m-rows.md: issue tracking

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:56:14 +02:00
egutierrez fef86250a0 fix(0132): terminal_panel black bg + prompt input + cross-platform demos + e2e
terminal_panel.cpp:
  - BeginChild con PushStyleColor(ChildBg, negro) + PushStyleColor(Text, gris claro)
  - PushStyleVar(WindowPadding, 8/6px) para padding terminal real
  - Input prompt siempre visible cuando readonly=false
  - Prefijo "$ " antes del InputText (TextUnformatted + SameLine)
  - BeginDisabled() cuando el shell esta cerrado (en vez de ocultar el widget)
  - Calculo de child_h reserva exactamente GetFrameHeightWithSpacing+6 para el prompt

cpp/tests/e2e/test_terminal_panel_e2e.py (nuevo):
  - 4 asserts: PNG existe, no todo-blanco, region oscura >= 30%, pixels no-negros >= 0.3%
  - Lanza primitives_gallery --capture, busca el binario Linux o Windows.exe automaticamente
  - Skip graceful si no hay GL ni binario (WSL/CI headless)
  - 4/4 pasan en Linux con LIBGL_ALWAYS_SOFTWARE=1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 23:48:42 +02:00
egutierrez 472b6092bb feat(0133): register data_table_bench in cpp/CMakeLists.txt
Adds the add_subdirectory block for apps/data_table_bench so the build
system picks it up. The app itself lives in its own sub-repo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 23:38:17 +02:00
egutierrez ea5c94fc8a feat(0133-1+2): columnar snapshot + string interning in data_table
Change 1 — Columnar Snapshot Internal:
- Add ColumnSnapshot struct (type + str_ids/i64/f64 per column) in data_table_internal.h
- Add SnapshotCache struct with pointer-identity sentinel (last_cells_ptr)
- Add SnapshotCache field to UiState singleton
- In render(): rebuild snapshot after join materialization when cells ptr changes
  Uses same pointer-identity pattern as existing stats_last_cells in State
  Int/Float columns parsed once via parse_number; String/Auto interned

Change 2 — String Interning:
- Add StringPool struct (strings + unordered_map<string_view, uint32_t>) to data_table_types.h
- StringPool is per-State (NOT global) for table isolation
- intern(sv) inserts if absent, returns stable uint32_t index
- Cleared + rebuilt on each snapshot rebuild for index coherence
- Add string_pool field to State struct

Documentation:
- Extended header comment in data_table_internal.h describing design,
  StringPool API, invariants (pointer-identity, row→snapshot_row),
  and how stats_last_cells and snapshot coexist independently

Build: fn_module_data_table + tables_qa pass, no new errors (only
pre-existing -Wformat-truncation warnings unrelated to this change).
Public API (data_table.h, TableInput, render() signature) unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 23:35:12 +02:00
egutierrez a8b09ad154 feat(0132): cpp terminal_panel module + ansi_parser
Nuevo modulo reutilizable terminal_panel (fn_term) para ImGui:

Sub-fn ansi_parser_cpp_core (cpp/functions/core/):
- Parser ANSI/VT100 byte-a-byte sin heap allocs por evento
- SGR colores FG/BG 16-color + bold + reset
- Cursor moves CUU/CUD/CUF/CUB + CUP absoluto
- Erase ED(2)/EL(2), CR/LF/BS
- Statemachine 4 estados, thread-unsafe por diseno
- 21 tests unitarios (57 assertions), todos pasan

terminal_panel_cpp_viz (cpp/functions/viz/terminal_panel/):
- terminal_panel.cpp: render ImGui + process_output con list clipper
- terminal_panel_linux.cpp: forkpty + reader thread no-blocking
- terminal_panel_windows.cpp: ConPTY CreatePseudoConsole (SDK >= 17763)
- Scrollback circular configurable (default 5000 lineas)
- Toolbar: clear, copy, reset, scroll-lock + status indicator
- readonly mode: sin input box, send() es no-op
- uses_functions: ansi_parser_cpp_core, logger_cpp_core

Tests:
- test_ansi_parser.cpp: 21 test cases, 57 assertions (PASS)
- test_terminal_panel_smoke.cpp: 3 test cases (PASS: spawn echo hello,
  process exits cleanly, readonly ignores send)

CMake:
- cpp/tests/CMakeLists.txt: add test_ansi_parser + test_terminal_panel_smoke
- primitives_gallery (sub-repo): ver commit separado en apps/primitives_gallery

Pendiente (anti-scope v1):
- Windows ConPTY: stub funcional que compila; join() del reader thread
  via std::thread no implementado (usa CreateThread detached)
- ANSI 256/24-bit color, italics, Unicode wide
- Curses pesados (vim, htop, top) — cursor visible basic solo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 23:35:11 +02:00
egutierrez 6aa874f2b6 done(0131): agents v0.2 — unified control + uptime/msg_24h + data_table + clear/cache 2026-05-22 23:10:32 +02:00
egutierrez 93352a7780 feat(issues): 0131 agents v0.2 — unified control + uptime/msg_24h + data_table + clear/cache 2026-05-22 22:47:02 +02:00
Egutierrez 0ffae6daa4 feat(0130): kanban_cpp v2 — backend Go + 5 registry parser fns + epic/sub-issues
Registry (issue 0130a):
- 5 fns infra: parse_issue_md, write_issue_md, scan_issues_dir,
  scan_flows_dir, watch_dir_fsnotify
- 3 tipos: Issue, Flow, FsEvent
- Tests round-trip + scan reales + watcher fsnotify (all PASS)
- Capability group 'kanban' nuevo (docs/capabilities/kanban.md)

Apps:
- apps/kanban_cpp/ (sub-repo) — frontend ImGui: board drag-drop,
  flows, filters, detail con CSV editors
- apps/kanban_cpp/backend/ — Go service port 8487: REST + SSE +
  fsnotify watcher, parser bidireccional MD<->SQLite cache

Issues:
- dev/issues/0130-kanban-cpp-v2.md (epic)
- 0130a parser, 0130b backend, 0130c frontend

CMakeLists.txt: add_subdirectory apps/kanban_cpp (registrado por
init_cpp_app scaffolder).

End-to-end verde: backend devuelve 189 issues + 9 flows; PATCH a
/api/issues/{id} reescribe .md (solo frontmatter, body intacto);
frontend --self-test exit 0; tests Go infra 5/5 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:20:15 +02:00
egutierrez 74b58cf0d0 fix(http_request): drop "2>&1" on Windows — CreateProcessW has no shell
POSIX popen routes via /bin/sh -c, so "2>&1" is a shell redirect. On
Windows we use CreateProcessW directly (no shell): curl receives "2>&1"
as a positional arg, treats it as a second URL, and fails with exit 3
"URL rejected: Bad hostname".

Stderr is already merged into the same pipe via STARTUPINFOW.hStdError
on Windows, so the redirect is also unnecessary there. Guard with
#ifndef _WIN32.

Also adds FN_HTTP_DEBUG env var to dump the cmdline + req.url for
future bug triage (zero-cost when unset).

Detected via agents_dashboard.exe --connect-test against
https://agents.organic-machine.com — same .exe with the fix now returns
"OK 11" in <2s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:15:37 +02:00
egutierrez 9752fb106a done: 0128 + 0129 — agents_and_robots HTTP API + agents_dashboard C++ ImGui
Both issues delivered end-to-end:

0128 (backend, merged via dataforge/agents_and_robots/pulls/1):
- HTTP daemon in cmd/launcher with apikey Bearer auth + SSE
- LIVE at https://agents.organic-machine.com via Coolify Traefik + LE cert
- systemd Restart=always
- Unified status autodetect fix applied

0129 (frontend, merged via dataforge/agents_dashboard/pulls/1):
- C++ ImGui app in projects/element_agents/apps/agents_dashboard
- 4 panels: Connection / Agents / Logs / Status
- secret_store_cpp_infra new function (DPAPI Windows / XOR Linux)
- Deployed to Windows Desktop, App Hub tarjeta visible

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:58:05 +02:00
egutierrez 8cb0121573 merge: 0129 agents_dashboard scaffold + secret_store_cpp_infra + CMakeLists register 2026-05-22 21:57:04 +02:00
egutierrez 90115270d2 chore: auto-commit (3 archivos)
- cpp/functions/infra/secret_store.cpp
- cpp/functions/infra/secret_store.h
- cpp/functions/infra/secret_store.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:52:37 +02:00
246 changed files with 30229 additions and 272 deletions
+58 -21
View File
@@ -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.
+1
View File
@@ -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. |
+131
View File
@@ -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.
+4
View File
@@ -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"]
}
}
}
+22
View File
@@ -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
+76
View File
@@ -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.
+109
View File
@@ -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).
+204
View File
@@ -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\"}"
+68
View File
@@ -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 -->
+116
View File
@@ -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
}
+66
View File
@@ -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 -->
+171
View File
@@ -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
}
+51
View File
@@ -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 -->
+81
View File
@@ -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
}
+79
View File
@@ -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).
+161
View File
@@ -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
+101
View File
@@ -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:-}"
+31 -4
View File
@@ -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
+24
View File
@@ -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()
+250
View File
@@ -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
+131
View File
@@ -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
+97
View File
@@ -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.
+60
View File
@@ -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;
+15 -2
View File
@@ -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;
+42 -7
View File
@@ -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
+10 -7
View File
@@ -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
+33 -10
View File
@@ -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
+510
View File
@@ -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
+41
View File
@@ -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
+101
View File
@@ -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.
+1 -1
View File
@@ -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: []
+1 -1
View File
@@ -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: []
+5 -6
View File
@@ -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.
+27 -9
View File
@@ -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
+24
View File
@@ -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()
+225
View File
@@ -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})"
)
+215
View File
@@ -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);
}
+243
View File
@@ -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());
}
+110
View File
@@ -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
+274
View File
@@ -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).
+157
View File
@@ -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).
+165
View File
@@ -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.
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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 `![](url)` (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`.
+121
View File
@@ -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).
+75
View File
@@ -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).
+114
View File
@@ -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.
+90
View File
@@ -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.
+979
View File
@@ -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.
+73
View File
@@ -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