diff --git a/agents/asistente2/config.yaml b/agents/asistente2/config.yaml index a244bbf..ccef07d 100644 --- a/agents/asistente2/config.yaml +++ b/agents/asistente2/config.yaml @@ -115,7 +115,7 @@ tools: matrix: homeserver: "https://matrix-af2f3d.organic-machine.com" user_id: "@asistente-2:matrix-af2f3d.organic-machine.com" - access_token_env: MATRIX_TOKEN_ASISTENTE2 + access_token_env: MATRIX_TOKEN_ASISTENTE_2 device_id: "XUGTSZJYFQ" encryption: diff --git a/agents/assistant/config.yaml b/agents/assistant/config.yaml index c468451..9218c4e 100644 --- a/agents/assistant/config.yaml +++ b/agents/assistant/config.yaml @@ -116,7 +116,7 @@ tools: matrix: homeserver: "https://matrix-af2f3d.organic-machine.com" user_id: "@assistant-bot:matrix-af2f3d.organic-machine.com" - access_token_env: MATRIX_TOKEN_ASSISTANT + access_token_env: MATRIX_TOKEN_ASSISTANT_BOT device_id: "SMWMRKMHDH" encryption: diff --git a/agents/runtime.go b/agents/runtime.go index 7a3384e..78c8cc4 100644 --- a/agents/runtime.go +++ b/agents/runtime.go @@ -68,6 +68,13 @@ func New(cfg *config.AgentConfig, rules []decision.Rule, logger *slog.Logger) (* } } + // Sign own device with the self-signing key so Element shows it as verified. + if err := matrixClient.SignOwnDevice(context.Background()); err != nil { + logger.Warn("failed to sign own device (non-fatal)", "err", err) + } else { + logger.Info("own device signed with cross-signing key") + } + logger.Info("e2ee ready") } diff --git a/cmd/verify/main.go b/cmd/verify/main.go index f7d94a1..bcf074b 100644 --- a/cmd/verify/main.go +++ b/cmd/verify/main.go @@ -4,6 +4,7 @@ // Usage: // // go run -tags goolm ./cmd/verify --homeserver https://... --username asistente-2 --password --token +// go run -tags goolm ./cmd/verify --homeserver https://... --username asistente-2 --token # tries dummy/admin UIA package main import ( @@ -38,7 +39,8 @@ func main() { Long: `Generates and uploads cross-signing keys so the bot's device is verified. This removes the "Encrypted by a device not verified by its owner" warning. -Requires the bot's access token and password (for UIA during key upload).`, +Requires the bot's access token. Password is optional — if omitted, tries +dummy auth (MSC3967, Synapse 1.79+) then falls back to password if needed.`, RunE: func(cmd *cobra.Command, args []string) error { homeserver = strings.TrimRight(homeserver, "/") serverName := homeserver @@ -102,7 +104,9 @@ Requires the bot's access token and password (for UIA during key upload).`, } fmt.Println("→ Generating and uploading cross-signing keys...") - recoveryKey, _, err := olmMachine.GenerateAndUploadCrossSigningKeysWithPassword(ctx, password, "") + + // Try multiple UIA strategies in order of preference. + recoveryKey, err := uploadCrossSigningKeys(ctx, olmMachine, password) if err != nil { // If keys already exist, try to just sign our device fmt.Printf(" Note: %v\n", err) @@ -111,41 +115,129 @@ Requires the bot's access token and password (for UIA during key upload).`, } fmt.Println("✓ Cross-signing keys uploaded successfully") - fmt.Printf("✓ Device %s is now verified by %s\n", client.DeviceID, userID) + + // Sign own device immediately after uploading keys + if signErr := signOwnDevice(ctx, olmMachine, client); signErr != nil { + fmt.Printf(" Warning: could not auto-sign device: %v\n", signErr) + } + fmt.Println() fmt.Println("─── IMPORTANT: Save the recovery key ───") - fmt.Printf("SSSS_RECOVERY_KEY_%s=%s\n", strings.ToUpper(strings.ReplaceAll(username, "-", "_")), recoveryKey) + envKey := strings.ToUpper(strings.ReplaceAll(username, "-", "_")) + fmt.Printf("SSSS_RECOVERY_KEY_%s=%s\n", envKey, recoveryKey) fmt.Println() fmt.Println("Add this to your .env file and set recovery_key_env in the agent's config.yaml:") fmt.Println(" encryption:") - fmt.Printf(" recovery_key_env: SSSS_RECOVERY_KEY_%s\n", strings.ToUpper(strings.ReplaceAll(username, "-", "_"))) + fmt.Printf(" recovery_key_env: SSSS_RECOVERY_KEY_%s\n", envKey) return nil }, } root.Flags().StringVar(&homeserver, "homeserver", "", "Matrix homeserver URL") root.Flags().StringVar(&username, "username", "", "Bot username (without @ or server)") - root.Flags().StringVar(&password, "password", "", "Bot password (for UIA auth)") + root.Flags().StringVar(&password, "password", "", "Bot password (for UIA auth, optional)") root.Flags().StringVar(&token, "token", "", "Bot access token") root.Flags().StringVar(&storePath, "store", "./data/verify-crypto/", "Crypto store path") root.Flags().StringVar(&pickleKeyHex, "pickle-key", "", "Hex-encoded pickle key (must match agent's pickle key if sharing crypto store)") _ = root.MarkFlagRequired("homeserver") _ = root.MarkFlagRequired("username") - _ = root.MarkFlagRequired("password") _ = root.MarkFlagRequired("token") + // password is no longer required if err := root.Execute(); err != nil { os.Exit(1) } } -func signOwnDevice(ctx context.Context, mach *crypto.OlmMachine, client *mautrix.Client) error { - device := &id.Device{ - UserID: client.UserID, - DeviceID: client.DeviceID, +// uploadCrossSigningKeys tries multiple UIA strategies to upload cross-signing keys. +// Order: password (if provided) → dummy (MSC3967) → password with empty string. +func uploadCrossSigningKeys(ctx context.Context, mach *crypto.OlmMachine, password string) (string, error) { + type strategy struct { + name string + fn func(*mautrix.RespUserInteractive) interface{} } - err := mach.SignOwnDevice(ctx, device) + + var strategies []strategy + + // If password provided, try it first + if password != "" { + strategies = append(strategies, strategy{ + name: "password auth", + fn: func(uiResp *mautrix.RespUserInteractive) interface{} { + return &mautrix.ReqUIAuthLogin{ + BaseAuthData: mautrix.BaseAuthData{ + Type: mautrix.AuthTypePassword, + Session: uiResp.Session, + }, + User: mach.Client.UserID.String(), + Password: password, + } + }, + }) + } + + // Try dummy auth (MSC3967 — works on first upload with Synapse 1.79+) + strategies = append(strategies, strategy{ + name: "dummy auth (MSC3967)", + fn: func(uiResp *mautrix.RespUserInteractive) interface{} { + return &mautrix.BaseAuthData{ + Type: mautrix.AuthTypeDummy, + Session: uiResp.Session, + } + }, + }) + + // If no password was given, also try password auth with empty as last resort + if password == "" { + strategies = append(strategies, strategy{ + name: "empty password auth", + fn: func(uiResp *mautrix.RespUserInteractive) interface{} { + return &mautrix.ReqUIAuthLogin{ + BaseAuthData: mautrix.BaseAuthData{ + Type: mautrix.AuthTypePassword, + Session: uiResp.Session, + }, + User: mach.Client.UserID.String(), + Password: " ", // non-empty to avoid omitempty dropping the field + } + }, + }) + } + + var lastErr error + for _, s := range strategies { + fmt.Printf(" Trying %s...\n", s.name) + recoveryKey, _, err := mach.GenerateAndUploadCrossSigningKeys(ctx, s.fn, "") + if err == nil { + fmt.Printf(" ✓ Succeeded with %s\n", s.name) + return recoveryKey, nil + } + fmt.Printf(" ✗ %s failed: %v\n", s.name, err) + lastErr = err + } + + return "", lastErr +} + +func signOwnDevice(ctx context.Context, mach *crypto.OlmMachine, client *mautrix.Client) error { + // Force-fetch own device keys from the server so the local store has + // the correct signing key. Without this, SignOwnDevice fails with + // "received update for device with different signing key (expected , got X)". + devices, err := mach.FetchKeys(ctx, []id.UserID{client.UserID}, true) if err != nil { + return fmt.Errorf("fetch own device keys: %w", err) + } + + userDevices, ok := devices[client.UserID] + if !ok { + return fmt.Errorf("own user %s not found in fetched keys", client.UserID) + } + device, ok := userDevices[client.DeviceID] + if !ok { + return fmt.Errorf("own device %s not found in fetched keys", client.DeviceID) + } + + if err := mach.SignOwnDevice(ctx, device); err != nil { return fmt.Errorf("sign own device: %w", err) } fmt.Printf("✓ Device %s signed with cross-signing key\n", client.DeviceID) diff --git a/dev-scripts/verify.sh b/dev-scripts/verify.sh new file mode 100755 index 0000000..5683ab7 --- /dev/null +++ b/dev-scripts/verify.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# verify.sh — (re)verifica dispositivos E2EE de agentes Matrix +# +# Genera/sube cross-signing keys y firma el device de cada agente. +# Usa el MISMO crypto store que el agente para que las keys queden disponibles. +# +# Uso: +# ./dev-scripts/verify.sh # verifica todos los habilitados con E2EE +# ./dev-scripts/verify.sh assistant-bot # verifica uno específico + +source "$(dirname "$0")/_common.sh" +load_env + +TARGET="${1:-}" + +# ── YAML helpers (simple grep-based, no deps) ──────────────────────────── + +yaml_val() { + # Extract a simple YAML value: yaml_val file "key" + # Handles both quoted and unquoted values. + local file="$1" key="$2" + grep -m1 "^\s*${key}:" "$file" 2>/dev/null \ + | sed 's/^[^:]*:\s*//' \ + | tr -d '"' \ + | tr -d "'" \ + | xargs +} + +# ── Verify a single agent ──────────────────────────────────────────────── + +verify_agent() { + local cfg="$1" + local agent_id; agent_id="$(yaml_val "$cfg" "id")" + local agent_dir; agent_dir="$(dirname "$cfg")" + + # Check E2EE is enabled + local enc_enabled; enc_enabled="$(yaml_val "$cfg" "enabled")" + # The first "enabled" is agent.enabled; we need encryption.enabled specifically + enc_enabled="$(grep -A5 'encryption:' "$cfg" | grep -m1 'enabled:' | awk '{print $2}')" + if [[ "$enc_enabled" != "true" ]]; then + dim " $agent_id — E2EE deshabilitado, saltando" + return 0 + fi + + # Extract config values + local user_id; user_id="$(yaml_val "$cfg" "user_id")" + local username; username="$(echo "$user_id" | sed 's/@\([^:]*\):.*/\1/')" + local token_env; token_env="$(yaml_val "$cfg" "access_token_env")" + local pickle_env; pickle_env="$(yaml_val "$cfg" "pickle_key_env")" + local recovery_env; recovery_env="$(yaml_val "$cfg" "recovery_key_env")" + local store_path; store_path="$(grep -A5 'encryption:' "$cfg" | grep -m1 'store_path:' | sed 's/^[^:]*:\s*//' | tr -d '"' | xargs)" + + local token="${!token_env:-}" + local pickle_key="${!pickle_env:-}" + + # Find password — convention: MATRIX_PASSWORD_ + local norm; norm="$(echo "$username" | tr '-' '_' | tr '[:lower:]' '[:upper:]')" + local pass_env="MATRIX_PASSWORD_${norm}" + local password="${!pass_env:-}" + + # Validate required values + if [[ -z "$token" ]]; then + fail " $agent_id — $token_env no está en .env" + return 1 + fi + if [[ -z "$password" ]]; then + warn " $agent_id — $pass_env no está en .env, intentando sin password..." + fi + + info "$agent_id — verificando device..." + dim " user: $username" + dim " store: $store_path" + dim " pickle_env: $pickle_env" + dim " token_env: $token_env" + + # Stop agent if running (crypto store can't be shared) + local was_running=false + if is_running "$agent_id"; then + was_running=true + info " Deteniendo $agent_id antes de verificar..." + "$REPO_ROOT/dev-scripts/stop.sh" "$agent_id" + sleep 1 + fi + + # Build verify command + local verify_bin="$REPO_ROOT/bin/verify" + if [[ ! -x "$verify_bin" ]] || [[ "$(find ./cmd/verify -newer "$verify_bin" 2>/dev/null | head -1)" ]]; then + info " Compilando cmd/verify..." + mkdir -p "$(dirname "$verify_bin")" + "$GO" build -tags goolm -o "$verify_bin" ./cmd/verify || { + fail " No se pudo compilar cmd/verify" + return 1 + } + fi + + # Run verification + local verify_args=( + --homeserver "$MATRIX_HOMESERVER" + --username "$username" + --token "$token" + --store "$store_path" + ) + if [[ -n "$password" ]]; then + verify_args+=(--password "$password") + fi + if [[ -n "$pickle_key" ]]; then + verify_args+=(--pickle-key "$pickle_key") + fi + + local output + if output=$("$verify_bin" "${verify_args[@]}" 2>&1); then + ok "$agent_id — verificación exitosa" + + # Extract recovery key from output if present + local new_rk + new_rk="$(echo "$output" | grep "^SSSS_RECOVERY_KEY_" | cut -d= -f2-)" + if [[ -n "$new_rk" && -n "$recovery_env" ]]; then + # Update .env with new recovery key (quoted — keys contain spaces) + local quoted_rk="\"${new_rk}\"" + if grep -q "^${recovery_env}=" "$REPO_ROOT/.env"; then + sed -i "s|^${recovery_env}=.*|${recovery_env}=${quoted_rk}|" "$REPO_ROOT/.env" + ok " Recovery key actualizada en .env ($recovery_env)" + else + echo "${recovery_env}=${quoted_rk}" >> "$REPO_ROOT/.env" + ok " Recovery key añadida a .env ($recovery_env)" + fi + fi + else + warn "$agent_id — verify output:" + echo "$output" + # If it says keys already exist, that's usually fine + if echo "$output" | grep -q "signed with cross-signing key"; then + ok "$agent_id — device firmado con keys existentes" + else + warn "$agent_id — puede necesitar atención manual" + fi + fi + + echo "$output" | sed 's/^/ /' + + # Restart agent if it was running + if [[ "$was_running" == "true" ]]; then + info " Reiniciando $agent_id..." + "$REPO_ROOT/dev-scripts/start.sh" "$agent_id" + fi + + echo +} + +# ── Main ────────────────────────────────────────────────────────────────── + +echo +info "Verificación E2EE de agentes Matrix" +echo + +if [[ -n "$TARGET" ]]; then + cfg="$(config_path_for "$TARGET")" + [[ -n "$cfg" ]] || fail "Agente '$TARGET' no encontrado" + verify_agent "$cfg" +else + while IFS='|' read -r id version enabled desc cfg; do + [[ "$enabled" == "true" ]] || continue + verify_agent "$cfg" + done < <(list_agents_raw) +fi + +ok "Verificación completada" diff --git a/docs/system-flow.md b/docs/system-flow.md new file mode 100644 index 0000000..eb78e98 --- /dev/null +++ b/docs/system-flow.md @@ -0,0 +1,274 @@ +# Flujo del sistema de agentes — Diagrama de funciones + +## 1. Arranque del sistema (Launcher) + +```mermaid +flowchart TD + START["cmd/launcher/main()"] --> NEWLOGGER["newLogger(level)"] + START --> GLOB["Glob: agents/*/config.yaml"] + GLOB --> LOAD["config.Load(path)
→ os.ExpandEnv + validate()"] + LOAD --> RULESFOR["rulesFor(agentID)
→ rulesRegistry[id]()"] + RULESFOR --> AGENTNEW["agents.New(cfg, rules, logger)"] + + subgraph "agents.New() — Ensamblado" + AGENTNEW --> MATRIXNEW["matrix.New(cfg.Matrix)
→ crea mautrix.Client"] + MATRIXNEW --> CRYPTO{"encryption.enabled?"} + CRYPTO -->|sí| INITCRYPTO["client.InitCrypto()
→ initCryptoCore()
→ initHelper()
→ resolvePickleKey()
→ logCryptoDiagnostics()"] + INITCRYPTO --> FETCHKEYS{"recovery_key?"} + FETCHKEYS -->|sí| CROSSSIGN["client.FetchCrossSigningKeys()
→ fetchCrossSigningKeysCore()"] + FETCHKEYS -->|no| SSHEXEC + CROSSSIGN --> SSHEXEC + CRYPTO -->|no| SSHEXEC + SSHEXEC["ssh.NewExecutor(cfg.SSH)"] + SSHEXEC --> LLMFACTORY["llm.FromConfig(cfg.LLM.Primary)
→ NewAnthropicComplete() /
NewOpenAIComplete()"] + LLMFACTORY --> FALLBACK{"fallback?"} + FALLBACK -->|sí| WITHFALLBACK["llm.WithFallback(primary, fallback)"] + FALLBACK -->|no| TOOLREG + WITHFALLBACK --> TOOLREG + TOOLREG["buildToolRegistry(cfg, ssh, matrix)
→ NewHTTPGet/Post()
→ NewSSHCommand()
→ NewReadFile()
→ NewCurrentTime()
→ NewMatrixSend()"] + TOOLREG --> RUNNER["effects.NewRunner(matrix, ssh)"] + RUNNER --> LISTENER["matrix.NewListener(client, cfg, handleEvent)"] + end + + AGENTNEW --> RUN["agent.Run(ctx)
→ listener.Run(ctx)
→ mautrix.SyncWithContext()"] + START --> SIGNAL["Espera SIGINT / SIGTERM
→ cancel ctx → shutdown"] +``` + +## 2. Procesamiento de eventos (flujo principal) + +```mermaid +flowchart TD + SYNC["mautrix SyncWithContext()"] --> EVENT["Evento Matrix recibido
EventMessage / StateMember"] + + EVENT --> AUTOJOIN{"StateMember
invite?"} + AUTOJOIN -->|sí| JOIN["Auto-join room"] + AUTOJOIN -->|no| SHOULD["listener.shouldHandle(evt)
→ filtra propios, bots, blocked, rooms"] + + SHOULD -->|rechazado| DROP["Descartado"] + SHOULD -->|aceptado| ISDM["listener.checkIsDM(roomID)
→ cache de rooms con 2 miembros"] + + ISDM --> PARSE["message.Parse(body, sender, room, ...)
→ detecta mentions
→ parsea command + args
→ retorna MessageContext"] + + PARSE --> GOROUTINE["goroutine: agent.handleEvent()"] + + subgraph "handleEvent() — Decisión y ejecución" + GOROUTINE --> TYPING["matrix.SendTyping(room, true)"] + TYPING --> EVALUATE["decision.Evaluate(msgCtx, rules)
→ recorre reglas, Match() → []Action"] + + EVALUATE --> HASACTIONS{"¿acciones?"} + HASACTIONS -->|sí| CHECKLLM{"¿contiene
ActionKindLLM?"} + HASACTIONS -->|no| FALLBACKLLM{"¿es DM o
mención?"} + + FALLBACKLLM -->|sí| RUNLLM["agent.runLLM(ctx, msgCtx)"] + FALLBACKLLM -->|no| NOOP["Sin acción"] + + CHECKLLM -->|sí| EXPANDLLM["Expande LLM actions:
runLLM() → ReplyAction"] + CHECKLLM -->|no| EXECUTE + + EXPANDLLM --> EXECUTE + RUNLLM --> EXECUTE["runner.Execute(ctx, roomID, actions)"] + end +``` + +## 3. Loop de herramientas del LLM (tool-use) + +```mermaid +flowchart TD + RUNLLM["agent.runLLM()"] --> BUILD["Construir CompletionRequest
→ SystemPrompt desde archivo
→ Messages: historial + user
→ Tools: registry.ToLLMSpecs()"] + + BUILD --> CALL["CompleteFunc(ctx, request)
→ Anthropic API / OpenAI API"] + + subgraph "shell/llm — Proveedores" + CALL --> ANTHROPIC["NewAnthropicComplete()
→ toAnthropicRequest()
→ HTTP POST /v1/messages
→ fromAnthropicResponse()"] + CALL --> OPENAI["NewOpenAIComplete()
→ toOpenAIMessage()
→ toOpenAITools()
→ SDK CreateChatCompletion"] + end + + ANTHROPIC --> RESPONSE["CompletionResponse
{Content, ToolCalls, Usage}"] + OPENAI --> RESPONSE + + RESPONSE --> HASTOOLS{"¿ToolCalls
en respuesta?"} + HASTOOLS -->|no| RETURN["Retorna Content como texto"] + HASTOOLS -->|sí| EXECTOOLS["Por cada ToolCall:
registry.Execute(name, argsJSON)"] + + subgraph "tools/ — Ejecución de herramientas" + EXECTOOLS --> TOOLSWITCH{"tool name"} + TOOLSWITCH --> HTTP_GET["http_get
→ validateDomain()
→ GET request"] + TOOLSWITCH --> HTTP_POST["http_post
→ validateDomain()
→ POST request"] + TOOLSWITCH --> SSH_CMD["ssh_command
→ validateTarget()
→ validateCommand()
→ ssh.Executor.Execute()"] + TOOLSWITCH --> READ_FILE["read_file
→ validatePath()
→ os.ReadFile()"] + TOOLSWITCH --> MATRIX_SEND["matrix_send
→ client.SendText()"] + TOOLSWITCH --> CURRENT_TIME["current_time
→ time.Now().Format()"] + end + + EXECTOOLS --> APPEND["Append assistant msg + tool results
a Messages del request"] + APPEND --> ITER{"iteración <
maxIter?"} + ITER -->|sí| CALL + ITER -->|no| RETURN +``` + +## 4. Ejecución de efectos (Runner) + +```mermaid +flowchart TD + EXECUTE["runner.Execute(ctx, roomID, actions)"] --> LOOP["Por cada Action en []Action"] + + LOOP --> EXECONE["runner.executeOne(ctx, roomID, action)"] + + EXECONE --> KIND{"action.Kind"} + + KIND -->|ActionKindReply| REPLY["matrix.SendText(ctx, roomID, content)
→ envío auto-encriptado si E2EE"] + KIND -->|ActionKindSSH| SSHEXEC["ssh.Executor.Execute(ctx, spec)"] + KIND -->|otro| UNHANDLED["Result{Err: unhandled}"] + + subgraph "shell/ssh — Ejecución SSH" + SSHEXEC --> LOOKUP["Buscar target en config
→ resolver user/port/key"] + LOOKUP --> LOADSIGNER["loadSigner(keyFileEnv)
→ leer clave privada"] + LOADSIGNER --> DIAL["gossh.Dial(tcp, host:port)"] + DIAL --> SESSION["client.NewSession()"] + SESSION --> RUNCMD["session.CombinedOutput(cmd)"] + RUNCMD --> SSHRESULT["Result{Stdout, Stderr, ExitCode}"] + end + + REPLY --> RESULT["Result{Action, Output, Err}"] + SSHRESULT --> RESULT + UNHANDLED --> RESULT +``` + +## 5. Motor de reglas puro (decision engine) + +```mermaid +flowchart TD + EVAL["decision.Evaluate(ctx, rules)"] --> LOOP["Por cada Rule"] + LOOP --> MATCH["rule.Match(ctx) → bool"] + + subgraph "MatchFuncs disponibles (pure)" + MATCH --> CMD["MatchCommand(cmd)
ctx.Command == cmd"] + MATCH --> PREFIX["MatchPrefix(prefix)
strings.HasPrefix(ctx.Content)"] + MATCH --> ANY["MatchAny()
→ true siempre"] + MATCH --> POWER["MatchMinPowerLevel(n)
ctx.PowerLevel >= n"] + MATCH --> COMPOSE["And(...) / Or(...)
composición lógica"] + end + + MATCH -->|true| COLLECT["Agregar rule.Actions a resultado"] + MATCH -->|false| NEXT["Siguiente regla"] + COLLECT --> NEXT + NEXT --> LOOP + LOOP -->|fin| ACTIONS["Retorna []Action acumuladas"] +``` + +## 6. Gestión de procesos (agentctl / dev-scripts) + +```mermaid +flowchart TD + CLI["cmd/agentctl/main()"] --> MGR["process.NewManager(runDir, glob, bin)"] + + MGR --> LISTCMD["listCmd → mgr.StatusAll()"] + MGR --> STARTCMD["startCmd → mgr.Start(info)"] + MGR --> STOPCMD["stopCmd → mgr.Stop(id)"] + MGR --> REMOVECMD["removeCmd → mgr.Stop + setEnabled(false)"] + + subgraph "process.Manager — Ciclo de vida" + LISTCMD --> SCAN["mgr.Scan()
→ glob configs
→ config.LoadMeta()"] + SCAN --> STATUS["mgr.Status(info)
→ findProcessPIDs()
→ resolveRunningPID()"] + + STARTCMD --> STARTCHECK{"¿ya running?"} + STARTCHECK -->|sí| REJECT["Error: already running"] + STARTCHECK -->|no| LAUNCH["Abrir log file
→ buildEnv()
→ os/exec.Start()
→ escribir PID file"] + + STOPCMD --> SIGTERM["SIGTERM a todos los PIDs"] + SIGTERM --> WAIT["Esperar 5s (polls cada 500ms)"] + WAIT --> ALIVE{"¿todavía vivo?"} + ALIVE -->|sí| SIGKILL["SIGKILL"] + ALIVE -->|no| CLEAN["removePID()"] + SIGKILL --> CLEAN + end + + subgraph "Monitoreo" + STATUS --> STATS["mgr.Stats(id)
→ statsForPID()
→ /proc/pid/stat (uptime)
→ /proc/pid/status (RSS)
→ ps -o pcpu (CPU)"] + STATUS --> LOGS["mgr.LogTail(id, n)
→ últimas N líneas del log"] + end +``` + +## 7. Dashboard TUI (Bubbletea — pure core / impure shell) + +```mermaid +flowchart TD + MAIN["cmd/dashboard/main()"] --> BRIDGE["newBridge(adapter)"] + BRIDGE --> TEA["tea.NewProgram(bridge)"] + + subgraph "Pure Core — pkg/tui" + INIT["bridge.Init()
→ IntentLoadAgents"] + UPDATE["puretui.Update(model, msg)
→ (Model, []Intent)"] + VIEW["puretui.View(model)
→ string renderizado"] + + UPDATE --> SCREENS{"Screen actual"} + SCREENS --> MAIN_MENU["updateMainScreen()"] + SCREENS --> AGENT_LIST["updateAgentList()"] + SCREENS --> AGENT_ACTIONS["updateAgentActions()
→ executeAction()"] + SCREENS --> LOGS_VIEW["updateLogs()"] + SCREENS --> SERVER_VIEW["updateServerScreen()
→ executeServerAction()"] + end + + subgraph "Impure Shell — shell/tui" + ADAPTER["Adapter.RunIntent(intent)"] + ADAPTER --> LOAD["loadAgents()
→ mgr.StatusAll() + Stats()"] + ADAPTER --> START["startAgent(id)
→ mgr.Start()"] + ADAPTER --> STOP["stopAgent(id)
→ mgr.Stop()"] + ADAPTER --> KILL["killAgent(id)
→ mgr.Kill()"] + ADAPTER --> RESTART["restartAgent(id)
→ Stop + Start"] + ADAPTER --> STARTALL["startAll() / stopAll()
restartAll() / killAll()"] + ADAPTER --> LOADLOGS["loadLogs(id)
→ mgr.LogTail()"] + end + + TEA --> INIT + INIT --> ADAPTER + TEA --> UPDATE + UPDATE -->|"[]Intent"| ADAPTER + ADAPTER -->|"tea.Cmd → Msg"| UPDATE + TEA --> VIEW +``` + +## 8. Registro y verificación E2EE de bots + +```mermaid +flowchart TD + subgraph "cmd/register — Registro en Matrix" + REG["main()"] --> CREATE["createUser(homeserver, token, userID, name, pass)
→ PUT /_synapse/admin/v2/users/"] + CREATE --> LOGIN["loginAs(homeserver, user, pass)
→ POST /_matrix/client/v3/login"] + LOGIN --> TOKEN["Imprime access_token + device_id
→ exportar como MATRIX_TOKEN_BOT"] + REG --> GENPASS["generatePassword()
→ 24 bytes /dev/urandom → hex"] + end + + subgraph "cmd/verify — Cross-signing E2EE" + VER["main()"] --> MAUTRIX["Crear mautrix.Client"] + MAUTRIX --> INITCRYPTO["cryptohelper.Init()
→ mismo store que el agente"] + INITCRYPTO --> GENKEYS["GenerateAndUploadCrossSigningKeys
WithPassword()"] + GENKEYS --> RECOVERY["Imprime SSSS recovery key"] + GENKEYS -->|"keys exist"| SIGNOWN["signOwnDevice()
→ mach.SignOwnDevice()"] + end +``` + +## 9. Flujo completo end-to-end + +```mermaid +flowchart LR + USER["Usuario Matrix"] -->|"mensaje"| HOMESERVER["Matrix Homeserver"] + HOMESERVER -->|"sync"| LISTENER["Listener.Run()
shouldHandle()
checkIsDM()"] + LISTENER -->|"Parse()"| MSGCTX["MessageContext
(puro)"] + MSGCTX -->|"handleEvent()"| DECIDE["Evaluate(rules)
(puro)"] + + DECIDE -->|"[]Action"| BRANCH{"¿tipo?"} + + BRANCH -->|"LLM"| LLM["runLLM()
→ tool-use loop"] + LLM -->|"CompleteFunc"| API["Anthropic / OpenAI API"] + API -->|"ToolCalls"| TOOLS["Registry.Execute()
http / ssh / file / time"] + TOOLS -->|"results"| LLM + LLM -->|"texto final"| REPLY + + BRANCH -->|"Reply"| REPLY["SendText() / SendMarkdown()"] + BRANCH -->|"SSH"| SSH["Executor.Execute()"] + SSH -->|"resultado"| REPLY + + REPLY -->|"respuesta"| HOMESERVER + HOMESERVER -->|"mensaje"| USER +``` diff --git a/shell/matrix/client.go b/shell/matrix/client.go index abd241e..57cc9b2 100644 --- a/shell/matrix/client.go +++ b/shell/matrix/client.go @@ -150,6 +150,41 @@ func (c *Client) FetchCrossSigningKeys(ctx context.Context, recoveryKey string) return fetchCrossSigningKeysCore(ctx, &olmSSSSFetcher{machine}, recoveryKey) } +// SignOwnDevice signs the bot's current device with the self-signing key. +// This is the step that makes Element show the device as "verified". +// Must be called after cross-signing private keys are available (via +// FetchCrossSigningKeys or GenerateAndUploadCrossSigningKeys). +// It force-fetches device keys from the server first to ensure the local +// store has the correct signing key. +func (c *Client) SignOwnDevice(ctx context.Context) error { + wrapper, ok := c.raw.Crypto.(*mautrixCryptoWrapper) + if !ok || wrapper == nil { + return fmt.Errorf("crypto not initialized") + } + machine := wrapper.Machine() + if machine == nil { + return fmt.Errorf("olm machine not available") + } + + // Force-fetch own device keys so the local store has the correct signing key. + // Without this, SignOwnDevice fails with "different signing key" when the + // store has a stale or empty entry. + devices, err := machine.FetchKeys(ctx, []id.UserID{c.raw.UserID}, true) + if err != nil { + return fmt.Errorf("fetch own device keys: %w", err) + } + userDevices, ok := devices[c.raw.UserID] + if !ok { + return fmt.Errorf("own user not found in fetched keys") + } + device, ok := userDevices[c.raw.DeviceID] + if !ok { + return fmt.Errorf("own device %s not found in fetched keys", c.raw.DeviceID) + } + + return machine.SignOwnDevice(ctx, device) +} + // fetchCrossSigningKeysCore contains the testable logic for SSSS key retrieval. func fetchCrossSigningKeysCore(ctx context.Context, fetcher ssssKeyFetcher, recoveryKey string) error { keyID, keyData, err := fetcher.GetDefaultKeyData(ctx)