From e3f61de4ad927e63f8bed5d7081e893e093832f2 Mon Sep 17 00:00:00 2001 From: Dom Date: Wed, 1 Jul 2026 12:36:47 +0200 Subject: [PATCH 1/5] fix(agent): embarquer httpx>=0.27 dans le build embed (orchestrateur Lea muet en clinique) Co-Authored-By: Claude Opus 4.8 (1M context) --- agent_v0/agent_v1/requirements.txt | 1 + deploy/build_package_full.sh | 1 + deploy/installer/README.md | 21 +++++++++++++++++---- deploy/installer/build_installer.sh | 1 + deploy/installer/configure_embed.ps1 | 2 +- deploy/lea_package/requirements_agent.txt | 1 + 6 files changed, 22 insertions(+), 5 deletions(-) diff --git a/agent_v0/agent_v1/requirements.txt b/agent_v0/agent_v1/requirements.txt index e1a07c190..2067ac802 100644 --- a/agent_v0/agent_v1/requirements.txt +++ b/agent_v0/agent_v1/requirements.txt @@ -3,6 +3,7 @@ mss>=9.0.1 # Capture d'écran haute performance pynput>=1.7.7 # Clavier/Souris Cross-plateforme Pillow>=10.0.0 # Crops et processing image requests>=2.31.0 # Streaming réseau +httpx>=0.27 # Client HTTP orchestrateur Léa (POST /api/learn/start) — brique conversationnelle python-socketio[client]>=5.10,<6.0 # Bus feedback Léa 'lea:*' (compat Flask-SocketIO 5.3.x serveur) psutil>=5.9.0 # Monitoring CPU/RAM screeninfo>=0.8 # QW1 — détection des monitors physiques + offsets diff --git a/deploy/build_package_full.sh b/deploy/build_package_full.sh index 6947926bd..2c5ecc581 100755 --- a/deploy/build_package_full.sh +++ b/deploy/build_package_full.sh @@ -208,6 +208,7 @@ REQUIRED=( "Lea/python-embed/Lib/site-packages/mss" "Lea/python-embed/Lib/site-packages/win32" "Lea/python-embed/Lib/site-packages/socketio" + "Lea/python-embed/Lib/site-packages/httpx" ) MISSING=() for f in "${REQUIRED[@]}"; do diff --git a/deploy/installer/README.md b/deploy/installer/README.md index db5cf39d3..5ba0348b1 100644 --- a/deploy/installer/README.md +++ b/deploy/installer/README.md @@ -81,16 +81,29 @@ cd deploy/installer wget https://www.python.org/ftp/python/3.12.8/python-3.12.8-embed-amd64.zip mkdir python-3.12-embed unzip python-3.12.8-embed-amd64.zip -d python-3.12-embed/ + +# IMPORTANT : l'embed doit contenir TOUTES les dependances HORS LIGNE. +# Le runtime client ne fait AUCUN pip/reseau (POC clinique). On installe donc +# les dependances une fois dans l'embed, puis on le commit/reutilise tel quel : +python312._pth # decommenter 'import site' +python -m pip install --target python-3.12-embed/Lib/site-packages \ + -r ../lea_package/requirements_agent.txt +# => doit inclure httpx (+ httpcore, h11) pour l'orchestrateur Lea (POST /api/learn/start). ``` Le staging copie automatiquement ce dossier si present. Le composant "pythonembed" devient alors selectionnable dans l'installeur. -Le script `configure_embed.ps1` : +Le script `configure_embed.ps1` (execute a l'installation, sur le poste) : 1. Patche `python312._pth` pour activer `import site` -2. Installe `pip` via `get-pip.py` -3. Installe `requirements_agent.txt` -4. Reecrit `Lea.bat` pour pointer sur `python-embed\pythonw.exe` +2. VERIFIE que les dependances sont deja embarquees (offline, aucun pip/reseau) — + `socketio, tkinter, mss, pynput, pystray, plyer, requests, httpx, PIL, win32api` ; + si une dependance manque, l'installation echoue explicitement. +3. Reecrit `Lea.bat` pour pointer sur `python-embed\pythonw.exe` + +> Note : `build_installer.sh` et `build_package_full.sh` valident aussi la presence +> des paquets (dont `httpx`, `httpcore`, `h11`) dans `Lib/site-packages/` avant de +> produire le paquet — un embed incomplet interrompt le build cote Linux. ## Installation silencieuse (deploiement de masse) diff --git a/deploy/installer/build_installer.sh b/deploy/installer/build_installer.sh index 7507e8e0a..e82bff38e 100755 --- a/deploy/installer/build_installer.sh +++ b/deploy/installer/build_installer.sh @@ -154,6 +154,7 @@ REQUIRED_EMBED=( "Lib/site-packages/pystray" "Lib/site-packages/plyer" "Lib/site-packages/requests" "Lib/site-packages/PIL" "Lib/site-packages/win32" + "Lib/site-packages/httpx" "Lib/site-packages/httpcore" "Lib/site-packages/h11" ) MISSING_EMBED=() for f in "${REQUIRED_EMBED[@]}"; do diff --git a/deploy/installer/configure_embed.ps1 b/deploy/installer/configure_embed.ps1 index fffd916ef..5f6b2b6dd 100644 --- a/deploy/installer/configure_embed.ps1 +++ b/deploy/installer/configure_embed.ps1 @@ -44,7 +44,7 @@ if ($PthFile) { # L'embed DOIT contenir toutes les dependances runtime. # AUCUN pip, AUCUN reseau : si une dependance manque -> echec explicite. # --------------------------------------------------------------- -$RequiredModules = @('socketio','tkinter','mss','pynput','pystray','plyer','requests','PIL','win32api') +$RequiredModules = @('socketio','tkinter','mss','pynput','pystray','plyer','requests','httpx','PIL','win32api') $Missing = @() foreach ($m in $RequiredModules) { & $PythonExe -c "import $m" 2>$null diff --git a/deploy/lea_package/requirements_agent.txt b/deploy/lea_package/requirements_agent.txt index 5a1beefbd..ccad751b7 100644 --- a/deploy/lea_package/requirements_agent.txt +++ b/deploy/lea_package/requirements_agent.txt @@ -5,6 +5,7 @@ mss>=9.0.1 # Capture d'ecran haute performance pynput>=1.7.7 # Clavier/Souris Pillow>=10.0.0 # Traitement image (crops, compression) requests>=2.31.0 # Communication serveur +httpx>=0.27 # Client HTTP orchestrateur Lea (POST /api/learn/start) - brique conversationnelle psutil>=5.9.0 # Monitoring CPU/RAM pystray>=0.19.5 # Icone systray plyer>=2.1.0 # Notifications toast natives From 144a5c288a3bfabb4207045b56aef00a692676e1 Mon Sep 17 00:00:00 2001 From: Dom Date: Wed, 1 Jul 2026 12:36:47 +0200 Subject: [PATCH 2/5] fix(agent): capture JPEG+downscale (allege CPU/disque, frequence intacte) + robustesse chemin _background/shots Co-Authored-By: Claude Opus 4.8 (1M context) --- agent_v0/agent_v1/vision/capture_io.py | 110 ++++++ agent_v0/agent_v1/vision/capturer.py | 47 ++- tests/unit/test_capturer_capture_io_format.py | 320 ++++++++++++++++++ 3 files changed, 465 insertions(+), 12 deletions(-) create mode 100644 agent_v0/agent_v1/vision/capture_io.py create mode 100644 tests/unit/test_capturer_capture_io_format.py diff --git a/agent_v0/agent_v1/vision/capture_io.py b/agent_v0/agent_v1/vision/capture_io.py new file mode 100644 index 000000000..089e52ff9 --- /dev/null +++ b/agent_v0/agent_v1/vision/capture_io.py @@ -0,0 +1,110 @@ +"""Politique de sauvegarde des captures — réduction du poids disque. + +Constat : tous les shots étaient sauvés en PNG plein écran lossless +(``img.save(path, "PNG", quality=...)`` — PNG ignore ``quality``), d'où +~90 Go pour 13 sessions. La majorité de ce poids n'a aucune valeur de +grounding (full + full_blurred en doublon, heartbeats plein écran). + +Cette politique distingue le **type** de shot et écrit le format adapté : + +- ``crop`` → PNG lossless. C'est la cible de grounding qwen3-vl ; on + préserve chaque pixel (perte JPEG = bruit sur de petites icônes). Le crop + fait 80×80 → poids négligeable, aucun intérêt à le dégrader. +- ``full`` / ``window`` / ``context`` → JPEG ``quality=SCREENSHOT_QUALITY, + optimize=True``. Ce sont des vues contextuelles / humaines : la + compression JPEG (~5-10x) est sans impact fonctionnel. +- ``heartbeat`` → JPEG **downscalé** (largeur max ``HEARTBEAT_MAX_WIDTH``, + ratio préservé). C'est de la *liveness* (le serveur vérifie juste qu'un + écran a changé), pas du grounding → la pleine résolution est du gaspillage. + +``save_capture`` retourne le chemin RÉELLEMENT écrit, extension ajustée selon +le format. L'appelant doit utiliser ce retour (et non un chemin ``.png`` +présumé) pour streamer / référencer le bon fichier. + +⚠️ Contrat avec le serveur : l'extension du crop NE DOIT PAS changer (le +serveur retrouve le crop par basename via ``vision_info.crop`` — voir +``stream_processor._extract_crop_b64`` stratégie 1). C'est pourquoi ``crop`` +reste PNG. Les full/window/context/heartbeat sont retrouvés par +``screenshot_id`` avec extension ``.png`` hardcodée côté serveur, mais le +serveur réécrit toujours l'upload sous ``{shot_id}.png`` (le suffixe envoyé +sur le fil est ignoré) → changer l'extension LOCALE de ces types est sûr. +""" + +from __future__ import annotations + +import os +from typing import Iterable + +from PIL import Image + +from ..config import SCREENSHOT_QUALITY + +# Types sauvés en JPEG (vue contextuelle / humaine, pas de grounding pixel). +_JPEG_KINDS: frozenset = frozenset({"full", "window", "context"}) + +# Largeur max d'un heartbeat downscalé. 1280 px suffit largement pour de la +# liveness (détecter qu'un écran a changé) ; on divise le poids d'un 2560 px +# par ~4 (surface) avant compression JPEG. +HEARTBEAT_MAX_WIDTH = 1280 + + +def _ensure_jpeg_ready(img: Image.Image) -> Image.Image: + """Convertit en RGB si nécessaire (JPEG ne supporte ni alpha ni palette).""" + if img.mode in ("RGBA", "LA", "P"): + return img.convert("RGB") + return img + + +def _downscale_to_width(img: Image.Image, max_width: int) -> Image.Image: + """Réduit l'image à ``max_width`` en préservant le ratio (no-op si plus petite).""" + if img.width <= max_width: + return img + new_height = max(1, round(img.height * max_width / img.width)) + return img.resize((max_width, new_height), Image.LANCZOS) + + +def save_capture(img: Image.Image, path_base: str, kind: str) -> str: + """Sauve ``img`` selon la politique du ``kind`` et retourne le chemin écrit. + + Args: + img: image PIL à sauvegarder. + path_base: chemin SANS extension (ex. + ``.../shots/shot_0001_full``). L'extension finale (``.png`` ou + ``.jpg``) est ajoutée par la politique. + kind: type de shot — ``"crop"`` | ``"full"`` | ``"window"`` | + ``"context"`` | ``"heartbeat"``. + + Returns: + Le chemin RÉELLEMENT écrit, avec la bonne extension. + + Raises: + ValueError: si ``kind`` n'est pas reconnu (fail-closed : on refuse + d'écrire un fichier dont la politique est indéterminée). + """ + if kind == "crop": + out_path = f"{path_base}.png" + img.save(out_path, "PNG") + return out_path + + if kind in _JPEG_KINDS: + out_path = f"{path_base}.jpg" + _ensure_jpeg_ready(img).save( + out_path, "JPEG", quality=SCREENSHOT_QUALITY, optimize=True + ) + return out_path + + if kind == "heartbeat": + out_path = f"{path_base}.jpg" + small = _downscale_to_width(_ensure_jpeg_ready(img), HEARTBEAT_MAX_WIDTH) + small.save(out_path, "JPEG", quality=SCREENSHOT_QUALITY) + return out_path + + raise ValueError( + f"kind de capture inconnu : {kind!r} " + f"(attendu: crop, full, window, context, heartbeat)" + ) + + +def known_kinds() -> Iterable[str]: + """Retourne les ``kind`` supportés (utile pour la validation appelant).""" + return ("crop", *sorted(_JPEG_KINDS), "heartbeat") diff --git a/agent_v0/agent_v1/vision/capturer.py b/agent_v0/agent_v1/vision/capturer.py index b7abeaa2a..afb3a8b80 100644 --- a/agent_v0/agent_v1/vision/capturer.py +++ b/agent_v0/agent_v1/vision/capturer.py @@ -18,8 +18,9 @@ import platform from typing import Any, Dict, List, Optional, Tuple from PIL import Image, ImageFilter, ImageStat import mss -from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY, BLUR_SENSITIVE +from ..config import TARGETED_CROP_SIZE, BLUR_SENSITIVE from .blur_sensitive import blur_sensitive_regions +from .capture_io import save_capture logger = logging.getLogger(__name__) @@ -425,6 +426,18 @@ class VisionCapturer: # On ne crée plus self.sct ici car mss n'est pas thread-safe sous Windows self.last_img_hash = None + def _ensure_shots_dir(self) -> None: + """Garantit l'existence de `shots/` avant toute écriture. + + Le dossier est créé dans `__init__`, mais l'auto-cleanup de + `SessionStorage` (`shutil.rmtree` par âge/taille) peut supprimer tout + le dossier de session — y compris la session permanente `_background`. + Sans ce garde, la capture suivante lève `[Errno 2] No such file or + directory` (bug observé poste Émilie). On recrée donc le répertoire + cible juste avant chaque sauvegarde. + """ + os.makedirs(self.shots_dir, exist_ok=True) + def capture_full_context(self, name_suffix: str, force=False) -> str: """ Capture l'écran complet. @@ -460,9 +473,15 @@ class VisionCapturer: if BLUR_SENSITIVE: blur_sensitive_regions(img) - path = os.path.join(self.shots_dir, f"context_{int(time.time())}_{name_suffix}.png") - img.save(path, "PNG", quality=SCREENSHOT_QUALITY) - return path + # Politique d'écriture : les heartbeats sont de la liveness pure + # (le serveur vérifie juste qu'un écran a changé) → JPEG downscalé. + # Les autres contextes (focus_change, result_of_*) → JPEG q85. + kind = "heartbeat" if "heartbeat" in name_suffix else "context" + self._ensure_shots_dir() + path_base = os.path.join( + self.shots_dir, f"context_{int(time.time())}_{name_suffix}" + ) + return save_capture(img, path_base, kind) except Exception as e: logger.error(f"Erreur Context Capture: {e}") return "" @@ -506,10 +525,10 @@ class VisionCapturer: return result return {} - full_path = os.path.join(self.shots_dir, f"{screenshot_id}_full.png") + full_base = os.path.join(self.shots_dir, f"{screenshot_id}_full") # Capture du Crop (Cœur de l'apprentissage qwen3-vl) - crop_path = os.path.join(self.shots_dir, f"{screenshot_id}_crop.png") + crop_base = os.path.join(self.shots_dir, f"{screenshot_id}_crop") w, h = TARGETED_CROP_SIZE left = max(0, x - w // 2) top = max(0, y - h // 2) @@ -523,8 +542,11 @@ class VisionCapturer: blur_sensitive_regions(img) blur_sensitive_regions(crop_img) - img.save(full_path, "PNG", quality=SCREENSHOT_QUALITY) - crop_img.save(crop_path, "PNG", quality=SCREENSHOT_QUALITY) + # Politique d'écriture : full = vue contextuelle → JPEG q85 ; + # crop = cible de grounding qwen3-vl → PNG lossless (contrat serveur). + self._ensure_shots_dir() + full_path = save_capture(img, full_base, "full") + crop_path = save_capture(crop_img, crop_base, "crop") # Mise à jour du hash pour le prochain heartbeat self.last_img_hash = self._compute_quick_hash(img) @@ -648,11 +670,12 @@ class VisionCapturer: if BLUR_SENSITIVE: blur_sensitive_regions(window_img) - # Sauvegarde - window_path = os.path.join( - self.shots_dir, f"{screenshot_id}_window.png" + # Sauvegarde — fenêtre = vue contextuelle → JPEG q85 (politique). + self._ensure_shots_dir() + window_base = os.path.join( + self.shots_dir, f"{screenshot_id}_window" ) - window_img.save(window_path, "PNG", quality=SCREENSHOT_QUALITY) + window_path = save_capture(window_img, window_base, "window") result = { "window_image": window_path, diff --git a/tests/unit/test_capturer_capture_io_format.py b/tests/unit/test_capturer_capture_io_format.py new file mode 100644 index 000000000..64bcf6a75 --- /dev/null +++ b/tests/unit/test_capturer_capture_io_format.py @@ -0,0 +1,320 @@ +"""Politique de format des captures + robustesse du répertoire shots. + +Deux corrections testées ici (agent_v0/agent_v1/vision) : + +1. FORMAT (allègement) : `capturer.py` doit déléguer l'écriture à + `capture_io.save_capture`, qui applique la politique : + - crop → PNG lossless (cible de grounding qwen3-vl) + - full/window/context → JPEG q85 + - heartbeat → JPEG downscalé (largeur max ~1280) + Aujourd'hui tout était sauvé en `img.save(path, "PNG", quality=...)` + (le `quality` est ignoré par PNG → PNG lossless plein écran, ~90 Go). + +2. BUG chemin (poste Émilie) : ``[Errno 2] No such file or directory: + ..._background/shots/context...``. Le répertoire `shots/` est créé une + seule fois dans `__init__`, mais l'auto-cleanup (`SessionStorage`, + `shutil.rmtree`) peut supprimer tout le dossier de session `_background`. + Les sauvegardes suivantes doivent recréer le répertoire cible + (`os.makedirs(dir, exist_ok=True)`) avant chaque écriture. + +Tests 100% mockés : aucune vraie capture écran (mss est patché). +""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from PIL import Image + + +# --------------------------------------------------------------------------- +# Helpers (repris du style de test_capturer_monitor_guard.py) +# --------------------------------------------------------------------------- + + +def _make_mock_mss(monitors): + """Mock `mss.mss()` renvoyant un monitor sain unique (image grise unie).""" + + def factory(): + instance = MagicMock() + instance.monitors = monitors + grab_result = MagicMock() + m = monitors[1] if len(monitors) > 1 else monitors[0] + w, h = m["width"], m["height"] + grab_result.size = (w, h) + grab_result.bgra = b"\x80\x80\x80\x00" * (w * h) + instance.grab = MagicMock(return_value=grab_result) + cm = MagicMock() + cm.__enter__ = MagicMock(return_value=instance) + cm.__exit__ = MagicMock(return_value=False) + return cm + + return factory + + +_NORMAL_MONITORS = [ + {"left": 0, "top": 0, "width": 800, "height": 600}, # composite + {"left": 0, "top": 0, "width": 800, "height": 600}, # primaire sain +] + + +def _vision_capturer(tmp_path): + from agent_v0.agent_v1.vision.capturer import VisionCapturer + + return VisionCapturer(str(tmp_path)) + + +def _patch_mss(): + """Contexte : mss patché + time.sleep no-op + pas de floutage. + + Le floutage est désactivé pour isoler la politique d'écriture (le blur + ouvre/modifie l'image mais n'impacte pas le format de sortie ; on le coupe + pour rester déterministe). + """ + return ( + patch( + "agent_v0.agent_v1.vision.capturer.mss.mss", + side_effect=_make_mock_mss(_NORMAL_MONITORS), + ), + patch("agent_v0.agent_v1.vision.capturer.time.sleep"), + patch("agent_v0.agent_v1.vision.capturer.BLUR_SENSITIVE", False), + ) + + +# =========================================================================== +# PARTIE A — Politique save_capture (unité capture_io) +# =========================================================================== + + +def test_save_capture_crop_stays_png(tmp_path: Path): + from agent_v0.agent_v1.vision import capture_io + + img = Image.new("RGB", (80, 80), (10, 20, 30)) + out = capture_io.save_capture(img, str(tmp_path / "shot_crop"), "crop") + + assert out.endswith(".png"), f"crop doit rester PNG, got {out!r}" + assert Path(out).exists() + with Image.open(out) as reopened: + assert reopened.format == "PNG" + + +@pytest.mark.parametrize("kind", ["full", "window", "context"]) +def test_save_capture_context_kinds_are_jpeg(tmp_path: Path, kind: str): + from agent_v0.agent_v1.vision import capture_io + + img = Image.new("RGB", (640, 480), (120, 130, 140)) + out = capture_io.save_capture(img, str(tmp_path / f"shot_{kind}"), kind) + + assert out.endswith(".jpg"), f"{kind} doit être JPEG, got {out!r}" + assert Path(out).exists() + with Image.open(out) as reopened: + assert reopened.format == "JPEG" + + +def test_save_capture_heartbeat_is_downscaled_jpeg(tmp_path: Path): + from agent_v0.agent_v1.vision import capture_io + + # Image large (2560) → doit être réduite à HEARTBEAT_MAX_WIDTH. + img = Image.new("RGB", (2560, 1440), (50, 60, 70)) + out = capture_io.save_capture(img, str(tmp_path / "hb"), "heartbeat") + + assert out.endswith(".jpg") + with Image.open(out) as reopened: + assert reopened.format == "JPEG" + assert reopened.width == capture_io.HEARTBEAT_MAX_WIDTH, ( + f"heartbeat doit être downscalé à {capture_io.HEARTBEAT_MAX_WIDTH}, " + f"got {reopened.width}" + ) + # ratio préservé (1440 * 1280/2560 = 720) + assert reopened.height == 720 + + +def test_save_capture_heartbeat_smaller_than_max_is_not_upscaled(tmp_path: Path): + from agent_v0.agent_v1.vision import capture_io + + img = Image.new("RGB", (640, 360), (1, 2, 3)) + out = capture_io.save_capture(img, str(tmp_path / "hb_small"), "heartbeat") + with Image.open(out) as reopened: + assert reopened.width == 640, "no-op si déjà plus petit que le max" + + +def test_save_capture_heartbeat_downscale_reduces_pixel_count(tmp_path: Path): + """Preuve de l'allègement heartbeat par la mesure objective du code : + le downscale réduit le nombre de pixels (2560×1440 → 1280×720 = /4 surface). + On mesure la géométrie de sortie (déterministe), pas le poids d'un JPEG + synthétique (qui dépend de libjpeg et n'est pas représentatif d'un vrai + écran).""" + from agent_v0.agent_v1.vision import capture_io + + src = Image.new("RGB", (2560, 1440)) + out = capture_io.save_capture(src, str(tmp_path / "hb_measure"), "heartbeat") + with Image.open(out) as small: + src_pixels = src.width * src.height + out_pixels = small.width * small.height + assert out_pixels < src_pixels / 3, ( + f"Le downscale heartbeat doit diviser la surface par ~4 " + f"({src_pixels} → {out_pixels})" + ) + + +def test_save_capture_rejects_unknown_kind(tmp_path: Path): + from agent_v0.agent_v1.vision import capture_io + + img = Image.new("RGB", (10, 10)) + with pytest.raises(ValueError): + capture_io.save_capture(img, str(tmp_path / "x"), "bogus") + + +# =========================================================================== +# PARTIE B — Câblage dans capturer.py (format des sorties runtime) +# =========================================================================== + + +def test_capture_full_context_writes_jpeg(tmp_path: Path): + """capture_full_context (context / focus_change / result_of_*) → JPEG.""" + p1, p2, p3 = _patch_mss() + with p1, p2, p3: + cap = _vision_capturer(tmp_path) + out = cap.capture_full_context("focus_change", force=True) + + assert out, "capture attendue" + assert out.endswith(".jpg"), f"context doit être JPEG, got {out!r}" + assert Path(out).exists() + with Image.open(out) as im: + assert im.format == "JPEG" + + +def test_capture_full_context_heartbeat_is_jpeg(tmp_path: Path): + """Un suffixe 'heartbeat' doit produire un JPEG (downscalé côté politique).""" + p1, p2, p3 = _patch_mss() + with p1, p2, p3: + cap = _vision_capturer(tmp_path) + out = cap.capture_full_context("heartbeat", force=True) + + assert out.endswith(".jpg"), f"heartbeat doit être JPEG, got {out!r}" + with Image.open(out) as im: + assert im.format == "JPEG" + + +def test_capture_dual_full_is_jpeg_crop_is_png(tmp_path: Path): + """capture_dual : full/window en JPEG, crop en PNG (contrat serveur).""" + p1, p2, p3 = _patch_mss() + with p1, p2, p3, patch( + # Neutraliser la capture fenêtre (dépend d'API OS) pour isoler full+crop + "agent_v0.agent_v1.vision.capturer.VisionCapturer.capture_active_window", + return_value=None, + ): + cap = _vision_capturer(tmp_path) + result = cap.capture_dual(x=100, y=200, screenshot_id="shot42") + + assert "full" in result and "crop" in result + assert result["full"].endswith(".jpg"), f"full doit être JPEG, got {result['full']!r}" + assert result["crop"].endswith(".png"), f"crop doit rester PNG, got {result['crop']!r}" + assert Path(result["full"]).exists() + assert Path(result["crop"]).exists() + with Image.open(result["full"]) as im: + assert im.format == "JPEG" + with Image.open(result["crop"]) as im: + assert im.format == "PNG" + + +def test_capture_active_window_writes_jpeg(tmp_path: Path): + """La fenêtre active est une vue contextuelle → JPEG.""" + p1, p2, p3 = _patch_mss() + fake_rect = { + "rect": [100, 100, 500, 400], + "size": [400, 300], + "title": "Bloc-notes", + "app_name": "notepad.exe", + } + full_img = Image.new("RGB", (800, 600), (90, 90, 90)) + with p1, p2, p3, patch( + "agent_v0.agent_v1.window_info_crossplatform.get_active_window_rect", + return_value=fake_rect, + ): + cap = _vision_capturer(tmp_path) + result = cap.capture_active_window( + x=200, y=200, screenshot_id="shotW", full_img=full_img + ) + + assert result is not None + assert result["window_image"].endswith(".jpg"), ( + f"window doit être JPEG, got {result['window_image']!r}" + ) + with Image.open(result["window_image"]) as im: + assert im.format == "JPEG" + + +# =========================================================================== +# PARTIE C — BUG chemin : shots/ recréé si supprimé par l'auto-cleanup +# =========================================================================== + + +def test_capture_full_context_recreates_shots_dir_after_rmtree(tmp_path: Path): + """Reproduction du bug poste Émilie. + + L'auto-cleanup (`SessionStorage.shutil.rmtree`) supprime tout le dossier + de session `_background` (donc `shots/`). Une capture ultérieure ne doit + PAS lever `[Errno 2] No such file or directory` : le répertoire cible + doit être recréé avant l'écriture. + """ + p1, p2, p3 = _patch_mss() + with p1, p2, p3: + cap = _vision_capturer(tmp_path) + + # Simule l'auto-cleanup : la session entière est purgée après ACK. + shutil.rmtree(cap.shots_dir) + assert not Path(cap.shots_dir).exists() + + out = cap.capture_full_context("context_after_purge", force=True) + + assert out, "La capture doit réussir même après purge du dossier shots" + assert Path(out).exists(), "Le fichier doit être physiquement écrit" + assert Path(cap.shots_dir).exists(), "shots/ doit avoir été recréé" + + +def test_capture_dual_recreates_shots_dir_after_rmtree(tmp_path: Path): + """capture_dual doit aussi survivre à la purge du dossier shots.""" + p1, p2, p3 = _patch_mss() + with p1, p2, p3, patch( + "agent_v0.agent_v1.vision.capturer.VisionCapturer.capture_active_window", + return_value=None, + ): + cap = _vision_capturer(tmp_path) + shutil.rmtree(cap.shots_dir) + + result = cap.capture_dual(x=50, y=60, screenshot_id="shot_purge") + + assert result.get("full") and result.get("crop"), ( + "capture_dual doit produire full+crop même après purge" + ) + assert Path(result["full"]).exists() + assert Path(result["crop"]).exists() + + +def test_capture_active_window_recreates_shots_dir_after_rmtree(tmp_path: Path): + """capture_active_window (crop fenêtre depuis full fourni) survit à la purge.""" + p1, p2, p3 = _patch_mss() + fake_rect = { + "rect": [10, 10, 210, 210], + "size": [200, 200], + "title": "W", + "app_name": "w.exe", + } + full_img = Image.new("RGB", (400, 400), (70, 70, 70)) + with p1, p2, p3, patch( + "agent_v0.agent_v1.window_info_crossplatform.get_active_window_rect", + return_value=fake_rect, + ): + cap = _vision_capturer(tmp_path) + shutil.rmtree(cap.shots_dir) + + result = cap.capture_active_window( + x=50, y=50, screenshot_id="shotW_purge", full_img=full_img + ) + + assert result is not None, "capture fenêtre doit réussir après purge" + assert Path(result["window_image"]).exists() From 9ab5ed46716314d9f7c58b8bdb211c4e9f6aeadb Mon Sep 17 00:00:00 2001 From: Dom Date: Wed, 1 Jul 2026 12:36:47 +0200 Subject: [PATCH 3/5] fix(agent): resilience disparition Lea en RDP/Citrix (watchdog session interactive re-affiche le tray) Co-Authored-By: Claude Opus 4.8 (1M context) --- agent_v0/agent_v1/main.py | 85 ++++--- agent_v0/agent_v1/ui/session_watchdog.py | 197 +++++++++++++++ agent_v0/agent_v1/ui/smart_tray.py | 64 ++++- tests/unit/test_agent_v1_session_watchdog.py | 248 +++++++++++++++++++ 4 files changed, 545 insertions(+), 49 deletions(-) create mode 100644 agent_v0/agent_v1/ui/session_watchdog.py create mode 100644 tests/unit/test_agent_v1_session_watchdog.py diff --git a/agent_v0/agent_v1/main.py b/agent_v0/agent_v1/main.py index 6a9a5c415..e299f0381 100644 --- a/agent_v0/agent_v1/main.py +++ b/agent_v0/agent_v1/main.py @@ -607,29 +607,20 @@ class AgentV1: def run(self): self.ui.run() -def _headless_keepalive(agent): - """Maintient le main thread vivant quand l'UI tray ne peut pas tourner. +def _install_signal_handlers(agent, watchdog) -> None: + """Installe SIGTERM/SIGINT/SIGBREAK pour un arrêt propre du main thread. - Sans cela, ``agent.run()`` retourne immédiatement (pystray échoue quand - Léa est lancée via SSH sans session interactive Windows), le main thread - se termine, et TOUS les daemon threads — y compris ``_replay_poll_loop`` - — meurent avec lui. Observé 3 fois en 24h les 24/05 : - - SSH ``Permission denied`` (1231) - - polls morts après relance distante (1620) - - polls morts ``replay_sess_506d6fa2`` (1627) - - Le keepalive ne se déclenche QUE si ``agent.run()`` est sorti tout en - laissant ``agent.running=True`` (cas anormal). En mode interactif - normal, ``pystray.Icon.run()`` ne sort jamais, donc ce code est - invisible. + Met ``agent.running=False`` (les daemon threads s'arrêtent) et réveille + le watchdog (qui sort de sa boucle de surveillance). Sans session + interactive (pystray.Icon.stop indisponible), c'est le SEUL moyen + d'arrêter Léa proprement : ``kill -TERM `` ou Ctrl+C. """ import signal as _sig - _stop = threading.Event() def _handler(sig, frame): logger.info(f"[MAIN] Signal {sig} recu — arret propre") - _stop.set() agent.running = False + watchdog.stop() for sig_name in ("SIGTERM", "SIGINT", "SIGBREAK"): sig_obj = getattr(_sig, sig_name, None) @@ -640,33 +631,49 @@ def _headless_keepalive(agent): except (ValueError, OSError): pass - logger.info( - "[MAIN] Keepalive headless actif — main thread bloque pour maintenir " - "les daemon threads (_replay_poll_loop, heartbeat, capture_server) vivants. " - "Pour stopper Lea : kill -TERM ou Ctrl+C." - ) - try: - _stop.wait() - except KeyboardInterrupt: - pass - agent.running = False - logger.info("[MAIN] Keepalive termine — agent.running=False, daemon threads vont s'arreter") + +def _agent_should_live(agent) -> bool: + """Vrai tant que Léa doit vivre : agent actif ET pas de Quitter explicite. + + Un « Quitter » utilisateur (``ui._quit_requested``) doit stopper le + watchdog pour de bon ; une simple déconnexion RDP ne met JAMAIS ce flag + → le tray revient tout seul à la reconnexion. + """ + if not getattr(agent, "running", False): + return False + ui = getattr(agent, "ui", None) + if ui is not None and getattr(ui, "_quit_requested", False): + return False + return True def main(): - agent = AgentV1() - try: - agent.run() - except Exception: - logger.exception("[MAIN] agent.run() a leve une exception") + from .ui.session_watchdog import InteractiveSessionWatchdog - if getattr(agent, "running", False): - logger.warning( - "[MAIN] agent.run() est sorti mais agent.running=True — " - "probablement pystray sans session interactive (SSH). " - "Bascule en keepalive headless." - ) - _headless_keepalive(agent) + agent = AgentV1() + + # Résilience RDP/Citrix : au lieu de bloquer le main thread pour toujours + # quand pystray sort (session interactive perdue), on surveille la + # session et on ré-affiche le tray + le chat à chaque reconnexion. + # agent.run() (== agent.ui.run()) est ré-entrant : les threads de fond + # ne démarrent qu'une fois, seule l'icône est recréée. Les daemon threads + # de capture/heartbeat/replay tournent contre agent.running et restent + # uniques — le watchdog n'y touche pas. + watchdog = InteractiveSessionWatchdog( + run_ui=agent.run, + is_running=lambda: _agent_should_live(agent), + ) + _install_signal_handlers(agent, watchdog) + + try: + watchdog.run() + except KeyboardInterrupt: + logger.info("[MAIN] Interruption clavier — arret propre") + except Exception: + logger.exception("[MAIN] Le watchdog de session a leve une exception") + finally: + agent.running = False + logger.info("[MAIN] Sortie — agent.running=False, daemon threads vont s'arreter") if __name__ == "__main__": diff --git a/agent_v0/agent_v1/ui/session_watchdog.py b/agent_v0/agent_v1/ui/session_watchdog.py new file mode 100644 index 000000000..f1f09e3a9 --- /dev/null +++ b/agent_v0/agent_v1/ui/session_watchdog.py @@ -0,0 +1,197 @@ +# agent_v1/ui/session_watchdog.py +"""Watchdog de session interactive Windows — résilience RDP/Citrix. + +Problème résolu (preuve poste clinique Émilie, 01/07) : + 09:46:28 [MAIN] agent.run() est sorti mais agent.running=True — probablement + pystray sans session interactive (SSH) + 09:46:28 [MAIN] Keepalive headless actif — main thread bloque... + +Sur les postes cliniques (tous RDP/Citrix), la session interactive +disparaît quand l'utilisateur se déconnecte / la session bascule en +verrouillage. `pystray.Icon.run()` sort alors immédiatement (plus de +bureau interactif `WinSta0\\Default` pour recevoir les entrées et afficher +l'icône). L'ancien `_headless_keepalive` bloquait le main thread *pour +toujours* : l'icône tray + la fenêtre chat DISPARAISSAIENT et ne +revenaient JAMAIS, même après reconnexion RDP. Les soignants croyaient +que Léa avait planté (la capture continuait pourtant en fond). + +Solution : un watchdog qui surveille la disponibilité du bureau +interactif via `OpenInputDesktop()` (signal Win32 canonique — échoue quand +la session est déconnectée/verrouillée, réussit à la reconnexion) et +(re)lance l'UI tray dès qu'une session redevient disponible. Les threads +de fond (heartbeat, replay poll, capture_server) NE SONT JAMAIS touchés : +ils tournent contre `agent.running` et restent uniques. On ne relance +JAMAIS un second `AgentV1` — seulement la couche UI (tray + chat). + +État de l'art (recherche 01/07) : + - `OpenInputDesktop()` échoue (ERROR_ACCESS_DENIED / ERROR_INVALID_...) + quand le processus n'est pas rattaché au windowstation interactif + `WinSta0` — c'est exactement le cas quand la session RDP est + déconnectée. C'est la méthode fiable recommandée (comparer les + *noms* de bureau via GetUserObjectInformation n'apporte rien de plus + ici : on a juste besoin d'un booléen « input desktop dispo ? »). + - `WTSGetActiveConsoleSessionId` renvoie une pseudo-session même sans + login → PAS fiable pour ce besoin. + - `pystray.Icon.run()` ne sort jamais en session interactive normale ; + il sort immédiatement sinon → c'est notre signal de « session perdue ». +""" + +from __future__ import annotations + +import logging +import platform +import threading +from typing import Callable, Optional + +logger = logging.getLogger(__name__) + +# Intervalle de sondage du bureau interactif (secondes). +# 3s = compromis : réactif à la reconnexion sans marteler l'API Win32. +POLL_INTERVAL_S = 3.0 + + +def is_interactive_desktop_available() -> bool: + """Retourne True si un bureau interactif Windows est disponible. + + Utilise `OpenInputDesktop()` : succès => le windowstation interactif + (`WinSta0\\Default`) est accessible et peut afficher un tray. Échec => + session RDP/Citrix déconnectée ou verrouillée sans bureau d'entrée. + + Hors Windows (Linux/dev/tests) : renvoie toujours True (pas de notion + de bureau interactif verrouillable ici — on laisse l'UI tourner). + Toute erreur d'appel Win32 est traitée comme « indisponible » (prudent) + SAUF l'indisponibilité de l'API elle-même (pywin32 absent) → True pour + ne pas priver un poste de son tray à cause d'une dépendance manquante. + """ + if platform.system() != "Windows": + return True + + try: + import win32con # type: ignore + import win32service # type: ignore + except Exception: + # pywin32 indisponible : on ne peut pas sonder → on suppose dispo + # (comportement historique : tenter l'UI plutôt que la bloquer). + logger.debug("pywin32 indisponible — sondage bureau interactif ignoré") + return True + + hdesk = None + try: + # DESKTOP_SWITCHDESKTOP (0x0100) = droit minimal, aligné sur l'usage + # documenté pour tester la présence du bureau d'entrée. + hdesk = win32service.OpenInputDesktop(0, False, win32con.DESKTOP_SWITCHDESKTOP) + return hdesk is not None + except Exception: + # OpenInputDesktop lève quand aucun bureau d'entrée n'est accessible + # (session déconnectée / verrouillée). C'est le cas « indisponible ». + return False + finally: + if hdesk is not None: + try: + # PyHANDLE se ferme via .Close() (pywin32) ; fallback silencieux. + hdesk.Close() + except Exception: + pass + + +class InteractiveSessionWatchdog: + """Surveille la session interactive et (re)lance l'UI tray à la reconnexion. + + Ne détient AUCUN état de capture. Sa seule responsabilité : garantir + qu'il existe au plus UN tray vivant à la fois, et le ressusciter quand + une session interactive redevient disponible. Les daemon threads de + l'agent (heartbeat/replay/capture) sont indépendants et intacts. + + Paramètres : + run_ui : callable bloquant qui lance le tray (typiquement + ``agent.ui.run`` / ``agent.run``). Retourne quand le + tray sort (normal en fin de session interactive). + is_running : callable -> bool ; True tant que l'agent doit vivre + (typiquement ``lambda: agent.running``). + is_available : callable -> bool de détection de session (injectable + pour les tests). Défaut = is_interactive_desktop_available. + poll_interval_s : période de sondage quand la session est absente. + """ + + def __init__( + self, + run_ui: Callable[[], None], + is_running: Callable[[], bool], + is_available: Optional[Callable[[], bool]] = None, + poll_interval_s: float = POLL_INTERVAL_S, + ) -> None: + self._run_ui = run_ui + self._is_running = is_running + self._is_available = is_available or is_interactive_desktop_available + self._poll_interval_s = poll_interval_s + self._wake = threading.Event() + # Sérialise le lancement de l'UI : jamais deux trays en parallèle. + self._ui_lock = threading.Lock() + + def stop(self) -> None: + """Réveille le watchdog pour qu'il réévalue ``is_running`` et sorte.""" + self._wake.set() + + def _run_ui_once(self) -> None: + """Lance l'UI tray une fois (bloquant) sous verrou, avec garde d'erreur. + + Le verrou empêche formellement qu'un second appel démarre un tray + alors qu'un premier tourne encore (invariant « un seul tray »). + """ + with self._ui_lock: + try: + self._run_ui() + except Exception: + # Un crash du tray ne doit jamais tuer le watchdog : on log et + # on laisse la boucle décider (retry ou sortie selon is_running). + logger.exception("[WATCHDOG] Le tray UI a levé une exception") + + def run(self) -> None: + """Boucle principale (bloque le main thread à la place du keepalive). + + Cycle : + 1. Attendre qu'un bureau interactif soit disponible. + 2. (Re)lancer le tray — bloque jusqu'à sa sortie (déconnexion RDP). + 3. Recommencer tant que ``is_running`` est vrai. + + Ne consomme pas de CPU en boucle serrée : sonde toutes les + ``poll_interval_s`` via un Event interruptible (réveil immédiat au stop). + """ + logger.info( + "[WATCHDOG] Surveillance session interactive active " + "(re-affichage auto du tray + chat à la reconnexion RDP/Citrix)." + ) + first_cycle = True + + while self._is_running(): + if not self._is_available(): + # Session absente : sonder périodiquement sans brûler le CPU. + if first_cycle: + logger.warning( + "[WATCHDOG] Aucune session interactive — Léa reste active " + "en fond (capture/heartbeat), tray masqué. En attente de " + "reconnexion RDP/Citrix pour ré-afficher l'interface." + ) + # Event.wait renvoie True si stop() a été appelé → on sort. + if self._wake.wait(timeout=self._poll_interval_s): + break + first_cycle = False + continue + + # Session disponible : (re)lancer le tray. + if not first_cycle: + logger.info( + "[WATCHDOG] Session interactive détectée — ré-affichage du " + "tray et de la fenêtre chat de Léa." + ) + first_cycle = False + + # Bloque jusqu'à la sortie du tray (fin de session interactive). + self._run_ui_once() + + # Le tray est sorti. Si l'agent doit vivre, on reboucle (le + # prochain tour re-sondera la session et re-affichera le tray). + if not self._is_running(): + break + + logger.info("[WATCHDOG] Arrêt de la surveillance de session interactive.") diff --git a/agent_v0/agent_v1/ui/smart_tray.py b/agent_v0/agent_v1/ui/smart_tray.py index 4e356ceff..6d844cc52 100644 --- a/agent_v0/agent_v1/ui/smart_tray.py +++ b/agent_v0/agent_v1/ui/smart_tray.py @@ -137,6 +137,15 @@ class SmartTrayV1: self._state_lock = threading.Lock() self._stop_event = threading.Event() + # Résilience RDP/Citrix : run() peut être rappelé plusieurs fois par le + # watchdog de session (ré-affichage du tray à la reconnexion). Les + # threads de fond (connexion, cache workflows, hotkey) et l'accueil ne + # doivent démarrer QU'UNE fois — sinon on duplique les threads. + self._bg_started = False + # Signalé quand l'utilisateur a demandé Quitter : le watchdog ne doit + # alors PAS relancer le tray. + self._quit_requested = False + # Notifications self._notifier = NotificationManager() @@ -709,6 +718,11 @@ class SmartTrayV1: """Arrete proprement l'agent et quitte.""" logger.info("Arret demande par l'utilisateur") + # Marquer l'arret volontaire : le watchdog de session ne doit PAS + # relancer le tray après un Quitter explicite (à distinguer d'une + # simple déconnexion RDP où le tray doit revenir tout seul). + self._quit_requested = True + # Arreter la session si en cours if self.is_recording: self.on_stop() @@ -885,17 +899,24 @@ class SmartTrayV1: # ------------------------------------------------------------------ def run(self) -> None: - """Demarre le tray, les threads de fond, et entre dans la boucle principale.""" - # Notification d'accueil — divulgation IA (Article 50, Reglement IA) - self._notifier.greet() + """Demarre (ou ré-affiche) le tray et entre dans la boucle pystray. - # Enregistrer le hotkey global Ctrl+Shift+L (toggle chat) - self._start_hotkey() + Ré-entrant : le watchdog de session (session_watchdog.py) rappelle + cette méthode à chaque reconnexion RDP/Citrix pour ré-afficher le + tray + la fenêtre chat. Les initialisations one-shot (accueil, + hotkey, threads de fond connexion/cache) sont protégées par + ``_bg_started`` pour ne PAS dupliquer les threads. Seule l'icône + pystray est recréée à chaque appel (l'ancienne est morte avec la + session précédente). + """ + self._start_background_once() # Tooltip avec identifiant machine pour le multi-machine tray_title = f"Agent V1 - {self.machine_id}" - # Menu statique — reconstruit via _update_icon() quand l'état change + # Menu statique — reconstruit via _update_icon() quand l'état change. + # Nouvelle icône à chaque (ré)affichage : l'objet pystray précédent + # est invalide une fois sa boucle sortie (session interactive perdue). self.icon = pystray.Icon( "AgentV1", self._current_icon(), @@ -903,6 +924,33 @@ class SmartTrayV1: menu=pystray.Menu(*self._get_menu_items()), ) + # Rafraîchir les workflows au (ré)affichage — utile après reconnexion. + if self._bg_started and self.server_client is not None: + threading.Thread(target=self._fetch_workflows, daemon=True).start() + + # Boucle principale pystray (bloquante). Sort quand la session + # interactive disparaît (RDP déconnecté) OU sur _on_quit → le + # watchdog décide alors de relancer ou non. + logger.info("SmartTrayV1 demarre — entree dans la boucle pystray") + self.icon.run() + + def _start_background_once(self) -> None: + """Initialisations one-shot : accueil, hotkey, threads de fond. + + Idempotent : les appels suivants (ré-affichage tray) sont des no-op. + Garantit qu'on n'accumule pas de threads connexion/cache à chaque + reconnexion RDP. + """ + if self._bg_started: + return + self._bg_started = True + + # Notification d'accueil — divulgation IA (Article 50, Reglement IA) + self._notifier.greet() + + # Enregistrer le hotkey global Ctrl+Shift+L (toggle chat) + self._start_hotkey() + # Demarrer le thread de verification connexion if self.server_client is not None: conn_thread = threading.Thread( @@ -924,7 +972,3 @@ class SmartTrayV1: threading.Thread( target=self._fetch_workflows, daemon=True ).start() - - # Boucle principale pystray (bloquante) - logger.info("SmartTrayV1 demarre — entree dans la boucle pystray") - self.icon.run() diff --git a/tests/unit/test_agent_v1_session_watchdog.py b/tests/unit/test_agent_v1_session_watchdog.py new file mode 100644 index 000000000..989b1918e --- /dev/null +++ b/tests/unit/test_agent_v1_session_watchdog.py @@ -0,0 +1,248 @@ +"""Tests du watchdog de session interactive (résilience RDP/Citrix). + +Vérifie que : + - Le tray est ré-affiché à la reconnexion RDP (run_ui rappelé). + - Un seul tray tourne à la fois (invariant « un seul tray »). + - Les threads de fond de l'agent (heartbeat/replay) ne sont JAMAIS + relancés par le watchdog (il ne relance QUE l'UI). + - Un Quitter explicite arrête le watchdog (pas de résurrection du tray). + - Le détecteur de session Windows tombe en marche (True) hors Windows. + +Aucune vraie UI : run_ui et is_available sont des callables mockés. +""" + +import sys +import threading +import time +from unittest.mock import MagicMock + +# Mocker les libs GUI/Win32 avant tout import du module sous test. +sys.modules.setdefault("pynput", MagicMock()) +sys.modules.setdefault("pynput.mouse", MagicMock()) +sys.modules.setdefault("pynput.keyboard", MagicMock()) +sys.modules.setdefault("pystray", MagicMock()) + +from agent_v0.agent_v1.ui.session_watchdog import ( # noqa: E402 + InteractiveSessionWatchdog, + is_interactive_desktop_available, +) + + +# --------------------------------------------------------------------------- +# Détection de session +# --------------------------------------------------------------------------- + +def test_detection_hors_windows_renvoie_true(monkeypatch): + """Hors Windows (dev/tests Linux) : bureau toujours 'disponible'.""" + monkeypatch.setattr( + "agent_v0.agent_v1.ui.session_watchdog.platform.system", + lambda: "Linux", + ) + assert is_interactive_desktop_available() is True + + +# --------------------------------------------------------------------------- +# Boucle du watchdog +# --------------------------------------------------------------------------- + +def test_relance_ui_a_la_reconnexion(): + """Session absente puis présente => le tray est (ré)affiché une fois dispo. + + Scénario : la 1re sonde dit 'indisponible' (RDP déconnecté), la 2e dit + 'disponible' (reconnexion) => run_ui doit être appelé exactement une fois, + puis le watchdog s'arrête. + """ + availability = iter([False, True]) + run_ui_calls = [] + + def _run_ui(): + run_ui_calls.append(time.time()) + + # L'agent vit jusqu'à ce que le tray ait été affiché une fois. + state = {"alive": True} + + def _is_running(): + # Après le premier affichage du tray, l'agent s'arrête. + return state["alive"] and len(run_ui_calls) == 0 + + def _is_available(): + return next(availability) + + wd = InteractiveSessionWatchdog( + run_ui=_run_ui, + is_running=_is_running, + is_available=_is_available, + poll_interval_s=0.01, # sonde très rapide pour le test + ) + wd.run() + + # Le tray a été (ré)affiché exactement une fois après la reconnexion. + assert len(run_ui_calls) == 1 + + +def test_reaffichage_apres_chaque_deconnexion(): + """Deux cycles connexion→déconnexion => tray relancé à chaque reconnexion.""" + run_ui_calls = [] + + # is_available toujours True ; le tray 'sort' immédiatement (déconnexion). + def _run_ui(): + run_ui_calls.append(1) + + def _is_running(): + # Vivre pour 2 affichages de tray, puis arrêter. + return len(run_ui_calls) < 2 + + wd = InteractiveSessionWatchdog( + run_ui=_run_ui, + is_running=_is_running, + is_available=lambda: True, + poll_interval_s=0.01, + ) + wd.run() + + assert len(run_ui_calls) == 2 + + +def test_un_seul_tray_a_la_fois(): + """L'invariant 'un seul tray' : run_ui n'est jamais réentrant en parallèle.""" + concurrent = {"count": 0, "max": 0} + lock = threading.Lock() + + def _run_ui(): + with lock: + concurrent["count"] += 1 + concurrent["max"] = max(concurrent["max"], concurrent["count"]) + time.sleep(0.02) # simule un tray qui tourne un peu + with lock: + concurrent["count"] -= 1 + + calls = {"n": 0} + + def _is_running(): + calls["n"] += 1 + return calls["n"] <= 3 # 3 cycles de tray + + wd = InteractiveSessionWatchdog( + run_ui=_run_ui, + is_running=_is_running, + is_available=lambda: True, + poll_interval_s=0.01, + ) + wd.run() + + # Jamais deux trays simultanés. + assert concurrent["max"] == 1 + + +def test_stop_reveille_le_watchdog_en_attente(): + """stop() sort immédiatement la boucle quand la session est absente.""" + run_ui_calls = [] + + wd = InteractiveSessionWatchdog( + run_ui=lambda: run_ui_calls.append(1), + is_running=lambda: True, + is_available=lambda: False, # jamais de session => reste en attente + poll_interval_s=60, # long : seul stop() peut débloquer + ) + + t = threading.Thread(target=wd.run) + t.start() + time.sleep(0.05) # laisser entrer dans l'attente + wd.stop() + t.join(timeout=2) + + assert not t.is_alive() # le watchdog est bien sorti + assert run_ui_calls == [] # aucun tray (jamais de session dispo) + + +def test_crash_du_tray_ne_tue_pas_le_watchdog(): + """Une exception dans run_ui est absorbée ; le watchdog reste maître.""" + calls = {"n": 0} + + def _run_ui(): + calls["n"] += 1 + raise RuntimeError("tray HS") + + def _is_running(): + return calls["n"] < 2 # tolérer 2 crashs puis sortir + + wd = InteractiveSessionWatchdog( + run_ui=_run_ui, + is_running=_is_running, + is_available=lambda: True, + poll_interval_s=0.01, + ) + # Ne doit PAS lever : le crash est loggé, pas propagé. + wd.run() + + assert calls["n"] == 2 + + +# --------------------------------------------------------------------------- +# Intégration avec main._agent_should_live (Quitter vs déconnexion) +# --------------------------------------------------------------------------- + +def test_tray_run_reentrant_ne_relance_pas_les_threads_de_fond(monkeypatch): + """SmartTrayV1.run() appelé 2x (reconnexion RDP) : threads de fond 1x seulement. + + On vérifie que `_start_background_once` est idempotent : les threads + connexion/cache et l'accueil ne démarrent qu'au premier affichage, mais + une nouvelle icône pystray est recréée à chaque appel (ré-affichage). + """ + import threading as _threading + from agent_v0.agent_v1.ui import smart_tray as smart_tray_mod + + tray = smart_tray_mod.SmartTrayV1.__new__(smart_tray_mod.SmartTrayV1) + tray._bg_started = False + tray.machine_id = "poste_test" + tray.server_client = None # pas de threads réseau => simplifie + tray.icon = None + + greet_calls = {"n": 0} + hotkey_calls = {"n": 0} + icons_created = {"n": 0} + + tray._notifier = MagicMock() + tray._notifier.greet.side_effect = lambda: greet_calls.__setitem__("n", greet_calls["n"] + 1) + monkeypatch.setattr(tray, "_start_hotkey", lambda: hotkey_calls.__setitem__("n", hotkey_calls["n"] + 1)) + monkeypatch.setattr(tray, "_current_icon", lambda: object()) + monkeypatch.setattr(tray, "_get_menu_items", lambda: []) + + # Icône pystray factice : run() ne bloque pas (simule une sortie immédiate). + class _FakeIcon: + def __init__(self, *a, **k): + icons_created["n"] += 1 + + def run(self): + return None + + monkeypatch.setattr(smart_tray_mod.pystray, "Icon", _FakeIcon) + monkeypatch.setattr(smart_tray_mod.pystray, "Menu", lambda *a, **k: None) + + # Deux affichages successifs (déconnexion → reconnexion). + tray.run() + tray.run() + + # Accueil + hotkey : une seule fois (one-shot). + assert greet_calls["n"] == 1 + assert hotkey_calls["n"] == 1 + # Mais une nouvelle icône à chaque affichage (le tray revient bien). + assert icons_created["n"] == 2 + + +def test_agent_should_live_distingue_quit_et_deconnexion(): + """Quitter explicite arrête le watchdog ; une déconnexion RDP non.""" + from types import SimpleNamespace + from agent_v0.agent_v1.main import _agent_should_live + + # Agent actif, tray sans quit demandé => doit vivre (déconnexion RDP OK). + agent = SimpleNamespace(running=True, ui=SimpleNamespace(_quit_requested=False)) + assert _agent_should_live(agent) is True + + # Quitter explicite => ne doit plus vivre (pas de résurrection). + agent.ui._quit_requested = True + assert _agent_should_live(agent) is False + + # agent.running=False => ne vit plus (arrêt global). + agent2 = SimpleNamespace(running=False, ui=SimpleNamespace(_quit_requested=False)) + assert _agent_should_live(agent2) is False From 61664c9a3629f11a9ac32e4b26953330438b64ca Mon Sep 17 00:00:00 2001 From: Dom Date: Wed, 1 Jul 2026 12:36:48 +0200 Subject: [PATCH 4/5] feat(update): scaffold MAJ silencieuse + canary par machine (DETTE-022, gated OFF, swap encore stub) Co-Authored-By: Claude Opus 4.8 (1M context) --- agent_v0/agent_v1/config.py | 10 + agent_v0/agent_v1/main.py | 71 +++++++ agent_v0/agent_v1/network/updater.py | 119 +++++++++++ agent_v0/server_v1/api_stream.py | 32 ++- agent_v0/server_v1/update_policy.py | 139 +++++++++++++ deploy/lea_package/config.txt | 9 + ...ESIGN_MAJ_SILENCIEUSE_CANARY_2026-07-01.md | 193 ++++++++++++++++++ .../integration/test_update_check_endpoint.py | 40 ++++ tests/unit/test_agent_v1_updater.py | 111 ++++++++++ tests/unit/test_update_policy_canary.py | 162 +++++++++++++++ 10 files changed, 879 insertions(+), 7 deletions(-) create mode 100644 agent_v0/server_v1/update_policy.py create mode 100644 docs/DESIGN_MAJ_SILENCIEUSE_CANARY_2026-07-01.md create mode 100644 tests/unit/test_update_policy_canary.py diff --git a/agent_v0/agent_v1/config.py b/agent_v0/agent_v1/config.py index 4d6070f5f..0c50b01e5 100644 --- a/agent_v0/agent_v1/config.py +++ b/agent_v0/agent_v1/config.py @@ -103,6 +103,16 @@ LOG_SHIP_INTERVAL_S = float(os.environ.get("RPA_LOG_SHIP_INTERVAL_S", "30")) AUTO_UPDATE_ENABLED = os.environ.get("RPA_AUTO_UPDATE_ENABLED", "false").lower() in ( "true", "1", "yes", "on", ) +# Intervalle entre deux interrogations serveur pour une MAJ (secondes). +# Défaut 1 h : une MAJ n'est jamais urgente ; on interroge peu pour ne pas +# charger le réseau clinique. Le check ne fait de toute façon aucun swap. +AUTO_UPDATE_INTERVAL_S = float(os.environ.get("RPA_AUTO_UPDATE_INTERVAL_S", "3600")) +# Dossier de STAGING des ZIP d'update (jamais les fichiers vivants). Équivalent +# de `Lea_next\\`. Sous LOCALAPPDATA en prod Windows, sinon à côté de l'agent. +AUTO_UPDATE_STAGING_DIR = os.environ.get( + "RPA_AUTO_UPDATE_STAGING_DIR", + str(BASE_DIR / "_update_staging"), +) # Monitoring PERF_MONITOR_INTERVAL_S = 30 diff --git a/agent_v0/agent_v1/main.py b/agent_v0/agent_v1/main.py index 6a9a5c415..848e8def3 100644 --- a/agent_v0/agent_v1/main.py +++ b/agent_v0/agent_v1/main.py @@ -18,6 +18,7 @@ from .config import ( SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS, LOG_FILE, SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, API_TOKEN, MAX_SESSION_DURATION_S, STREAMING_ENDPOINT, LOG_SHIP_ENABLED, LOG_SHIP_INTERVAL_S, + AUTO_UPDATE_ENABLED, AUTO_UPDATE_INTERVAL_S, AUTO_UPDATE_STAGING_DIR, ) from .core.captor import EventCaptorV1 from .core.executor import ActionExecutorV1 @@ -158,6 +159,15 @@ class AgentV1: threading.Thread(target=self._replay_poll_loop, daemon=True).start() threading.Thread(target=self._background_heartbeat_loop, daemon=True).start() + # DETTE-022 v2 : MAJ silencieuse — boucle de check GATED (défaut OFF). + # Interroge le serveur (canary-aware) et télécharge en STAGING ; le swap + # réel reste réservé révision humaine (updater.apply_update = stub no-op). + # Activable poste par poste via RPA_AUTO_UPDATE_ENABLED, sans rebuild. + if AUTO_UPDATE_ENABLED: + threading.Thread( + target=self._auto_update_loop, daemon=True, name="lea-auto-update" + ).start() + # Mini-serveur HTTP pour captures a la demande (port 5006) self._capture_server = CaptureServer() self._capture_server.start() @@ -441,6 +451,67 @@ class AgentV1: logger.debug(f"[HEARTBEAT] Erreur: {e}") time.sleep(5) + def _auto_update_loop(self): + """DETTE-022 v2 — boucle de MAJ silencieuse GATED (défaut OFF). + + Interroge périodiquement le serveur (endpoint canary-aware), et si une + MAJ est proposée pour CE poste, la télécharge dans le STAGING après + vérif SHA256. Le swap réel N'EST PAS fait ici : `updater.run_update_cycle` + s'arrête au staging (apply_update = stub réservé révision humaine + swap + hors-process par Lea.bat au prochain démarrage). + + SÉCURITÉ — « au bon moment » : on NE stage PAS pendant un enregistrement + ou un replay actif (self.session_id / self._replay_active), pour ne pas + perturber le travail utilisateur ni consommer du réseau au mauvais + moment. Best-effort : aucune exception ne remonte (ne casse jamais Léa). + """ + try: + from .network.updater import run_update_cycle + except Exception as e: + logger.warning("[UPDATE] Module updater indisponible : %s", e) + return + + logger.info( + "[UPDATE] Boucle MAJ silencieuse démarrée (intervalle=%.0fs, " + "version=%s) — check seul, swap réservé révision humaine", + AUTO_UPDATE_INTERVAL_S, AGENT_VERSION, + ) + + while self.running: + # Découpe l'attente pour réagir vite à l'arrêt. + waited = 0.0 + step = 1.0 + while self.running and waited < AUTO_UPDATE_INTERVAL_S: + time.sleep(step) + waited += step + if not self.running: + break + + # « Au bon moment » : jamais en plein travail (enregistrement/replay). + if self.session_id or getattr(self, "_replay_active", False): + logger.debug("[UPDATE] Report du check (session/replay active)") + continue + + try: + result = run_update_cycle( + local_version=AGENT_VERSION, + machine_id=self.machine_id, + staging_dir=AUTO_UPDATE_STAGING_DIR, + ) + status = result.get("status") + if status == "staged": + logger.info( + "[UPDATE] MAJ %s téléchargée en staging (SHA256=%s) — " + "swap réservé révision humaine, non appliqué", + result.get("target_version"), + result.get("sha256_verified"), + ) + elif status not in ("up_to_date", "disabled"): + logger.debug("[UPDATE] Cycle: %s", result) + except Exception as e: + # run_update_cycle est déjà best-effort ; double filet ici. + logger.debug("[UPDATE] Erreur boucle MAJ : %s", e) + def stop_session(self): # Sauvegarder le session_id avant de l'annuler (pour les logs) ended_session_id = self.session_id diff --git a/agent_v0/agent_v1/network/updater.py b/agent_v0/agent_v1/network/updater.py index eac9a78ac..80af1c63d 100644 --- a/agent_v0/agent_v1/network/updater.py +++ b/agent_v0/agent_v1/network/updater.py @@ -238,6 +238,125 @@ def download_update( } +# --------------------------------------------------------------------------- +# Interrogation serveur — checker INJECTABLE (GET /agents/update/check) +# --------------------------------------------------------------------------- + +def _default_update_checker(local_version: str, machine_id: str): + """Interroge le serveur : y a-t-il une MAJ ? (best-effort, INJECTABLE). + + GET SERVER_URL/agents/update/check?current_version=..&machine_id=.. + (endpoint gated côté serveur — 503 si RPA_AUTO_UPDATE_SERVER_ENABLED OFF, + auquel cas on renvoie None : pas de MAJ). Bearer si présent. Pattern aligné + sur `log_shipper._default_sender`. INJECTABLE : remplacé par un fake en test. + + Returns: + Le dict réponse serveur (`should_update` sait le lire), ou None si + indisponible / gated / erreur (jamais d'exception ne remonte). + """ + try: + import requests # import tardif + + headers = {} + try: + from ..config import SERVER_URL, API_TOKEN + + base = SERVER_URL + if API_TOKEN: + headers["Authorization"] = f"Bearer {API_TOKEN}" + except Exception: + base = "" + url = f"{base}/agents/update/check" + resp = requests.get( + url, + params={"current_version": local_version, "machine_id": machine_id}, + headers=headers, + timeout=10, + allow_redirects=False, + ) + # 503 = endpoint gated OFF côté serveur → pas de MAJ (silencieux). + if resp.status_code == 503: + return None + if not resp.ok: + logger.debug("update/check HTTP %s", resp.status_code) + return None + return resp.json() + except Exception as e: + logger.debug("update/check indisponible : %s", e) + return None + + +# --------------------------------------------------------------------------- +# Orchestrateur GATED — check → décide → download (staging) → stub apply +# --------------------------------------------------------------------------- + +def run_update_cycle( + local_version: str, + machine_id: str, + staging_dir, + checker: Optional[Callable[[str, str], object]] = None, + downloader: Optional[Callable[[str], bytes]] = None, +) -> dict: + """Un cycle complet de MAJ silencieuse — GATED, best-effort, SANS swap. + + Enchaîne : + 1. GATE `auto_update_enabled()` (RPA_AUTO_UPDATE_ENABLED, défaut OFF) — + si OFF, ne fait STRICTEMENT rien (aucun appel réseau). + 2. `checker(local_version, machine_id)` → réponse serveur (canary-aware). + 3. `should_update(...)` → plan (double garde semver, jamais de downgrade). + 4. `download_update(...)` → ZIP dans le STAGING + vérif SHA256. Ne touche + JAMAIS les fichiers vivants. + 5. Le swap réel N'EST PAS FAIT ici : `apply_update` reste un stub no-op + (réservé révision humaine + Lea.bat hors-process). Le résultat porte + `applied: False`. + + Jamais d'exception ne remonte (ne doit JAMAIS casser Léa). Retourne un dict + d'état pour le diagnostic / le log : + status ∈ {disabled, check_failed, up_to_date, download_failed, staged} + + Args: + checker : callable `(local_version, machine_id) -> dict|None` + INJECTABLE (défaut = HTTP réel vers l'endpoint gated). + downloader : callable `(url) -> bytes` INJECTABLE (défaut = HTTP réel). + """ + if not auto_update_enabled(): + return {"status": "disabled", "applied": False} + + chk = checker if checker is not None else _default_update_checker + + try: + server_response = chk(local_version, machine_id) + except Exception as e: + logger.warning("update check a levé : %s", e) + return {"status": "check_failed", "applied": False, "error": str(e)} + + plan = should_update(local_version, server_response) + if plan is None: + return {"status": "up_to_date", "applied": False} + + staged = download_update(plan, staging_dir, downloader=downloader) + if not staged.get("ok"): + return { + "status": "download_failed", + "applied": False, + "error": staged.get("error"), + } + + # ⚠️ Le swap réel est réservé révision humaine : on APPELLE le stub (no-op) + # pour matérialiser le point d'extension, mais rien n'est écrasé/redémarré. + applied = apply_update(staged) + + return { + "status": "staged", + "applied": bool(applied.get("applied", False)), + "target_version": staged.get("target_version"), + "update_type": staged.get("update_type"), + "staged_zip": staged.get("staged_zip"), + "sha256_verified": staged.get("sha256_verified", False), + "apply_reason": applied.get("reason"), + } + + # =========================================================================== # ⚠️ ZONE DANGEREUSE — STUBS RÉSERVÉS RÉVISION HUMAINE (NE PAS IMPLÉMENTER # PAR UN AGENT). Points d'extension explicites, no-op pour l'instant. diff --git a/agent_v0/server_v1/api_stream.py b/agent_v0/server_v1/api_stream.py index afaeee5a8..0dd590f06 100644 --- a/agent_v0/server_v1/api_stream.py +++ b/agent_v0/server_v1/api_stream.py @@ -7848,6 +7848,9 @@ async def lea_screen_analyze(payload: _Phase25ScreenRequest, request: Request): # client + Lea.bat). # ========================================================================= from .update_check import decide_update as _decide_update # noqa: E402 +from .update_policy import ( # noqa: E402 + resolve_target_version_from_env as _resolve_target_version_from_env, +) def _auto_update_server_enabled() -> bool: @@ -7857,14 +7860,25 @@ def _auto_update_server_enabled() -> bool: ) -def _latest_agent_version() -> str: - """Dernière version d'agent disponible côté serveur. +def _latest_agent_version(machine_id: Optional[str] = None) -> str: + """Version d'agent cible POUR CE POSTE (canary-aware, DETTE-022 v2). - Source de vérité minimale (POC) : variable d'environnement - RPA_AGENT_LATEST_VERSION. Permet de piloter la fleet sans rebuild. Une - évolution future pourra la lire d'un manifeste/DB (cf. design). + ⭐ SÉCURITÉ flotte ⭐ — la version servie est résolue PAR MACHINE via la + politique canary (`update_policy.resolve_target_version_from_env`) : un + poste canary (Émilie `lea-4zbgwxty`) reçoit la nouvelle version en premier ; + tous les autres restent sur le floor stable. Piloté 100 % par env, sans + rebuild : + RPA_AGENT_STABLE_VERSION (défaut 1.0.1) — servi à toute la flotte. + RPA_AGENT_CANARY_VERSION — servi AUX SEULS postes canary. + RPA_AGENT_CANARY_MACHINES — allow-list CSV des machine_id canary. + + Rétrocompat : si `RPA_AGENT_LATEST_VERSION` (ancienne var globale) est + positionnée, elle prime — évite toute régression d'un déploiement existant. """ - return os.environ.get("RPA_AGENT_LATEST_VERSION", "1.0.1") + legacy = os.environ.get("RPA_AGENT_LATEST_VERSION") + if legacy: + return legacy + return _resolve_target_version_from_env(machine_id) @app.get("/api/v1/agents/update/check") @@ -7877,6 +7891,10 @@ async def check_agent_update( Réponse : {update_available, latest_version, update_type, url}. + La version cible est résolue PAR MACHINE (canary) : voir + `_latest_agent_version`. Un poste hors canary ne se voit JAMAIS proposer la + version canary (blast radius borné à la liste canary). + GATED : si RPA_AUTO_UPDATE_SERVER_ENABLED n'est pas positionné → 503 (aucun effet sur le pipeline existant — anti-régression). Auth Bearer requise (dépendance globale `_verify_token`). @@ -7891,7 +7909,7 @@ async def check_agent_update( ) return _decide_update( current_version=current_version, - latest_version=_latest_agent_version(), + latest_version=_latest_agent_version(machine_id), update_type=update_type, machine_id=machine_id, ) diff --git a/agent_v0/server_v1/update_policy.py b/agent_v0/server_v1/update_policy.py new file mode 100644 index 000000000..06b446f7f --- /dev/null +++ b/agent_v0/server_v1/update_policy.py @@ -0,0 +1,139 @@ +# agent_v0/server_v1/update_policy.py +"""Politique de déploiement CANARY de la MAJ silencieuse Léa (DETTE-022 v2). + +⭐ Brique de SÉCURITÉ centrale ⭐ — 10+ postes cliniques live (Wallerstein). + +Une MAJ ratée peut briquer toute la flotte. La règle non négociable : on ne +pousse JAMAIS une nouvelle version sur tous les postes d'un coup. On la déploie +d'abord sur UN poste (canary = Émilie `lea-4zbgwxty`), on vérifie, puis on +élargit. Ce module résout, PAR MACHINE, la version cible : + + - poste dans la liste canary → `canary_version` (la nouvelle) ; + - tous les autres postes → `stable_version` (le floor, inchangé). + +Piloté 100 % par variables d'environnement (config serveur, sans rebuild) : + RPA_AGENT_STABLE_VERSION — version servie à toute la flotte (défaut floor). + RPA_AGENT_CANARY_VERSION — version servie AUX SEULS postes canary (optionnel). + RPA_AGENT_CANARY_MACHINES — allow-list CSV des machine_id canary. + +Promotion = quand le canary est validé, on met RPA_AGENT_STABLE_VERSION à la +version canary (toute la flotte suit) et on vide RPA_AGENT_CANARY_MACHINES. +Rollback canary = on remet RPA_AGENT_CANARY_VERSION à l'ancienne / on vide la +liste : le prochain check ne proposera plus la MAJ (le swap réel côté client +reste réservé révision humaine — cf. updater.py). + +Module PUR (aucun import FastAPI, aucune IO) → importable et testable seul +(DETTE-013). Le branchement HTTP vit dans api_stream.py. + +Branche feat/push-log-dgx. +""" + +from __future__ import annotations + +import os +from typing import Optional, Set + +# Réutilise le comparateur semver de la décision (même module serveur, pas de +# duplication) : "1.0.2" < "1.0.10" correctement, tolérant aux formats invalides. +try: # import relatif quand chargé comme package + from .update_check import is_newer +except Exception: # chargé par chemin (tests importlib) : import du voisin + import importlib.util as _ilu + from pathlib import Path as _Path + + _uc_path = _Path(__file__).resolve().parent / "update_check.py" + _spec = _ilu.spec_from_file_location("_rpa_update_check_for_policy", _uc_path) + _uc = _ilu.module_from_spec(_spec) + _spec.loader.exec_module(_uc) + is_newer = _uc.is_newer + + +# Séparateurs tolérés dans l'allow-list canary (CSV, espaces, point-virgule). +_CANARY_SEPARATORS = (",", ";") + + +def parse_canary_machines(raw: Optional[str]) -> Set[str]: + """Parse l'allow-list canary en un ensemble de machine_id. + + Tolérant : virgule / point-virgule / espace comme séparateurs, entrées + vides ignorées. `None` ou chaîne vide → ensemble vide (aucun canary). + """ + if not raw or not isinstance(raw, str): + return set() + normalized = raw + for sep in _CANARY_SEPARATORS: + normalized = normalized.replace(sep, " ") + return {tok for tok in (t.strip() for t in normalized.split()) if tok} + + +def resolve_target_version( + machine_id: Optional[str], + stable_version: str, + canary_version: Optional[str], + canary_machines: Set[str], +) -> str: + """Résout la version cible POUR CE POSTE (cœur canary — sécurité). + + Règles (toutes prudentes par défaut) : + 1. Poste HORS liste canary → `stable_version` (jamais la nouvelle). + 2. machine_id absent / liste vide / pas de canary_version → `stable_version`. + 3. Poste DANS la liste canary ET `canary_version` fournie ET STRICTEMENT + plus récente que stable → `canary_version`. + 4. Garde-fou : si `canary_version` <= `stable_version` (config douteuse, + ex. downgrade), on sert quand même `stable_version` (jamais de recul). + + Ne lève jamais. Une version illisible retombe naturellement sur le stable + via le comparateur semver tolérant. + """ + # Cas 1/2 : hors canary → stable. + if not machine_id or machine_id not in canary_machines: + return stable_version + if not canary_version: + return stable_version + + # Cas 4 : garde-fou anti-recul — le canary doit être STRICTEMENT plus récent. + if not is_newer(canary_version, stable_version): + return stable_version + + # Cas 3 : poste canary → nouvelle version. + return canary_version + + +# --------------------------------------------------------------------------- +# Lecture de la politique depuis l'environnement (pilotage sans rebuild). +# --------------------------------------------------------------------------- + +# Défaut historique aligné sur AGENT_VERSION client (config.py) et sur le +# fallback de _latest_agent_version(). +_DEFAULT_STABLE_VERSION = "1.0.1" + + +def stable_version_from_env() -> str: + """Version servie à toute la flotte (floor). Défaut = 1.0.1.""" + return os.environ.get("RPA_AGENT_STABLE_VERSION", _DEFAULT_STABLE_VERSION) + + +def canary_version_from_env() -> Optional[str]: + """Version canary (nouvelle), servie aux seuls postes canary. Optionnel.""" + val = os.environ.get("RPA_AGENT_CANARY_VERSION", "").strip() + return val or None + + +def canary_machines_from_env() -> Set[str]: + """Allow-list canary (machine_id) depuis RPA_AGENT_CANARY_MACHINES.""" + return parse_canary_machines(os.environ.get("RPA_AGENT_CANARY_MACHINES", "")) + + +def resolve_target_version_from_env(machine_id: Optional[str]) -> str: + """Raccourci : résout la version cible pour `machine_id` d'après l'env. + + C'est le point d'entrée que l'endpoint serveur appelle. Il isole toute la + lecture d'environnement ici (testable en injectant les paramètres via + `resolve_target_version`). + """ + return resolve_target_version( + machine_id=machine_id, + stable_version=stable_version_from_env(), + canary_version=canary_version_from_env(), + canary_machines=canary_machines_from_env(), + ) diff --git a/deploy/lea_package/config.txt b/deploy/lea_package/config.txt index 8073f07aa..ab1b73481 100644 --- a/deploy/lea_package/config.txt +++ b/deploy/lea_package/config.txt @@ -38,3 +38,12 @@ RPA_USER_LABEL=CONFIGURE_ME # --- Parametres avances (ne pas modifier sauf indication) --- RPA_BLUR_SENSITIVE=false RPA_LOG_RETENTION_DAYS=180 + +# --- MAJ silencieuse (DETTE-022 v2) — DESACTIVEE par defaut --- +# Deploiement CANARY : on active d'ABORD ce flag sur le SEUL poste pilote +# (Emilie), on verifie, puis on elargit. Le poste interroge le serveur et +# telecharge la MAJ en staging ; le remplacement reel des fichiers reste manuel +# / supervise (reserve revision humaine). Decommenter pour activer ce poste : +# RPA_AUTO_UPDATE_ENABLED=true +# Intervalle d'interrogation serveur en secondes (defaut 3600 = 1h) : +# RPA_AUTO_UPDATE_INTERVAL_S=3600 diff --git a/docs/DESIGN_MAJ_SILENCIEUSE_CANARY_2026-07-01.md b/docs/DESIGN_MAJ_SILENCIEUSE_CANARY_2026-07-01.md new file mode 100644 index 000000000..b5ff3b7a8 --- /dev/null +++ b/docs/DESIGN_MAJ_SILENCIEUSE_CANARY_2026-07-01.md @@ -0,0 +1,193 @@ +# DESIGN — MAJ silencieuse du client Léa + déploiement CANARY (DETTE-022 v2) + +Date : 2026-07-01 +Branche : `feat/push-log-dgx` +Statut : **premier draft fonctionnel — GATED OFF partout, aucun swap réel, revue supervisée Dom requise avant toute activation** + +> ⚠️ RIEN N'A ÉTÉ DÉPLOYÉ. Aucun SSH poste, aucune action fleet. Ce document + +> le code de la branche sont un livrable de conception/implémentation pour revue. + +--- + +## 1. Problème + +Pousser des correctifs au client Léa sur ~19 postes cliniques live (Wallerstein) +**sans** patch manuel DSI et **sans** déranger les TIM en plein travail. Contrainte +absolue : une MAJ ratée peut **briquer toute la flotte**. Le mécanisme doit donc +être **conservateur** : canary lent + rollback béton plutôt que rapide et risqué. + +## 2. État de départ (stub commit `813b33b47`) — ce qui existait déjà + +Le noyau était plus avancé qu'un simple squelette. Déjà présent et **testé (vert)** : + +| Brique | Fichier | Rôle | +|---|---|---| +| Décision serveur PURE | `agent_v0/server_v1/update_check.py` | `parse_version`/`is_newer` (semver correct : `1.0.2 < 1.0.10`), `decide_update()`, `build_download_url()` | +| Endpoint serveur gated | `agent_v0/server_v1/api_stream.py:7843+` | `GET /api/v1/agents/update/check` — **503 si `RPA_AUTO_UPDATE_SERVER_ENABLED` OFF**, Bearer requis | +| Noyau client PUR | `agent_v0/agent_v1/network/updater.py` | `auto_update_enabled()` (flag `RPA_AUTO_UPDATE_ENABLED`, défaut OFF), `should_update()` (double garde anti-downgrade), `download_update()` (staging + SHA256, ne touche jamais les fichiers vivants) | +| **Stubs dangereux (no-op)** | `updater.py:246+` | `apply_update()` / `write_boot_ok_marker()` — **réservés révision humaine** (swap fichiers, édition `Lea.bat`, restart) | +| Version agent | `agent_v0/agent_v1/config.py:30` | `AGENT_VERSION = os.environ.get("RPA_AGENT_VERSION", "1.0.1")` (amorcé `105ade959`) | +| Tests | `tests/unit/test_update_check_server.py`, `tests/unit/test_agent_v1_updater.py`, `tests/integration/test_update_check_endpoint.py` | R2/R3 verts | + +### Ce qui MANQUAIT (comblé par ce draft) + +1. **Aucune logique canary** : `decide_update` recevait `machine_id` mais l'ignorait pour choisir la version. La version cible était une seule var globale `RPA_AGENT_LATEST_VERSION` → une MAJ partait sur **toute** la flotte d'un coup. **C'est le trou de sécurité n°1.** +2. **Le noyau client n'était pas wiré** : `updater.py` n'était appelé nulle part. `main.py` ne l'importait pas. Aucun caller HTTP de `/agents/update/check`. +3. **Pas d'orchestrateur** reliant check → décide → download (staging) côté client. + +## 3. Fleet / versioning existant (réutilisé, pas réinventé) + +- Registre SQLite `enrolled_agents` (`agent_v0/server_v1/agent_registry.py:105`) : colonne `version` + `last_seen_at` par `machine_id`. Le dashboard Fleet (`web_dashboard/templates/index.html:2247`) affiche déjà la version par poste. +- **Limite connue** : `version` n'est écrite qu'à l'`enroll` (installateur), pas rafraîchie par le heartbeat runtime. Le serveur connaît donc la version *installée*, pas forcément la *version vive*. → **inventaire de version = amélioration future** (voir §8), non bloquante pour le canary (le canary est piloté par une allow-list de `machine_id`, pas par l'inventaire). + +## 4. Design retenu (et pourquoi) + +Aligné sur l'état de l'art self-update desktop 2025 (canary / blue-green / A-B swap + watchdog rollback + intégrité + version) — sources en fin de doc. + +### 4.1 CANARY côté serveur — la keystone de sécurité (IMPLÉMENTÉ) + +Nouveau module PUR `agent_v0/server_v1/update_policy.py`. Il résout la version cible +**PAR MACHINE** : + +- poste dans l'allow-list canary → `canary_version` (la nouvelle) ; +- tous les autres postes → `stable_version` (le floor, inchangé). + +Piloté 100 % par **variables d'environnement serveur** (aucun rebuild, aucune +DSI) : + +``` +RPA_AGENT_STABLE_VERSION # version servie à TOUTE la flotte (défaut 1.0.1) +RPA_AGENT_CANARY_VERSION # version servie AUX SEULS postes canary (optionnel) +RPA_AGENT_CANARY_MACHINES # allow-list CSV des machine_id canary +``` + +Garde-fous du résolveur (tous prudents par défaut) : +- machine_id absent / liste vide / pas de `canary_version` → **stable** ; +- `canary_version` doit être **strictement plus récente** que `stable` (sinon on sert stable — jamais de recul) ; +- ne lève jamais ; version illisible → retombe sur stable via le comparateur semver tolérant. + +Wiring : `_latest_agent_version(machine_id)` dans `api_stream.py` appelle +`resolve_target_version_from_env(machine_id)`. **Rétrocompat** : si l'ancienne +`RPA_AGENT_LATEST_VERSION` est positionnée, elle prime (pas de régression d'un +déploiement existant). + +**Effet** : la 1.0.2 ne peut PAS fuiter hors de la liste canary. Blast radius = +la liste. On démarre la liste = `lea-4zbgwxty` (Émilie) seul. + +**Promotion** = quand le canary est validé : `RPA_AGENT_STABLE_VERSION=` ++ vider `RPA_AGENT_CANARY_MACHINES` → toute la flotte suit. +**Rollback canary** = vider `RPA_AGENT_CANARY_MACHINES` / remettre l'ancienne +`RPA_AGENT_CANARY_VERSION` → le prochain check ne propose plus rien. + +### 4.2 Orchestrateur client (IMPLÉMENTÉ, GATED, sans swap) + +`updater.run_update_cycle(local_version, machine_id, staging_dir, checker?, downloader?)` : + +1. **GATE** `auto_update_enabled()` (`RPA_AUTO_UPDATE_ENABLED`, défaut OFF) — si OFF, ne fait **strictement rien**, aucun appel réseau ; +2. `checker(...)` → réponse serveur (défaut = `_default_update_checker` : GET vers l'endpoint gated, Bearer, 503→None, jamais d'exception) ; +3. `should_update(...)` → plan (double garde semver anti-downgrade) ; +4. `download_update(...)` → ZIP en **staging** + vérif **SHA256** (fichiers vivants jamais touchés) ; +5. `apply_update(staged)` = **stub no-op** → résultat `applied: False`. **Le swap réel n'est PAS fait par du code d'agent.** + +Statuts retournés (diagnostic/log) : `disabled | check_failed | up_to_date | download_failed | staged`. Best-effort total : aucune exception ne remonte (ne casse jamais Léa). + +### 4.3 Wiring runtime (IMPLÉMENTÉ, GATED) + +`main.py` : thread daemon `_auto_update_loop`, démarré **uniquement si** +`AUTO_UPDATE_ENABLED`, à côté des boucles permanentes existantes (même pattern +que le log shipper). Sécurité « **au bon moment** » : on ne stage PAS pendant un +enregistrement (`self.session_id`) ou un replay actif (`self._replay_active`) — +pas de perturbation du travail TIM. Intervalle `RPA_AUTO_UPDATE_INTERVAL_S` +(défaut **3600 s / 1 h** : une MAJ n'est jamais urgente). + +### 4.4 Intégrité + version + +- **Intégrité** : SHA256 vérifié dans `download_update` (déjà présent) ; mismatch → rejet + staging propre. +- **Version** : `AGENT_VERSION` envoyée à chaque check (`current_version`) ; le serveur choisit la cible par machine. +- **Signature (à ajouter, §8)** : SHA256 seul protège de la corruption, pas de l'usurpation. Recommandation : signer le manifeste (le SHA256 vient d'un canal authentifié — l'endpoint Bearer — donc chaîne acceptable pour le POC ; signature détachée = durcissement futur). + +### 4.5 Swap atomique + rollback (SPEC — réservé révision humaine, PAS codé par agent) + +Le swap réel reste dans les stubs `apply_update` / `write_boot_ok_marker` et +dans `Lea.bat`. **Un agent ne doit pas écrire de code qui écrase des binaires +vivants ni relance un process.** Spec cible pour la revue humaine : + +- **A-B / staging** : le ZIP est extrait dans `Lea_next\`. Au **prochain démarrage**, `Lea.bat` (hors-process) : backup `Lea\`→`Lea_prev\`, swap `Lea_next\`→`Lea\`, lance la nouvelle version. +- **Watchdog rollback** : la nouvelle version doit écrire un marker `boot_ok_` **après** ~60 s de heartbeat DGX sain + session OK. Si `Lea.bat` ne trouve pas le marker au démarrage suivant (crash au boot), il restaure `Lea_prev\` automatiquement. Cible « rollback latency » < 90 s (état de l'art). +- **Cas edge** (documenté dans les stubs) : DGX down ≠ Léa N+1 buguée — le health-check doit distinguer les deux pour éviter un faux rollback. + +## 5. Fichiers touchés (cette branche) + +**Ajouts** +- `agent_v0/server_v1/update_policy.py` — canary PUR (résolveur par machine + lecture env). +- `tests/unit/test_update_policy_canary.py` — TDD canary (résolveur + env). + +**Modifs** +- `agent_v0/server_v1/api_stream.py` — `_latest_agent_version(machine_id)` canary-aware (rétrocompat legacy) + docstring endpoint. +- `agent_v0/agent_v1/network/updater.py` — `_default_update_checker()` + `run_update_cycle()` (orchestrateur gated, sans swap). +- `agent_v0/agent_v1/config.py` — `AUTO_UPDATE_INTERVAL_S`, `AUTO_UPDATE_STAGING_DIR`. +- `agent_v0/agent_v1/main.py` — thread `_auto_update_loop` gated + import config. +- `tests/unit/test_agent_v1_updater.py` — TDD `run_update_cycle` (gate off, up-to-date, staged, sha mismatch, checker raise). +- `tests/integration/test_update_check_endpoint.py` — TDD canary HTTP (poste canary vs hors-canary). +- `deploy/lea_package/config.txt` — flags client MAJ documentés (commentés, OFF). + +**Intacts (réservés révision humaine)** : `updater.apply_update`, `updater.write_boot_ok_marker`, `Lea.bat`. + +## 6. Matrice des flags (tout OFF par défaut) + +| Flag | Côté | Défaut | Effet | +|---|---|---|---| +| `RPA_AUTO_UPDATE_SERVER_ENABLED` | serveur | OFF (503) | active l'endpoint de décision | +| `RPA_AGENT_STABLE_VERSION` | serveur | `1.0.1` | version floor de toute la flotte | +| `RPA_AGENT_CANARY_VERSION` | serveur | — | nouvelle version, postes canary seulement | +| `RPA_AGENT_CANARY_MACHINES` | serveur | — | allow-list CSV canary | +| `RPA_AGENT_LATEST_VERSION` (legacy) | serveur | — | si set, prime sur le canary (rétrocompat) | +| `RPA_AUTO_UPDATE_ENABLED` | client | OFF | active la boucle de check + staging | +| `RPA_AUTO_UPDATE_INTERVAL_S` | client | `3600` | intervalle de check | + +## 7. Plan de déploiement CANARY (étapes + critères GO / ROLLBACK) + +> Prérequis avant TOUTE étape : la mécanique de **swap réel** (§4.5) doit avoir +> été implémentée et revue par un humain. Tant qu'elle est en stub, ce plan ne +> fait que **stager** un ZIP (aucun poste ne change réellement de version) — ce +> qui est déjà utile pour valider la chaîne check/download/intégrité à vide. + +**Étape 0 — Serveur seul (aucun poste touché)** +- Action : `RPA_AUTO_UPDATE_SERVER_ENABLED=true`, `RPA_AGENT_STABLE_VERSION=1.0.1`, PAS de canary encore. +- GO si : `GET /agents/update/check` répond 200 pour un `machine_id` quelconque avec `update_available:false`. Aucun poste n'a la MAJ activée côté client. +- ROLLBACK : repasser le flag serveur OFF. + +**Étape 1 — Canary Émilie, staging seul** +- Action serveur : `RPA_AGENT_CANARY_VERSION=`, `RPA_AGENT_CANARY_MACHINES=lea-4zbgwxty`. +- Action poste Émilie (config.txt) : `RPA_AUTO_UPDATE_ENABLED=true`. +- GO si : dans les logs d'Émilie (remontés par le push-log DGX), `[UPDATE] MAJ téléchargée en staging (SHA256=True)`, ZIP présent dans le staging, `applied:False`, Léa continue de tourner normalement (session/replay non perturbés). Vérifier qu'AUCUN autre poste ne reçoit `update_available:true`. +- ROLLBACK : vider `RPA_AGENT_CANARY_MACHINES` (le check ne propose plus rien). Aucun impact : rien n'avait été appliqué. + +**Étape 2 — Canary Émilie, swap réel (après implémentation humaine du §4.5)** +- GO si : après redémarrage, Émilie tourne la nouvelle version (`AGENT_VERSION` remontée), marker `boot_ok` écrit, heartbeat DGX sain > 24 h, zéro régression fonctionnelle (enregistrement + replay OK). +- ROLLBACK : automatique par watchdog `Lea.bat` si pas de `boot_ok` au boot ; manuel = restaurer `Lea_prev\` + vider la liste canary. + +**Étape 3 — Élargissement progressif (rings)** +- Ajouter 2-3 postes à `RPA_AGENT_CANARY_MACHINES`, attendre 48 h par palier. +- GO/ROLLBACK : mêmes critères qu'étape 2, par palier. + +**Étape 4 — Promotion générale** +- `RPA_AGENT_STABLE_VERSION=` + vider `RPA_AGENT_CANARY_MACHINES`. +- Toute la flotte converge au rythme de son intervalle de check. +- ROLLBACK flotte : remettre `RPA_AGENT_STABLE_VERSION` à l'ancienne (les postes ne redescendent pas seuls — le swap-down reste une opération supervisée ; les nouveaux checks ne proposeront plus la MAJ). + +## 8. Améliorations futures (hors périmètre de ce draft) + +1. **Swap réel + watchdog rollback** (§4.5) — la brique manquante n°1, révision humaine. +2. **Inventaire de version vive** : rafraîchir `enrolled_agents.version` au heartbeat (le serveur saurait exactement quelle version tourne où — utile pour piloter le canary depuis le dashboard). +3. **Signature détachée** du manifeste (durcissement au-delà du SHA256 sur canal Bearer). +4. **Endpoint de download versionné** : aujourd'hui `/api/fleet/download/` (dashboard) sert l'installateur complet et **ignore `?type=&version=`** ; il faudra qu'il serve le vrai payload `code-only` incrémental attendu par le contrat d'URL. +5. **Auto-report du résultat de swap** (succès/rollback) au serveur pour un tableau de bord canary. + +## 9. Sources (état de l'art self-update desktop / canary 2025) + +- [Rollback Strategies for Enterprise: 2025 Best Practices — sparkco.ai](https://sparkco.ai/blog/rollback-strategies-for-enterprise-2025-best-practices) +- [Canary Deployment with Auto-Rollback for AI Agents — antigravitylab.net](https://antigravitylab.net/en/articles/agents/antigravity-ai-agent-canary-deployment-burn-rate-slo) +- [awesome-agentic-patterns — canary rollout & automatic rollback](https://github.com/nibzard/awesome-agentic-patterns/blob/main/patterns/canary-rollout-and-automatic-rollback-for-agent-policy-changes.md) +- [What is Canary Testing — aqua-cloud.io](https://aqua-cloud.io/canary-testing/) +- [Rollback Automation Best Practices for CI/CD — hokstadconsulting.com](https://hokstadconsulting.com/blog/rollback-automation-best-practices-for-ci-cd) diff --git a/tests/integration/test_update_check_endpoint.py b/tests/integration/test_update_check_endpoint.py index 9fe0279b3..ffd8c7f66 100644 --- a/tests/integration/test_update_check_endpoint.py +++ b/tests/integration/test_update_check_endpoint.py @@ -83,3 +83,43 @@ class TestUpdateCheckEndpointEnabled: "/api/v1/agents/update/check?current_version=1.0.1", ) assert resp.status_code == 401 + + +class TestUpdateCheckCanary: + """Canary : seul le poste canary se voit proposer la nouvelle version. + + On n'utilise PAS RPA_AGENT_LATEST_VERSION (var legacy globale) : on pilote + la version cible via la politique canary (stable + canary + allow-list). + """ + + @pytest.fixture(autouse=True) + def _enable_canary(self, monkeypatch): + monkeypatch.setenv("RPA_AUTO_UPDATE_SERVER_ENABLED", "true") + # Legacy OFF pour que la politique canary pilote la décision. + monkeypatch.delenv("RPA_AGENT_LATEST_VERSION", raising=False) + monkeypatch.setenv("RPA_AGENT_STABLE_VERSION", "1.0.1") + monkeypatch.setenv("RPA_AGENT_CANARY_VERSION", "1.0.2") + monkeypatch.setenv("RPA_AGENT_CANARY_MACHINES", "lea-4zbgwxty") + + def test_poste_canary_recoit_la_nouvelle_version(self, client): + resp = client.get( + "/api/v1/agents/update/check" + "?current_version=1.0.1&machine_id=lea-4zbgwxty", + headers=_auth_headers(), + ) + assert resp.status_code == 200 + body = resp.json() + assert body["update_available"] is True + assert body["latest_version"] == "1.0.2" + + def test_poste_hors_canary_reste_a_jour_sur_stable(self, client): + # Poste NON canary, déjà en 1.0.1 = stable → pas de MAJ (blast radius + # borné : la 1.0.2 ne fuite pas hors de la liste canary). + resp = client.get( + "/api/v1/agents/update/check" + "?current_version=1.0.1&machine_id=un-autre-poste", + headers=_auth_headers(), + ) + assert resp.status_code == 200 + body = resp.json() + assert body["update_available"] is False diff --git a/tests/unit/test_agent_v1_updater.py b/tests/unit/test_agent_v1_updater.py index 5de767636..bd3debf0d 100644 --- a/tests/unit/test_agent_v1_updater.py +++ b/tests/unit/test_agent_v1_updater.py @@ -223,3 +223,114 @@ class TestDangerousPartsAreStubs: def test_write_boot_ok_marker_est_un_stub(self, mod): result = mod.write_boot_ok_marker("1.0.2") assert result["written"] is False + + +# --------------------------------------------------------------------------- +# run_update_cycle — orchestrateur GATED (check → décide → stage → stub apply) +# AUCUN réseau réel, AUCUN swap réel : checker/downloader INJECTABLES, le swap +# reste un stub no-op (réservé révision humaine). +# --------------------------------------------------------------------------- + +class TestRunUpdateCycle: + def _checker(self, response): + """Fabrique un checker injectable qui renvoie `response`.""" + def _c(local_version, machine_id): + return response + return _c + + def test_gate_off_ne_fait_rien(self, mod, tmp_path, monkeypatch): + # Flag OFF (défaut) : le cycle ne doit RIEN faire (pas d'appel réseau). + monkeypatch.delenv("RPA_AUTO_UPDATE_ENABLED", raising=False) + called = {"n": 0} + + def _checker(local_version, machine_id): + called["n"] += 1 + return {"update_available": True, "latest_version": "9.9.9", + "url": "http://x", "sha256": None} + + result = mod.run_update_cycle( + local_version="1.0.1", + machine_id="pc-1", + staging_dir=tmp_path, + checker=_checker, + downloader=lambda u: b"x", + ) + assert result["status"] == "disabled" + assert called["n"] == 0 # aucun appel réseau quand OFF + + def test_a_jour_ne_stage_rien(self, mod, tmp_path, monkeypatch): + monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true") + result = mod.run_update_cycle( + local_version="1.0.1", + machine_id="pc-1", + staging_dir=tmp_path, + checker=self._checker( + {"update_available": False, "latest_version": "1.0.1"} + ), + downloader=lambda u: b"should-not-be-called", + ) + assert result["status"] == "up_to_date" + assert list(tmp_path.glob("*.zip")) == [] + + def test_maj_dispo_telecharge_en_staging_mais_ne_swappe_pas( + self, mod, tmp_path, monkeypatch + ): + monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true") + payload = b"PK\x03\x04 fake" + sha = hashlib.sha256(payload).hexdigest() + + result = mod.run_update_cycle( + local_version="1.0.1", + machine_id="pc-1", + staging_dir=tmp_path, + checker=self._checker({ + "update_available": True, + "latest_version": "1.0.2", + "update_type": "code-only", + "url": "http://srv/dl?version=1.0.2", + "sha256": sha, + }), + downloader=lambda u: payload, + ) + # Téléchargé + vérifié + STAGÉ, mais PAS appliqué (swap = stub humain). + assert result["status"] == "staged" + assert result["target_version"] == "1.0.2" + assert result["sha256_verified"] is True + staged = Path(result["staged_zip"]) + assert staged.exists() and staged.parent == tmp_path + # Le swap est explicitement NON fait (réservé révision humaine). + assert result["applied"] is False + + def test_sha256_mismatch_ne_stage_pas(self, mod, tmp_path, monkeypatch): + monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true") + result = mod.run_update_cycle( + local_version="1.0.1", + machine_id="pc-1", + staging_dir=tmp_path, + checker=self._checker({ + "update_available": True, + "latest_version": "1.0.2", + "update_type": "code-only", + "url": "http://x", + "sha256": "0" * 64, + }), + downloader=lambda u: b"corrupted", + ) + assert result["status"] == "download_failed" + assert list(tmp_path.glob("*.zip")) == [] + + def test_checker_qui_leve_pas_de_crash(self, mod, tmp_path, monkeypatch): + monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true") + + def _boom(local_version, machine_id): + raise RuntimeError("serveur down / 503") + + result = mod.run_update_cycle( + local_version="1.0.1", + machine_id="pc-1", + staging_dir=tmp_path, + checker=_boom, + downloader=lambda u: b"x", + ) + # Best-effort : jamais d'exception ne remonte (ne casse pas Léa). + assert result["status"] == "check_failed" diff --git a/tests/unit/test_update_policy_canary.py b/tests/unit/test_update_policy_canary.py new file mode 100644 index 000000000..bd5b998e2 --- /dev/null +++ b/tests/unit/test_update_policy_canary.py @@ -0,0 +1,162 @@ +"""TDD — DETTE-022 v2 : CANARY server-side pour la MAJ silencieuse Léa. + +Périmètre testé ICI = logique PURE de la POLITIQUE de déploiement canary, +testable sans démarrer le serveur (DETTE-013 : on N'IMPORTE PAS `api_stream` +— on charge `update_policy.py` par chemin, comme test_update_check_server). + +Objectif SÉCURITÉ (10+ postes cliniques live) : une MAJ ne doit JAMAIS +partir sur toute la flotte d'un coup. Le canary résout la version cible +*par machine* : + + - un poste dans la liste canary reçoit la version `canary` (Émilie d'abord) ; + - tous les autres restent sur la version `stable` (floor) tant que le canary + n'est pas promu. + +`resolve_target_version(machine_id, ...)` est la brique PURE ; `decide_update` +côté serveur l'appelle pour choisir la version cible avant de comparer. + +Le NOYAU dangereux (swap fichiers / Lea.bat / restart) reste HORS périmètre. +""" + +import importlib.util +from pathlib import Path + +import pytest + +_MOD_PATH = ( + Path(__file__).resolve().parents[2] + / "agent_v0" / "server_v1" / "update_policy.py" +) + + +def _load_module(): + spec = importlib.util.spec_from_file_location("rpa_update_policy", _MOD_PATH) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def mod(): + return _load_module() + + +# --------------------------------------------------------------------------- +# parse_canary_machines — liste d'allow-list (CSV / espaces tolérés) +# --------------------------------------------------------------------------- + +class TestParseCanaryMachines: + def test_liste_csv(self, mod): + assert mod.parse_canary_machines("lea-4zbgwxty") == {"lea-4zbgwxty"} + assert mod.parse_canary_machines("a,b,c") == {"a", "b", "c"} + + def test_espaces_et_vides_toleres(self, mod): + assert mod.parse_canary_machines(" a , b , ") == {"a", "b"} + assert mod.parse_canary_machines("") == set() + assert mod.parse_canary_machines(None) == set() + + def test_supporte_separateurs_espace_et_point_virgule(self, mod): + # Tolérant : virgule, point-virgule, espace comme séparateurs. + assert mod.parse_canary_machines("a; b c") == {"a", "b", "c"} + + +# --------------------------------------------------------------------------- +# resolve_target_version — LE cœur canary (sécurité) +# --------------------------------------------------------------------------- + +class TestResolveTargetVersion: + def test_machine_canary_recoit_version_canary(self, mod): + # Émilie (canary) reçoit la nouvelle version en premier. + target = mod.resolve_target_version( + machine_id="lea-4zbgwxty", + stable_version="1.0.1", + canary_version="1.0.2", + canary_machines={"lea-4zbgwxty"}, + ) + assert target == "1.0.2" + + def test_machine_hors_canary_reste_sur_stable(self, mod): + # Tous les autres postes restent sur la version stable (floor). + target = mod.resolve_target_version( + machine_id="lea-autre-poste", + stable_version="1.0.1", + canary_version="1.0.2", + canary_machines={"lea-4zbgwxty"}, + ) + assert target == "1.0.1" + + def test_pas_de_canary_configure_tout_le_monde_stable(self, mod): + # Aucun canary défini → personne ne monte (défaut ultra-prudent). + target = mod.resolve_target_version( + machine_id="lea-4zbgwxty", + stable_version="1.0.1", + canary_version="1.0.2", + canary_machines=set(), + ) + assert target == "1.0.1" + + def test_canary_version_absente_retombe_sur_stable(self, mod): + # Si canary_version n'est pas fournie, même un poste canary reste stable. + target = mod.resolve_target_version( + machine_id="lea-4zbgwxty", + stable_version="1.0.1", + canary_version=None, + canary_machines={"lea-4zbgwxty"}, + ) + assert target == "1.0.1" + + def test_machine_id_none_reste_stable(self, mod): + # machine_id inconnu / non fourni → jamais canary (prudence). + target = mod.resolve_target_version( + machine_id=None, + stable_version="1.0.1", + canary_version="1.0.2", + canary_machines={"lea-4zbgwxty"}, + ) + assert target == "1.0.1" + + def test_canary_ne_downgrade_jamais_en_dessous_de_stable(self, mod): + # GARDE-FOU : si le canary_version est PLUS ANCIEN que stable (erreur + # de config), on NE descend PAS le poste canary — on sert stable. + target = mod.resolve_target_version( + machine_id="lea-4zbgwxty", + stable_version="1.0.5", + canary_version="1.0.2", # plus ancien → config douteuse + canary_machines={"lea-4zbgwxty"}, + ) + assert target == "1.0.5" + + +# --------------------------------------------------------------------------- +# Lecture depuis l'environnement (pilotage sans rebuild) — défauts prudents +# --------------------------------------------------------------------------- + +class TestEnvPolicy: + def test_defauts_prudents_aucune_maj(self, mod, monkeypatch): + # Aucune var positionnée → stable par défaut, pas de canary. + for var in ( + "RPA_AGENT_STABLE_VERSION", + "RPA_AGENT_CANARY_VERSION", + "RPA_AGENT_CANARY_MACHINES", + ): + monkeypatch.delenv(var, raising=False) + assert mod.stable_version_from_env() == "1.0.1" + assert mod.canary_version_from_env() is None + assert mod.canary_machines_from_env() == set() + # Un poste quelconque reste sur stable. + assert mod.resolve_target_version_from_env("lea-4zbgwxty") == "1.0.1" + + def test_canary_actif_via_env_seul_le_poste_canary_monte(self, mod, monkeypatch): + monkeypatch.setenv("RPA_AGENT_STABLE_VERSION", "1.0.1") + monkeypatch.setenv("RPA_AGENT_CANARY_VERSION", "1.0.2") + monkeypatch.setenv("RPA_AGENT_CANARY_MACHINES", "lea-4zbgwxty") + assert mod.resolve_target_version_from_env("lea-4zbgwxty") == "1.0.2" + assert mod.resolve_target_version_from_env("autre-poste") == "1.0.1" + + def test_promotion_toute_la_flotte_suit(self, mod, monkeypatch): + # Promotion : on met stable = version canary, on vide la liste canary. + monkeypatch.setenv("RPA_AGENT_STABLE_VERSION", "1.0.2") + monkeypatch.delenv("RPA_AGENT_CANARY_VERSION", raising=False) + monkeypatch.delenv("RPA_AGENT_CANARY_MACHINES", raising=False) + assert mod.resolve_target_version_from_env("autre-poste") == "1.0.2" + assert mod.resolve_target_version_from_env("lea-4zbgwxty") == "1.0.2" From a210e5ee3283e563417cbccb47f2db24073e2364 Mon Sep 17 00:00:00 2001 From: Dom Date: Wed, 1 Jul 2026 14:10:34 +0200 Subject: [PATCH 5/5] feat(update): swap atomique + rollback (Lea.bat) + confirmation boot (main.py) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implémente le SWAP réel de la MAJ silencieuse (DETTE-022), remplace les stubs : - updater.apply_update : ARME le swap (extrait le ZIP -> agent_v1_new/ + marqueur UPDATE_READY, garde-fou zip-slip). N'écrase JAMAIS le vivant. - updater.write_boot_ok_marker : désarme le rollback (retire PENDING_BOOT). - Lea.bat (template + embed généré par configure_embed.ps1) : swap ATOMIQUE par renames (agent_v1 -> agent_v1_prev backup ; agent_v1_new -> agent_v1) + rollback auto si PENDING_BOOT persiste (boot précédent non confirmé). - main.py : confirme le boot après 90 s de liveness locale OU quit propre (évite un faux rollback ; RPA_BOOT_CONFIRM_DELAY_S surchargeable pour les tests). Testable (Python) : 45 tests verts. Le swap OS (renames Lea.bat) + le câblage main.py seront validés par le test Win 11 (step 0 pré-canary, dont le rollback). Co-Authored-By: Claude Opus 4.8 (1M context) --- agent_v0/agent_v1/main.py | 45 +++++++ agent_v0/agent_v1/network/updater.py | 182 ++++++++++++++++++--------- deploy/installer/configure_embed.ps1 | 23 ++++ deploy/lea_package/Lea.bat | 29 +++++ tests/unit/test_agent_v1_updater.py | 114 +++++++++++++---- 5 files changed, 313 insertions(+), 80 deletions(-) diff --git a/agent_v0/agent_v1/main.py b/agent_v0/agent_v1/main.py index 6b992156b..fbc931ad2 100644 --- a/agent_v0/agent_v1/main.py +++ b/agent_v0/agent_v1/main.py @@ -168,6 +168,22 @@ class AgentV1: target=self._auto_update_loop, daemon=True, name="lea-auto-update" ).start() + # MAJ silencieuse — confirmation de boot post-swap. Si Lea.bat vient + # d'appliquer une MAJ (marqueur PENDING_BOOT), on désarme le rollback + # après ~90 s de tourne STABLE (liveness LOCALE, indépendante du DGX). + # Un quit propre avant 90 s confirme aussi (cf. main()). Seul un vrai + # crash laisse PENDING_BOOT → rollback au prochain lancement. + if _pending_boot_marker_exists(): + def _boot_confirm(): + import os as _os + import time as _time + _time.sleep(float(_os.environ.get("RPA_BOOT_CONFIRM_DELAY_S", "90"))) + if self.running: + _confirm_boot_ok() + threading.Thread( + target=_boot_confirm, daemon=True, name="lea-boot-confirm" + ).start() + # Mini-serveur HTTP pour captures a la demande (port 5006) self._capture_server = CaptureServer() self._capture_server.start() @@ -718,6 +734,31 @@ def _agent_should_live(agent) -> bool: return True +def _pending_boot_marker_exists() -> bool: + """True si Lea.bat a posé PENDING_BOOT (boot post-MAJ à valider).""" + try: + from .network.updater import _resolve_app_dir + return (_resolve_app_dir(None) / "PENDING_BOOT").exists() + except Exception: + return False + + +def _confirm_boot_ok() -> None: + """Confirme un boot post-MAJ : écrit boot_ok + retire PENDING_BOOT. + + Désarme le rollback de Lea.bat. No-op si pas de PENDING_BOOT (boot normal). + Best-effort — ne doit jamais casser l'arrêt/la vie de Léa. + """ + try: + if not _pending_boot_marker_exists(): + return + from .network import updater + updater.write_boot_ok_marker(AGENT_VERSION) + logger.info("[MAJ] Boot confirmé (v%s) — rollback désarmé", AGENT_VERSION) + except Exception as e: # noqa: BLE001 + logger.debug("confirm_boot_ok: %s", e) + + def main(): from .ui.session_watchdog import InteractiveSessionWatchdog @@ -738,6 +779,10 @@ def main(): try: watchdog.run() + # Sortie normale du watchdog = quit propre (tray / session) → le boot + # était sain : on confirme (couvre un quit AVANT les 90 s, évite un faux + # rollback). No-op si ce n'est pas un boot post-MAJ. + _confirm_boot_ok() except KeyboardInterrupt: logger.info("[MAIN] Interruption clavier — arret propre") except Exception: diff --git a/agent_v0/agent_v1/network/updater.py b/agent_v0/agent_v1/network/updater.py index 80af1c63d..f4e01064b 100644 --- a/agent_v0/agent_v1/network/updater.py +++ b/agent_v0/agent_v1/network/updater.py @@ -18,12 +18,11 @@ Ce module ne contient que les parties PURES / testables, sans réseau réel : les fichiers vivants. Retourne un plan d'application. - `auto_update_enabled()` : lit le flag (défaut OFF). -⚠️⚠️ PARTIES DANGEREUSES — RÉSERVÉES RÉVISION HUMAINE ⚠️⚠️ -Le remplacement réel des fichiers (`apply_update`), l'écriture du marker -rollback (`write_boot_ok_marker`), l'édition de `Lea.bat` et le redémarrage -ne sont PAS implémentés ici : ce sont des STUBS no-op explicites. Un agent ne -doit pas écrire de code qui écrase des binaires vivants ou relance un process -sans supervision. Les points d'extension sont marqués `# TODO swap supervisé`. +⚠️ SWAP — répartition claire des responsabilités : +`apply_update` / `write_boot_ok_marker` ci-dessous ne font que l'ARMEMENT côté +Python (extraction vers `agent_v1_new/` + marqueurs) — ils n'écrasent JAMAIS un +fichier vivant. Le remplacement ATOMIQUE (renames), le redémarrage et le +rollback sont faits HORS-PROCESS par `Lea.bat` au démarrage (revu ligne à ligne). Pattern d'import / résilience aligné sur `log_shipper.py` (même branche). @@ -33,8 +32,10 @@ Branche feat/push-log-dgx. from __future__ import annotations import hashlib +import json import logging import os +import shutil from pathlib import Path from typing import Callable, Optional, Tuple @@ -296,6 +297,7 @@ def run_update_cycle( staging_dir, checker: Optional[Callable[[str, str], object]] = None, downloader: Optional[Callable[[str], bytes]] = None, + app_dir=None, ) -> dict: """Un cycle complet de MAJ silencieuse — GATED, best-effort, SANS swap. @@ -306,9 +308,10 @@ def run_update_cycle( 3. `should_update(...)` → plan (double garde semver, jamais de downgrade). 4. `download_update(...)` → ZIP dans le STAGING + vérif SHA256. Ne touche JAMAIS les fichiers vivants. - 5. Le swap réel N'EST PAS FAIT ici : `apply_update` reste un stub no-op - (réservé révision humaine + Lea.bat hors-process). Le résultat porte - `applied: False`. + 5. `apply_update` ARME le swap (extraction `agent_v1_new/` + marqueur + UPDATE_READY) mais NE swappe PAS : le remplacement atomique + le + redémarrage sont faits par Lea.bat au prochain démarrage. `applied` + reste False tant que Léa n'a pas redémarré sur la nouvelle version. Jamais d'exception ne remonte (ne doit JAMAIS casser Léa). Retourne un dict d'état pour le diagnostic / le log : @@ -342,76 +345,137 @@ def run_update_cycle( "error": staged.get("error"), } - # ⚠️ Le swap réel est réservé révision humaine : on APPELLE le stub (no-op) - # pour matérialiser le point d'extension, mais rien n'est écrasé/redémarré. - applied = apply_update(staged) + # Armement du swap : extraction du ZIP vers agent_v1_new\ + marqueur + # UPDATE_READY. Le swap ATOMIQUE (renames) et le redémarrage sont faits + # HORS-PROCESS par Lea.bat au prochain démarrage — JAMAIS depuis ici + # (on n'écrase pas les fichiers d'un Léa en cours d'exécution). + armed = apply_update(staged, app_dir=app_dir) return { - "status": "staged", - "applied": bool(applied.get("applied", False)), + "status": "armed" if armed.get("armed") else "arm_failed", + "applied": False, # le swap effectif est fait par Lea.bat, pas ici + "armed": bool(armed.get("armed", False)), "target_version": staged.get("target_version"), "update_type": staged.get("update_type"), "staged_zip": staged.get("staged_zip"), "sha256_verified": staged.get("sha256_verified", False), - "apply_reason": applied.get("reason"), + "marker": armed.get("marker"), + "error": armed.get("error"), } # =========================================================================== -# ⚠️ ZONE DANGEREUSE — STUBS RÉSERVÉS RÉVISION HUMAINE (NE PAS IMPLÉMENTER -# PAR UN AGENT). Points d'extension explicites, no-op pour l'instant. +# SWAP — côté Python : ARMEMENT SEULEMENT (extraction + marqueurs). +# Le remplacement ATOMIQUE des fichiers vivants + le redémarrage + le +# rollback sont faits HORS-PROCESS par `Lea.bat` au démarrage (renames). +# Python n'écrase JAMAIS les fichiers d'un Léa en cours d'exécution. # =========================================================================== -def apply_update(prepared: dict) -> dict: - """STUB — application réelle de l'update (swap des fichiers). +def _resolve_app_dir(app_dir) -> Path: + """Répertoire d'install (contient `agent_v1/`, `run_agent_v1.py`, `Lea.bat`). - Réservé révision humaine : remplacer des fichiers vivants du client et - déclencher un swap est trop risqué pour être généré par un agent. La - mécanique cible (design v2) est : + INJECTABLE (tests : tmp_path). Défaut = parent du package agent_v1. + """ + if app_dir is not None: + return Path(app_dir) + try: + from ..config import BASE_DIR # BASE_DIR = dossier du package agent_v1 + return Path(BASE_DIR).parent + except Exception: + return Path(__file__).resolve().parent.parent.parent - - code-only : extraire `agent_v1\\` + `lea_ui\\` + `run_agent_v1.py` + - `config.py` du ZIP staging, poser un marker `UPDATE_READY` - (`update_type=code-only`) ; le swap effectif est fait par `Lea.bat` - au prochain démarrage (xcopy ciblé). - - full : poser `UPDATE_READY` (`update_type=full`) ; `Lea.bat` fait le - backup complet `Lea_prev\\` puis le swap complet. - # TODO swap supervisé : extraction ZIP + écriture marker UPDATE_READY. - # NE PAS écraser les fichiers vivants depuis Python — c'est Lea.bat qui - # swappe hors-process. Édition de Lea.bat + restart = hors périmètre agent. +def apply_update(prepared: dict, app_dir=None) -> dict: + """ARME le swap : extrait le ZIP staging vers `agent_v1_new/` + marqueur. + + NE swappe PAS et NE redémarre PAS (c'est le rôle de `Lea.bat`). Écrit + uniquement à côté des fichiers vivants (dossier neuf + marqueur), donc + l'opération est sûre même sur un Léa en cours d'exécution. + + 1. Extrait `prepared["staged_zip"]` → `/agent_v1_new/` + (nettoyé au préalable ; garde-fou zip-slip). + 2. Écrit `/UPDATE_READY` (JSON : version, type, chemins) que + `Lea.bat` lira au prochain démarrage pour faire le swap atomique. + + Best-effort : aucune exception ne remonte (ne doit jamais casser Léa). Returns: - {applied: False, reason: "réservé révision humaine (swap supervisé)"} + succès : {armed: True, applied: False, target_version, update_type, + marker, extracted_to} + échec : {armed: False, applied: False, error} """ - logger.info( - "apply_update appelé mais NON implémenté (stub réservé révision humaine) : %r", - prepared.get("target_version") if isinstance(prepared, dict) else prepared, - ) - return { - "applied": False, - "reason": "réservé révision humaine — swap supervisé (Lea.bat), hors périmètre agent", - } + if not isinstance(prepared, dict): + return {"armed": False, "applied": False, "error": "prepared invalide"} + staged_zip = prepared.get("staged_zip") + target_version = prepared.get("target_version", "unknown") + update_type = _normalize_update_type(prepared.get("update_type")) + try: + root = _resolve_app_dir(app_dir) + zip_path = Path(staged_zip) if staged_zip else None + if zip_path is None or not zip_path.is_file(): + return {"armed": False, "applied": False, "error": "ZIP staging introuvable"} + + new_dir = root / "agent_v1_new" + if new_dir.exists(): + shutil.rmtree(new_dir, ignore_errors=True) # nettoie un staging partiel + new_dir.mkdir(parents=True, exist_ok=True) + + import zipfile + new_root = new_dir.resolve() + with zipfile.ZipFile(zip_path) as zf: + for name in zf.namelist(): # garde-fou zip-slip (chemins ../) + dest = (new_dir / name).resolve() + if not str(dest).startswith(str(new_root)): + shutil.rmtree(new_dir, ignore_errors=True) + return {"armed": False, "applied": False, + "error": f"zip-slip refusé : {name}"} + zf.extractall(new_dir) + + marker = root / "UPDATE_READY" + marker.write_text(json.dumps({ + "target_version": target_version, + "update_type": update_type, + "extracted_to": str(new_dir), + "staged_zip": str(zip_path), + }), encoding="utf-8") + + logger.info( + "Update ARMÉ : %s (%s) → %s ; swap au prochain démarrage (Lea.bat)", + target_version, update_type, new_dir, + ) + return {"armed": True, "applied": False, "target_version": target_version, + "update_type": update_type, "marker": str(marker), + "extracted_to": str(new_dir)} + except Exception as e: # noqa: BLE001 + logger.warning("apply_update (armement) a échoué : %s", e) + return {"armed": False, "applied": False, "error": f"arm_failed: {e}"} -def write_boot_ok_marker(version: str) -> dict: - """STUB — écriture du marker rollback `boot_ok_{version}` (R1). +def write_boot_ok_marker(version: str, app_dir=None) -> dict: + """Confirme un boot sain : écrit `boot_ok_{version}` + désarme le rollback. - Réservé révision humaine : le marker pilote le rollback de Lea.bat au - prochain démarrage. Sa sémantique (health-check ~60s heartbeat DGX + - session active AVANT écriture) doit être validée à la main pour éviter un - faux rollback (cas DGX down ≠ Léa N+1 buguée — cf. design R1, cas edge 3). + Appelé par `main.py` après ~90 s de tourne STABLE (liveness LOCALE, + indépendante du DGX — évite un faux rollback quand le réseau est coupé). + Retirer `PENDING_BOOT*` dit à `Lea.bat` que la nouvelle version a démarré + correctement (sinon, au prochain lancement, Lea.bat rollback vers la + version précédente). - # TODO swap supervisé : écrire `%LOCALAPPDATA%\\Lea\\boot_ok_{version}` - # après ~60s de heartbeat DGX sain + session active (main.py startup). - - Returns: - {written: False, reason: "..."} + Best-effort : aucune exception ne remonte. """ - logger.info( - "write_boot_ok_marker appelé mais NON implémenté (stub R1) : version=%s", - version, - ) - return { - "written": False, - "reason": "réservé révision humaine — marker rollback (health-check), hors périmètre agent", - } + try: + root = _resolve_app_dir(app_dir) + marker = root / f"boot_ok_{version}" + marker.write_text("ok", encoding="utf-8") + cleared = [] + for p in root.glob("PENDING_BOOT*"): + try: + p.unlink() + cleared.append(p.name) + except OSError: + pass + logger.info("boot_ok écrit (%s) ; PENDING_BOOT retiré : %s", + version, cleared or "aucun") + return {"written": True, "marker": str(marker), "cleared_pending": cleared} + except Exception as e: # noqa: BLE001 + logger.warning("write_boot_ok_marker a échoué : %s", e) + return {"written": False, "error": str(e)} diff --git a/deploy/installer/configure_embed.ps1 b/deploy/installer/configure_embed.ps1 index 5f6b2b6dd..47f185e6c 100644 --- a/deploy/installer/configure_embed.ps1 +++ b/deploy/installer/configure_embed.ps1 @@ -76,6 +76,29 @@ if exist "lea_agent.lock" ( timeout /t 2 >nul ) +:: MAJ SILENCIEUSE — swap atomique + rollback (renames uniquement) +if exist "PENDING_BOOT" ( + echo [MAJ] Boot precedent non confirme : retour a la version precedente. + if exist "agent_v1_prev" ( + if exist "agent_v1_echec" rmdir /s /q "agent_v1_echec" >nul 2>&1 + if exist "agent_v1" move "agent_v1" "agent_v1_echec" >nul 2>&1 + move "agent_v1_prev" "agent_v1" >nul 2>&1 + ) + del /f /q "PENDING_BOOT" >nul 2>&1 +) else if exist "UPDATE_READY" ( + if exist "agent_v1_new" ( + echo [MAJ] Application de la mise a jour... + if exist "agent_v1" ( + if exist "agent_v1_prev" rmdir /s /q "agent_v1_prev" >nul 2>&1 + move "agent_v1" "agent_v1_prev" >nul 2>&1 + ) + move "agent_v1_new" "agent_v1" >nul 2>&1 + move "UPDATE_READY" "PENDING_BOOT" >nul 2>&1 + ) else ( + del /f /q "UPDATE_READY" >nul 2>&1 + ) +) + if exist "config.txt" ( for /f "usebackq eol=# tokens=1,* delims==" %%a in ("config.txt") do ( if not "%%a"=="" if not "%%b"=="" set "%%a=%%b" diff --git a/deploy/lea_package/Lea.bat b/deploy/lea_package/Lea.bat index 7a03aa7eb..b49bf0de6 100644 --- a/deploy/lea_package/Lea.bat +++ b/deploy/lea_package/Lea.bat @@ -20,6 +20,35 @@ if exist "lea_agent.lock" ( timeout /t 2 >nul ) +:: --------------------------------------------------------------- +:: MAJ SILENCIEUSE — swap atomique + rollback (hors-process) +:: L'ancienne instance est fermee ci-dessus : agent_v1\ est libre. +:: Renames uniquement (quasi-atomiques), jamais d'ecrasement fichier par fichier. +:: --------------------------------------------------------------- +if exist "PENDING_BOOT" ( + :: Le boot precedent n'a JAMAIS confirme (crash) -> ROLLBACK version precedente + echo [MAJ] Boot precedent non confirme : retour a la version precedente. + if exist "agent_v1_prev" ( + if exist "agent_v1_echec" rmdir /s /q "agent_v1_echec" >nul 2>&1 + if exist "agent_v1" move "agent_v1" "agent_v1_echec" >nul 2>&1 + move "agent_v1_prev" "agent_v1" >nul 2>&1 + ) + del /f /q "PENDING_BOOT" >nul 2>&1 +) else if exist "UPDATE_READY" ( + :: Une MAJ est armee (agent_v1_new pret) -> SWAP + if exist "agent_v1_new" ( + echo [MAJ] Application de la mise a jour... + if exist "agent_v1" ( + if exist "agent_v1_prev" rmdir /s /q "agent_v1_prev" >nul 2>&1 + move "agent_v1" "agent_v1_prev" >nul 2>&1 + ) + move "agent_v1_new" "agent_v1" >nul 2>&1 + move "UPDATE_READY" "PENDING_BOOT" >nul 2>&1 + ) else ( + del /f /q "UPDATE_READY" >nul 2>&1 + ) +) + :: --------------------------------------------------------------- :: Verifier que l'installation a ete faite :: --------------------------------------------------------------- diff --git a/tests/unit/test_agent_v1_updater.py b/tests/unit/test_agent_v1_updater.py index bd3debf0d..b99bb9fab 100644 --- a/tests/unit/test_agent_v1_updater.py +++ b/tests/unit/test_agent_v1_updater.py @@ -206,23 +206,88 @@ class TestDownloadUpdate: # --------------------------------------------------------------------------- -# Stubs réservés à la révision humaine — DOIVENT être no-op explicites +# apply_update — ARMEMENT du swap (extraction agent_v1_new + marqueur). +# NE swappe PAS et NE touche PAS les fichiers vivants (Lea.bat le fait au boot). # --------------------------------------------------------------------------- -class TestDangerousPartsAreStubs: - def test_apply_update_est_un_stub_non_implemente(self, mod, tmp_path): - # Le swap réel est réservé révision humaine : le stub NE TOUCHE RIEN - # et signale qu'il n'est pas implémenté. - result = mod.apply_update( - {"target_version": "1.0.2", "update_type": "code-only", - "staged_zip": str(tmp_path / "x.zip")} - ) - assert result["applied"] is False - assert "human" in result["reason"].lower() or "supervis" in result["reason"].lower() +def _make_zip(path, entries): + """Fabrique un ZIP {nom: contenu} pour les tests.""" + import zipfile + with zipfile.ZipFile(path, "w") as zf: + for name, content in entries.items(): + zf.writestr(name, content) + return path - def test_write_boot_ok_marker_est_un_stub(self, mod): - result = mod.write_boot_ok_marker("1.0.2") - assert result["written"] is False + +class TestApplyUpdateArm: + def test_arme_extrait_et_pose_marqueur(self, mod, tmp_path): + app = tmp_path / "app"; app.mkdir() + z = _make_zip(tmp_path / "u.zip", {"main.py": "v2", "sub/x.py": "y"}) + res = mod.apply_update( + {"target_version": "1.0.2", "update_type": "code-only", "staged_zip": str(z)}, + app_dir=app, + ) + assert res["armed"] is True and res["applied"] is False + new_dir = app / "agent_v1_new" + assert (new_dir / "main.py").read_text() == "v2" + assert (new_dir / "sub" / "x.py").read_text() == "y" + import json as _j + data = _j.loads((app / "UPDATE_READY").read_text()) + assert data["target_version"] == "1.0.2" + assert data["update_type"] == "code-only" + + def test_ne_touche_pas_le_agent_v1_vivant(self, mod, tmp_path): + app = tmp_path / "app"; (app / "agent_v1").mkdir(parents=True) + live = app / "agent_v1" / "sentinelle.txt" + live.write_text("VERSION_VIVANTE") + z = _make_zip(tmp_path / "u.zip", {"main.py": "v2"}) + mod.apply_update( + {"target_version": "1.0.2", "update_type": "code-only", "staged_zip": str(z)}, + app_dir=app, + ) + assert live.read_text() == "VERSION_VIVANTE" # swap différé à Lea.bat + + def test_zip_introuvable_pas_de_crash_ni_marqueur(self, mod, tmp_path): + app = tmp_path / "app"; app.mkdir() + res = mod.apply_update( + {"target_version": "1.0.2", "update_type": "code-only", + "staged_zip": str(tmp_path / "absent.zip")}, + app_dir=app, + ) + assert res["armed"] is False and "error" in res + assert not (app / "UPDATE_READY").exists() + + def test_relance_nettoie_agent_v1_new_precedent(self, mod, tmp_path): + app = tmp_path / "app"; app.mkdir() + stale = app / "agent_v1_new"; stale.mkdir() + (stale / "vieux.txt").write_text("obsolete") + z = _make_zip(tmp_path / "u.zip", {"main.py": "v2"}) + mod.apply_update( + {"target_version": "1.0.3", "update_type": "code-only", "staged_zip": str(z)}, + app_dir=app, + ) + assert not (app / "agent_v1_new" / "vieux.txt").exists() + assert (app / "agent_v1_new" / "main.py").read_text() == "v2" + + def test_zip_slip_refuse(self, mod, tmp_path): + app = tmp_path / "app"; app.mkdir() + z = _make_zip(tmp_path / "evil.zip", {"../evil.py": "pwn"}) + res = mod.apply_update( + {"target_version": "1.0.2", "update_type": "code-only", "staged_zip": str(z)}, + app_dir=app, + ) + assert res["armed"] is False + assert not (app / "evil.py").exists() + + +class TestWriteBootOkMarker: + def test_ecrit_boot_ok_et_desarme_pending(self, mod, tmp_path): + app = tmp_path / "app"; app.mkdir() + (app / "PENDING_BOOT_1.0.2").write_text("x") + res = mod.write_boot_ok_marker("1.0.2", app_dir=app) + assert res["written"] is True + assert (app / "boot_ok_1.0.2").exists() + assert not (app / "PENDING_BOOT_1.0.2").exists() # --------------------------------------------------------------------------- @@ -272,12 +337,18 @@ class TestRunUpdateCycle: assert result["status"] == "up_to_date" assert list(tmp_path.glob("*.zip")) == [] - def test_maj_dispo_telecharge_en_staging_mais_ne_swappe_pas( + def test_maj_dispo_arme_le_swap_mais_ne_swappe_pas( self, mod, tmp_path, monkeypatch ): monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true") - payload = b"PK\x03\x04 fake" + # payload = un VRAI ZIP (le download le stage, apply_update l'extrait) + import io, zipfile + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("main.py", "code v1.0.2") + payload = buf.getvalue() sha = hashlib.sha256(payload).hexdigest() + app = tmp_path / "app"; app.mkdir() result = mod.run_update_cycle( local_version="1.0.1", @@ -291,15 +362,16 @@ class TestRunUpdateCycle: "sha256": sha, }), downloader=lambda u: payload, + app_dir=app, ) - # Téléchargé + vérifié + STAGÉ, mais PAS appliqué (swap = stub humain). - assert result["status"] == "staged" + # Téléchargé + vérifié + ARMÉ (agent_v1_new + UPDATE_READY), mais PAS + # swappé : le remplacement atomique est fait par Lea.bat au reboot. + assert result["status"] == "armed" assert result["target_version"] == "1.0.2" assert result["sha256_verified"] is True - staged = Path(result["staged_zip"]) - assert staged.exists() and staged.parent == tmp_path - # Le swap est explicitement NON fait (réservé révision humaine). assert result["applied"] is False + assert (app / "UPDATE_READY").exists() + assert (app / "agent_v1_new" / "main.py").read_text() == "code v1.0.2" def test_sha256_mismatch_ne_stage_pas(self, mod, tmp_path, monkeypatch): monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")