diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a512bb..ef563e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.75.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.76.0**. --- @@ -14,6 +14,27 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.76.0] — 2026-10-22 + +### Ajouté + +- **Émission d'événements vers le journal du service d'agents + (#220, Fusion — Lot 2 vague 5)** — ObsiGate émet ses mutations de + notes **explicites** au journal d'activité du service d'agents + (contrat ACC-0.2) : `obsigate.note.created / edited / moved / + deleted`, enveloppes signées HMAC-SHA256 (clé + `AGENT_EVENEMENTS_CLE`), envoyées en arrière-plan sans jamais + bloquer le métier, inactives tant que la clé n'est pas posée. + Points d'accroche : les endpoints d'écriture de l'API web (acteur + `user`) et la couche outils / serveur MCP (acteur `agent:mcp`). + **Garde-fou : jamais le watcher** — les modifications faites hors + API (Obsidian, éditeur externe) n'émettent rien. Domaine par vault + via `AGENT_EVENEMENTS_DOMAINES`, repli `pro` (Q-A ouverte). + Redéploiement Docker requis, puis pose de la clé des deux côtés + pour activer. + +--- + ## [2.75.0] — 2026-10-22 ### Ajouté diff --git a/README.fr.md b/README.fr.md index 0274fd8..7a624f2 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.75.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.76.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.75.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.76.0). --- -*Projet : ObsiGate | Version : 2.75.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.76.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index e072caf..42944b5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.75.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.76.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.75.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.76.0). --- -*Project: ObsiGate | Version: 2.75.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.76.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index ddcb9bf..9445334 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.75.0 +2.76.0 diff --git a/backend/agent_journal.py b/backend/agent_journal.py new file mode 100644 index 0000000..0c60bdc --- /dev/null +++ b/backend/agent_journal.py @@ -0,0 +1,173 @@ +"""Émission d'événements vers le journal du service d'agents +(Fusion, Lot 2 vague 5 — contrat ACC-0.2, Roadmap #220). + +ObsiGate émet ses mutations de notes **explicites** vers +``POST /api/v1/evenements`` du service d'agents, qui les expose dans +le journal de sa console. L'enveloppe ne contient qu'une référence, +un libellé et un lien — jamais le contenu de la note : +``{id, type, occurred_at, actor, domain, source_app, +object{ref, label, url}}``. + +**Garde-fou (conception validée)** : ce module n'est appelé que +depuis les points d'écriture explicites de l'API web — +:mod:`backend.routers.files_write` (session utilisateur, acteur +``user``) et la couche outils :mod:`backend.tools.service` +(serveur MCP d'ObsiGate, acteur ``agent:mcp`` ; assistant intégré, +acteur ``user``). Le **watcher** (:mod:`backend.watcher`) et +l'indexeur ne l'appellent **jamais** : le vault principal compte +plus de 220 000 fichiers, un branchement sur les événements du +système de fichiers inonderait le journal. Une modification faite +hors API (éditeur externe, synchro disque…) reste donc invisible +du journal — c'est voulu. + +Authentification : signature HMAC-SHA256 du corps brut, en-tête +``X-Draco-Signature``, clé propre à ObsiGate +(``AGENT_EVENEMENTS_CLE``, jamais en dur). Sans clé, le module est +**inactif** : aucun appel, aucun bruit. URL surchargée par +``AGENT_EVENEMENTS_URL``. + +Discipline « au mieux » (même esprit que TikTak et Kanbante) : +l'envoi part dans un fil d'arrière-plan démon, délai maximal 2 s, +et toute erreur est avalée avec une ligne de journal — une panne +du récepteur ne doit jamais casser ni ralentir le métier. + +Domaine : ObsiGate ne modélise pas encore le domaine d'un vault +(question Q-A du programme Fusion toujours ouverte). La +correspondance peut être déclarée par ``AGENT_EVENEMENTS_DOMAINES`` +(JSON ``{"NomDuVault": "famille"}``) ; repli ``pro``, qui est aussi +le défaut du journal du service. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import os +import threading +import uuid +from datetime import UTC, datetime +from urllib.parse import quote + +import httpx + +logger = logging.getLogger("obsigate.agent_journal") + +SOURCE_APP = "obsigate" +URL_DEFAUT = "https://agent.dracodev.net/api/v1/evenements" +DELAI_S = 2.0 + +TYPE_CREE = "obsigate.note.created" +TYPE_EDITE = "obsigate.note.edited" +TYPE_DEPLACE = "obsigate.note.moved" +TYPE_SUPPRIME = "obsigate.note.deleted" + +ACTEUR_USER = "user" +ACTEUR_MCP = "agent:mcp" + + +def _cle() -> str: + return os.environ.get("AGENT_EVENEMENTS_CLE", "").strip() + + +def _url() -> str: + return os.environ.get("AGENT_EVENEMENTS_URL", "").strip() or URL_DEFAUT + + +def actif() -> bool: + """L'émission n'existe que si la clé d'ObsiGate est configurée.""" + return bool(_cle()) + + +def domaine_du_vault(vault: str) -> str: + """Domaine journal du vault : correspondance déclarée dans + ``AGENT_EVENEMENTS_DOMAINES`` (JSON), repli ``pro``.""" + brut = os.environ.get("AGENT_EVENEMENTS_DOMAINES", "").strip() + if brut: + try: + correspondance = json.loads(brut) + except ValueError: + correspondance = {} + if isinstance(correspondance, dict): + domaine = correspondance.get(vault) + if isinstance(domaine, str) and domaine: + return domaine + return "pro" + + +def lien_fichier(vault: str, chemin: str) -> str: + """Lien profond ObsiGate vers un fichier (format ``#file=`` lu + par le frontend : ``#file=:``, décodé en URI).""" + return f"/#file={quote(vault, safe='')}:{quote(chemin, safe='/')}" + + +def construire_enveloppe( + type_: str, + vault: str, + chemin: str, + actor: str = ACTEUR_USER, +) -> tuple[dict, bytes]: + """L'enveloppe ACC-0.2 et son corps JSON exact (celui qui est + signé puis envoyé — la signature porte sur ces octets-là). + ``chemin`` est le chemin relatif au vault ; pour un déplacement, + c'est le **nouveau** chemin (la référence pointe sur l'objet tel + qu'il existe après l'opération).""" + env = { + "id": uuid.uuid4().hex, + "type": type_, + "occurred_at": datetime.now(UTC).isoformat(), + "actor": actor or ACTEUR_USER, + "domain": domaine_du_vault(vault), + "source_app": SOURCE_APP, + "object": { + "ref": f"fichier:{vault}/{chemin}", + "label": chemin.rsplit("/", 1)[-1] if chemin else vault, + "url": lien_fichier(vault, chemin), + }, + } + corps = json.dumps(env, ensure_ascii=False).encode("utf-8") + return env, corps + + +def _signer(corps: bytes) -> str: + return hmac.new(_cle().encode("utf-8"), corps, hashlib.sha256).hexdigest() + + +def _envoyer(corps: bytes) -> None: + """Le POST signé, dans le fil d'arrière-plan. Avale tout : le + journal distant est un confort, jamais une dépendance.""" + try: + with httpx.Client(timeout=DELAI_S, trust_env=False) as client: + client.post( + _url(), + content=corps, + headers={ + "Content-Type": "application/json", + "X-Draco-Signature": _signer(corps), + }, + ) + except Exception as exc: # panne, délai, DNS… : on log et on passe + logger.warning("événement non livré au journal du service (%s)", exc) + + +def emettre( + type_: str, + vault: str, + chemin: str, + actor: str = ACTEUR_USER, +) -> bool: + """Émet un événement en tâche de fond. Retourne True si l'envoi + a été déclenché, False si le module est inactif (pas de clé) ou + si l'enveloppe n'a pas pu être construite. Ne lève jamais.""" + if not actif(): + return False + try: + _env, corps = construire_enveloppe(type_, vault, chemin, actor) + except Exception: + logger.warning("événement %s non construit", type_, exc_info=True) + return False + threading.Thread( + target=_envoyer, args=(corps,), daemon=True, name="obsigate-evenements" + ).start() + return True diff --git a/backend/routers/files_write.py b/backend/routers/files_write.py index 1a5e265..5695952 100644 --- a/backend/routers/files_write.py +++ b/backend/routers/files_write.py @@ -15,6 +15,7 @@ from typing import Any from fastapi import APIRouter, Body, Depends, HTTPException, Query +from backend import agent_journal from backend.audit import log_file_delete, log_file_save from backend.auth.middleware import check_vault_access, require_auth from backend.history import ( @@ -133,6 +134,9 @@ async def api_file_save( client_ip = current_user.get("_request_ip", "unknown") log_file_save(current_user["username"], vault_name, path, len(content), client_ip) + # Journal du service d'agents (#220) — émission « au mieux », API seulement. + agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"]) + return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]} @@ -201,6 +205,7 @@ def api_file_xlsx_save( sum(len(str(v)) for v in cells.values()), current_user.get("_request_ip", "unknown"), ) + agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"]) return { "status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"], "revision": result.get("revision"), @@ -250,6 +255,7 @@ def api_file_csv_save( sum(len(str(v)) for v in cells.values()), current_user.get("_request_ip", "unknown"), ) + agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"]) return { "status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"], "revision": result.get("revision"), @@ -313,6 +319,7 @@ def api_file_xlsx_structure( len(actions), current_user.get("_request_ip", "unknown"), ) + agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"]) return { "status": "ok", "vault": result["vault"], "path": result["path"], "size": len(result["applied"]), "revision": result.get("revision"), @@ -381,6 +388,7 @@ def api_file_xlsx_style( len(ops), current_user.get("_request_ip", "unknown"), ) + agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"]) return { "status": "ok", "vault": result["vault"], "path": result["path"], "size": len(result["applied"]), "revision": result.get("revision"), @@ -405,6 +413,9 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R result = service_delete_file(vault_name, path) + # Journal du service d'agents (#220) — émission « au mieux », API seulement. + agent_journal.emettre(agent_journal.TYPE_SUPPRIME, vault_name, result["path"]) + # Audit log client_ip = current_user.get("_request_ip", "unknown") log_file_delete(current_user["username"], vault_name, path, client_ip) @@ -577,6 +588,9 @@ async def api_file_create( result = service_create_file(vault_name, body.path, body.content) + # Journal du service d'agents (#220) — émission « au mieux », API seulement. + agent_journal.emettre(agent_journal.TYPE_CREE, vault_name, result["path"]) + # Update index await update_single_file(vault_name, result["path"]) @@ -686,6 +700,9 @@ async def api_file_rename( old_path_str = result["old_path"] new_path_str = result["new_path"] + # Journal du service d'agents (#220) — la référence pointe sur le nouveau chemin. + agent_journal.emettre(agent_journal.TYPE_DEPLACE, vault_name, new_path_str) + # Update index await handle_file_move(vault_name, old_path_str, new_path_str) @@ -731,6 +748,11 @@ async def api_file_move( new_path_str = result["new_path"] item_type = result["item_type"] + # Journal du service d'agents (#220) — fichiers seulement (le catalogue + # est note.*) ; les déplacements de dossiers ne sont pas émis. + if item_type == "file": + agent_journal.emettre(agent_journal.TYPE_DEPLACE, vault_name, new_path_str) + # Update index if item_type == "directory": from backend.indexer import reload_single_vault diff --git a/backend/tools/service.py b/backend/tools/service.py index eae6570..f677656 100644 --- a/backend/tools/service.py +++ b/backend/tools/service.py @@ -14,6 +14,7 @@ import logging import os from typing import Any +from backend import agent_journal from backend.indexer import get_backlinks as _get_backlinks from backend.indexer import get_vault_names from backend.services.backups import diff_backup as _diff_backup @@ -58,7 +59,7 @@ from backend.services.search import advanced_search_vaults, search_vaults from backend.services.search import list_tags as _list_tags from backend.services.search import search_paths as _search_paths from backend.services.vaults import browse_directory, list_accessible_vaults, list_all_files -from backend.tools.context import ToolContext, ToolRisk +from backend.tools.context import ToolContext, ToolMode, ToolRisk from backend.tools.registry import tool from backend.tools.schemas import ( AppendToFileInput, @@ -360,6 +361,12 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]: # ── D. Mutations ─────────────────────────────────────────────────────────── +def _acteur_journal(ctx: ToolContext) -> str: + """Acteur journal (#220) : ``agent:mcp`` quand l'appel vient du + serveur MCP d'ObsiGate, ``user`` pour l'assistant intégré.""" + return agent_journal.ACTEUR_MCP if ctx.mode == ToolMode.MCP else agent_journal.ACTEUR_USER + + @tool( name="create_file", description=( @@ -374,7 +381,12 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]: ) def create_file(ctx: ToolContext, params: CreateFileInput) -> dict[str, Any]: """Create a vault file (fails if it already exists).""" - return _create_file(params.vault, params.path, params.content) + result = _create_file(params.vault, params.path, params.content) + agent_journal.emettre( + agent_journal.TYPE_CREE, params.vault, result.get("path", params.path), + actor=_acteur_journal(ctx), + ) + return result @tool( @@ -402,7 +414,12 @@ def create_directory(ctx: ToolContext, params: CreateDirectoryInput) -> dict[str ) def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]: """Replace a vault file's content.""" - return _edit_file(params.vault, params.path, params.content) + result = _edit_file(params.vault, params.path, params.content) + agent_journal.emettre( + agent_journal.TYPE_EDITE, params.vault, result.get("path", params.path), + actor=_acteur_journal(ctx), + ) + return result @tool( @@ -414,7 +431,12 @@ def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]: ) def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any]: """Append content to a vault file.""" - return _append_to_file(params.vault, params.path, params.content) + result = _append_to_file(params.vault, params.path, params.content) + chemin = result.get("path", params.path) if isinstance(result, dict) else params.path + agent_journal.emettre( + agent_journal.TYPE_EDITE, params.vault, chemin, actor=_acteur_journal(ctx), + ) + return result @tool( @@ -426,7 +448,12 @@ def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any ) def rename_file(ctx: ToolContext, params: RenameFileInput) -> dict[str, Any]: """Rename a vault file.""" - return _rename_file(params.vault, params.path, params.new_name) + result = _rename_file(params.vault, params.path, params.new_name) + agent_journal.emettre( + agent_journal.TYPE_DEPLACE, params.vault, + result.get("new_path", params.path), actor=_acteur_journal(ctx), + ) + return result @tool( @@ -450,7 +477,14 @@ def rename_directory(ctx: ToolContext, params: RenameDirectoryInput) -> dict[str ) def move_path(ctx: ToolContext, params: MovePathInput) -> dict[str, Any]: """Move a vault file or directory.""" - return _move_path(params.vault, params.source_path, params.destination_dir) + result = _move_path(params.vault, params.source_path, params.destination_dir) + # Journal (#220) : fichiers seulement — les dossiers sont hors catalogue. + if result.get("item_type") == "file": + agent_journal.emettre( + agent_journal.TYPE_DEPLACE, params.vault, + result.get("new_path", params.source_path), actor=_acteur_journal(ctx), + ) + return result @tool( @@ -497,7 +531,12 @@ def replace_in_files(ctx: ToolContext, params: ReplaceInFilesInput) -> dict[str, ) def delete_file(ctx: ToolContext, params: DeleteFileInput) -> dict[str, Any]: """Delete a vault file.""" - return _delete_file(params.vault, params.path) + result = _delete_file(params.vault, params.path) + chemin = result.get("path", params.path) if isinstance(result, dict) else params.path + agent_journal.emettre( + agent_journal.TYPE_SUPPRIME, params.vault, chemin, actor=_acteur_journal(ctx), + ) + return result @tool( diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 0939222..c0cf5f1 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.75.0" +version = "2.76.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 9e4c705..ba8f192 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.75.0" +version = "2.76.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 52391cb..e2acc08 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.75.0", + "version": "2.76.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 901698a..b37e503 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.75.0 | **Dernière mise à jour :** 2026-10-22 +> **Version :** 2.76.0 | **Dernière mise à jour :** 2026-10-22 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -30,6 +30,28 @@ ## 🔵 En cours +### 220. Fusion — émission d'événements vers le journal du service d'agents (Lot 2, vague 5) + +- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** 🔵 en cours +- **Fiche :** [features/fusion-journal-evenements-220.md](./features/fusion-journal-evenements-220.md) +- **Description :** ObsiGate émet ses mutations de notes **explicites** + vers le journal d'activité du service d'agents (contrat ACC-0.2, + carte fusion-trois-apps #18) : `obsigate.note.created / edited / + moved / deleted`, signées HMAC, en arrière-plan, inactives sans la + clé `AGENT_EVENEMENTS_CLE`. Points d'accroche : les endpoints + d'écriture de l'API web (acteur `user`) et la couche outils / + serveur MCP (acteur `agent:mcp`). **Garde-fou : jamais le + watcher** — seules les opérations explicites via l'API émettent. +- **Sous-tâches :** + - [x] Module `backend/agent_journal.py` (enveloppe ACC-0.2, + signature HMAC-SHA256, envoi « au mieux ») + - [x] Branchements `backend/routers/files_write.py` (créer, + sauvegarder, renommer, déplacer, supprimer) + - [x] Branchements `backend/tools/service.py` (create/edit/ + append/rename/move/delete, acteur selon le mode) + - [x] Tests `tests/test_agent_journal.py` — dont la preuve du + garde-fou watcher (écriture disque + indexation = silence) + ### 218. Fusion — panneau Spark intégré (agent commun) - **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.74.0, redéploiement Docker en attente) diff --git a/docs/features/fusion-journal-evenements-220.md b/docs/features/fusion-journal-evenements-220.md new file mode 100644 index 0000000..e0ab57a --- /dev/null +++ b/docs/features/fusion-journal-evenements-220.md @@ -0,0 +1,98 @@ +# #220 — Fusion : émission d'événements vers le journal du service d'agents + +**Contexte.** Le Lot 2 du programme Fusion (carte fusion-trois-apps +#18) dote le service d'agents d'un **journal d'activité** cross-apps : +chaque application y émet ses mutations significatives au contrat +**ACC-0.2** — enveloppe `{id, type, occurred_at, actor, domain, +source_app, object{ref, label, url}}` signée **HMAC-SHA256** du corps +brut (en-tête `X-Draco-Signature`, clé propre à chaque application), +que le service expose dans la vue Journal de sa console (agent-commun +0.11.0). TikTak (0.59.0) et Kanbante (0.28.0) émettent déjà ; ObsiGate +est la troisième source, après Flowdeck. + +**Principe directeur : référence, jamais contenu.** L'enveloppe ne +contient que la référence de la note (`fichier:/`), +son nom (libellé) et un lien profond (`/#file=:`) — +jamais son contenu. + +## Garde-fou central : jamais le watcher + +ObsiGate surveille les vaults par un **watcher** de système de +fichiers (`backend/watcher.py`) qui réindexe chaque changement +disque — le vault principal compte **222 393 fichiers**. Brancher +l'émission sur le watcher inonderait le journal (et confondrait les +gestes d'Obsidian, de la synchro de fichiers ou d'un éditeur externe +avec des opérations faites *dans* ObsiGate). + +L'émission n'a donc lieu que pour les **opérations explicites via +l'API web**, à deux points d'accroche, et nulle part ailleurs : + +| Source | Point d'accroche | Acteur | +|---|---|---| +| Interface web / clients REST | `backend/routers/files_write.py` | `user` | +| Serveur MCP d'ObsiGate (outils) | `backend/tools/service.py`, mode `mcp` | `agent:mcp` | +| Assistant intégré (outils, mode `in_app`) | `backend/tools/service.py` | `user` | + +Conséquences assumées : + +- une note créée/modifiée **hors API** (Obsidian, éditeur externe, + copie de fichiers) n'apparaît **pas** au journal — c'est voulu ; +- les écritures internes qui passent par la couche mutations sans + passer par ces points (planificateur, crawler, fusion de doublons, + restauration de sauvegarde) n'émettent pas non plus ; +- le test `test_watcher_et_indexation_sans_evenement` verrouille le + garde-fou : écriture disque directe + gestionnaires du watcher + + `update_single_file` de l'indexeur → **zéro émission**. + +## Catalogue d'événements émis + +| Type | Déclencheur | +|---|---| +| `obsigate.note.created` | `POST /api/file/{vault}` ; outil `create_file` | +| `obsigate.note.edited` | `PUT /api/file/{vault}/save` (+ variantes xlsx/csv : cellules, structure, style) ; outils `edit_file`, `append_to_file` | +| `obsigate.note.moved` | `PATCH /api/file/{vault}` (renommage), `POST /api/move/{vault}` (**fichiers seulement**) ; outils `rename_file`, `move_path` (fichiers seulement) — la référence pointe sur le **nouveau** chemin | +| `obsigate.note.deleted` | `DELETE /api/file/{vault}` ; outil `delete_file` | + +Hors catalogue (non émis, volontairement) : dossiers (création, +renommage, déplacement, suppression — le catalogue est `note.*`), +téléversement par lots (`batch-upload`), duplication, restauration +de sauvegarde, `replace_in_files` (multi-fichiers). + +## Module `backend/agent_journal.py` + +- **Activation** : `AGENT_EVENEMENTS_CLE` (clé propre à ObsiGate, + jamais en dur). Sans clé, le module est **inactif** : aucun appel, + aucun bruit. URL surchargée par `AGENT_EVENEMENTS_URL` (défaut + `https://agent.dracodev.net/api/v1/evenements`). +- **Discipline « au mieux »** (identique à TikTak/Kanbante) : envoi + dans un fil d'arrière-plan démon via httpx, délai maximal 2 s, + exceptions avalées avec une ligne de journal local — une panne du + service d'agents ne casse ni ne ralentit jamais le métier (prouvé + par `test_recepteur_en_panne_ne_casse_rien`). +- **Domaine** : ObsiGate ne modélise pas encore le domaine d'un + vault (la question Q-A du programme — les domaines sont-ils des + vaults distincts ? — est toujours ouverte). La correspondance peut + être déclarée par `AGENT_EVENEMENTS_DOMAINES` (JSON + `{"NomDuVault": "famille"}`) ; repli `pro`, qui est aussi le + défaut du journal du service. + +## Activation en production (gestes de Bruno) + +1. Générer une clé propre à ObsiGate (`openssl rand -hex 32`). +2. Service d'agents : ajouter l'entrée `obsigate` à + `AGENT_EVENEMENTS_CLES` (JSON des clés par application), recréer + le conteneur. +3. ObsiGate : poser `AGENT_EVENEMENTS_CLE=` dans son + environnement et **redéployer** (Docker). + +Tant que la clé n'est pas posée des deux côtés, ObsiGate n'émet +rien — c'est le comportement voulu. + +## Tests + +`tests/test_agent_journal.py` (7 tests) : enveloppe et signature +revérifiées comme le fait le service ; cycle de vie complet en REST +(créer → éditer → renommer → déplacer → supprimer, acteur `user`) ; +chemin outils/MCP (`create/edit/rename/delete`, acteur +`agent:mcp`) ; inactivité sans clé ; garde-fou watcher/indexation ; +panne du récepteur sans effet métier ; correspondance de domaines. diff --git a/package.json b/package.json index 94e44aa..2577d39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.75.0", + "version": "2.76.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/test_agent_journal.py b/tests/test_agent_journal.py new file mode 100644 index 0000000..d0db7cb --- /dev/null +++ b/tests/test_agent_journal.py @@ -0,0 +1,302 @@ +"""Émission d'événements vers le journal du service d'agents (#220, +Fusion Lot 2 vague 5 — contrat ACC-0.2). + +Un faux récepteur local capture les POST signés HMAC et les vérifie +comme le fait le service d'agents ; on prouve aussi l'inactivité sans +clé, la non-régression métier quand le récepteur est en panne, et le +garde-fou central : le watcher / l'indexation n'émettent JAMAIS +(seules les écritures explicites via l'API ou les outils le font). +""" + +import asyncio +import hashlib +import hmac +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +from types import SimpleNamespace + +import pytest + +CLE = "cle-obsigate-de-test" + + +class _Recepteur(BaseHTTPRequestHandler): + recus = [] + + def do_POST(self): + n = int(self.headers.get("Content-Length") or 0) + corps = self.rfile.read(n) + type(self).recus.append( + {"headers": {k.lower(): v for k, v in self.headers.items()}, + "corps": corps}) + self.send_response(201) + self.end_headers() + + def log_message(self, *a): + pass + + +def _demarrer(monkeypatch): + _Recepteur.recus = [] + serveur = HTTPServer(("127.0.0.1", 0), _Recepteur) + port = serveur.server_address[1] + threading.Thread(target=serveur.serve_forever, daemon=True).start() + monkeypatch.setenv("AGENT_EVENEMENTS_URL", + f"http://127.0.0.1:{port}/api/v1/evenements") + monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE) + return serveur + + +def _attendre(types, delai=6.0): + """Attend que chaque type demandé soit arrivé ; retourne + {type: enveloppe} — la signature HMAC de CHAQUE envoi est + revérifiée ici, comme le fait le service.""" + vus = {} + fin = time.time() + delai + while time.time() < fin and not all(t in vus for t in types): + for recu in list(_Recepteur.recus): + env = json.loads(recu["corps"].decode("utf-8")) + sig = recu["headers"].get("x-draco-signature", "") + attendu = hmac.new(CLE.encode(), recu["corps"], + hashlib.sha256).hexdigest() + assert hmac.compare_digest(sig.lower(), attendu), \ + "signature HMAC invalide" + vus.setdefault(env["type"], env) + if not all(t in vus for t in types): + time.sleep(0.05) + manquants = [t for t in types if t not in vus] + assert not manquants, f"événements jamais reçus : {manquants}" + return vus + + +# ── Enveloppe ──────────────────────────────────────────────────────── + + +def test_enveloppe_et_signature(monkeypatch): + from backend import agent_journal + + monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE) + env, corps = agent_journal.construire_enveloppe( + agent_journal.TYPE_CREE, "TestVault", "Journal/nouvelle.md") + assert env["source_app"] == "obsigate" + assert env["domain"] == "pro" # repli : aucun domaine modélisé (Q-A) + assert env["actor"] == "user" + assert env["object"] == { + "ref": "fichier:TestVault/Journal/nouvelle.md", + "label": "nouvelle.md", + "url": "/#file=TestVault:Journal/nouvelle.md", + } + assert json.loads(corps.decode("utf-8")) == env + assert env["id"] and env["occurred_at"] + sig = hmac.new(CLE.encode(), corps, hashlib.sha256).hexdigest() + assert len(sig) == 64 + + +def test_domaine_par_vault(monkeypatch): + from backend import agent_journal + + monkeypatch.setenv("AGENT_EVENEMENTS_DOMAINES", + json.dumps({"TestVault": "famille"})) + assert agent_journal.domaine_du_vault("TestVault") == "famille" + assert agent_journal.domaine_du_vault("AutreVault") == "pro" + + +# ── Écritures REST (acteur « user ») ───────────────────────────────── + + +def test_cycle_de_vie_rest(client, monkeypatch): + serveur = _demarrer(monkeypatch) + try: + r = client.post("/api/file/TestVault", json={ + "path": "Journal/nouvelle.md", "content": "# Nouvelle\n"}) + assert r.status_code == 200 + + r = client.put("/api/file/TestVault/save", + params={"path": "Journal/nouvelle.md"}, + json={"content": "# Nouvelle v2\n"}) + assert r.status_code == 200 + + r = client.patch("/api/file/TestVault", json={ + "path": "Journal/nouvelle.md", "new_name": "renommee.md"}) + assert r.status_code == 200 + + r = client.post("/api/move/TestVault", json={ + "source_path": "Journal/renommee.md", "destination_dir": "Projets"}) + assert r.status_code == 200 + + r = client.delete("/api/file/TestVault", + params={"path": "Projets/renommee.md"}) + assert r.status_code == 200 + + vus = _attendre([ + "obsigate.note.created", "obsigate.note.edited", + "obsigate.note.moved", "obsigate.note.deleted"]) + + env = vus["obsigate.note.created"] + assert env["actor"] == "user" + assert env["object"]["ref"] == "fichier:TestVault/Journal/nouvelle.md" + assert env["object"]["label"] == "nouvelle.md" + assert env["object"]["url"] == "/#file=TestVault:Journal/nouvelle.md" + + env = vus["obsigate.note.edited"] + assert env["object"]["ref"] == "fichier:TestVault/Journal/nouvelle.md" + + # moved : émis deux fois (renommage puis déplacement). Les + # envois partent en fils d'arrière-plan — l'ordre d'arrivée + # n'est pas garanti, on compare l'ensemble des références. + refs_deplacees = { + json.loads(recu["corps"].decode("utf-8"))["object"]["ref"] + for recu in _Recepteur.recus + if json.loads(recu["corps"].decode("utf-8"))["type"] + == "obsigate.note.moved"} + assert refs_deplacees == { + "fichier:TestVault/Journal/renommee.md", + "fichier:TestVault/Projets/renommee.md"} + + env = vus["obsigate.note.deleted"] + assert env["object"]["ref"] == "fichier:TestVault/Projets/renommee.md" + finally: + serveur.shutdown() + + +# ── Écritures par les outils / MCP (acteur « agent:mcp ») ─────────── + + +@pytest.fixture +def vault_outils(tmp_path, monkeypatch): + """Vault minimal pour la couche outils (patron test_document_tools).""" + import backend.indexer + + vault_dir = tmp_path / "Vault" + vault_dir.mkdir() + monkeypatch.setitem( + backend.indexer.index, "Vault", + {"name": "Vault", "path": str(vault_dir), "config": {}}) + return vault_dir + + +def test_outils_mcp_emettent_agent_mcp(vault_outils, monkeypatch): + from backend.tools.api import ToolContext, ToolMode, call_tool + + serveur = _demarrer(monkeypatch) + try: + ctx = ToolContext( + user={"username": "tester", "role": "admin", "vaults": ["*"]}, + mode=ToolMode.MCP, audit_enabled=False) + + out = call_tool("create_file", ctx, { + "vault": "Vault", "path": "par-mcp.md", "content": "# MCP\n", + }, confirm=True) + assert out.ok + + out = call_tool("edit_file", ctx, { + "vault": "Vault", "path": "par-mcp.md", "content": "# MCP v2\n", + }, confirm=True) + assert out.ok + + out = call_tool("rename_file", ctx, { + "vault": "Vault", "path": "par-mcp.md", "new_name": "renomme-mcp.md", + }, confirm=True) + assert out.ok + + out = call_tool("delete_file", ctx, { + "vault": "Vault", "path": "renomme-mcp.md", + }, confirm=True) + assert out.ok + + vus = _attendre([ + "obsigate.note.created", "obsigate.note.edited", + "obsigate.note.moved", "obsigate.note.deleted"]) + for env in vus.values(): + assert env["actor"] == "agent:mcp" + assert vus["obsigate.note.created"]["object"]["ref"] == \ + "fichier:Vault/par-mcp.md" + assert vus["obsigate.note.moved"]["object"]["ref"] == \ + "fichier:Vault/renomme-mcp.md" + finally: + serveur.shutdown() + + +# ── Inactivité sans clé ────────────────────────────────────────────── + + +def test_inactif_sans_cle(client, monkeypatch): + serveur = _demarrer(monkeypatch) + monkeypatch.delenv("AGENT_EVENEMENTS_CLE") + try: + from backend import agent_journal + + assert agent_journal.actif() is False + assert agent_journal.emettre( + agent_journal.TYPE_CREE, "TestVault", "x.md") is False + r = client.post("/api/file/TestVault", json={ + "path": "sans-journal.md", "content": "# Rien\n"}) + assert r.status_code == 200 + time.sleep(0.6) + assert _Recepteur.recus == [] + finally: + serveur.shutdown() + + +# ── Garde-fou : watcher et indexation n'émettent jamais ───────────── + + +def test_watcher_et_indexation_sans_evenement(client, test_vault_dir, + monkeypatch): + """Une écriture disque externe (éditeur, synchro de fichiers…) + traitée par le watcher puis l'indexeur ne produit AUCUN + événement : seuls les appels explicites à l'API émettent.""" + from pathlib import Path + + appels = [] + monkeypatch.setattr( + "backend.agent_journal.emettre", + lambda *a, **k: appels.append((a, k)) or False) + monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE) + + cible = Path(test_vault_dir) / "externe.md" + cible.write_text("# Écrit hors API\n", encoding="utf-8") + + # Chemin watcher : les gestionnaires d'événements filesystem. + from backend.watcher import VaultEventHandler + + boucle = asyncio.new_event_loop() + try: + file_attente: asyncio.Queue = asyncio.Queue() + handler = VaultEventHandler("TestVault", file_attente, boucle) + evt = SimpleNamespace(is_directory=False, src_path=str(cible), + dest_path=None) + handler.on_created(evt) + handler.on_modified(evt) + + # Chemin indexation : la même fonction que le watcher appelle + # après debounce pour intégrer le fichier à l'index. + from backend.indexer import update_single_file + + boucle.run_until_complete(update_single_file("TestVault", "externe.md")) + finally: + boucle.close() + + assert appels == [] + + +# ── Panne du récepteur : le métier n'est jamais affecté ───────────── + + +def test_recepteur_en_panne_ne_casse_rien(client, monkeypatch): + # Port fermé : connexion refusée dans le fil d'arrière-plan. + monkeypatch.setenv("AGENT_EVENEMENTS_URL", + "http://127.0.0.1:59999/api/v1/evenements") + monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE) + r = client.post("/api/file/TestVault", json={ + "path": "malgre-panne.md", "content": "# OK\n"}) + assert r.status_code == 200 + r = client.put("/api/file/TestVault/save", + params={"path": "malgre-panne.md"}, + json={"content": "# OK v2\n"}) + assert r.status_code == 200 + r = client.get("/api/file/TestVault", params={"path": "malgre-panne.md"}) + assert r.status_code == 200 + assert "OK v2" in r.json()["html"] # le GET renvoie le Markdown rendu