merge(client): fixes Lea poste Emilie — httpx embed, capture JPEG, watchdog RDP, MAJ silencieuse (gated OFF)

4 fixes TDD-verts (revue qualite 3 GO + httpx debloque par peuplement embed).
MAJ silencieuse embarquee flag OFF (dormante, quadruple gate). Cible EXE->Julien.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-07-01 22:44:40 +02:00
23 changed files with 2207 additions and 136 deletions

View File

@@ -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 ( AUTO_UPDATE_ENABLED = os.environ.get("RPA_AUTO_UPDATE_ENABLED", "false").lower() in (
"true", "1", "yes", "on", "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 # Monitoring
PERF_MONITOR_INTERVAL_S = 30 PERF_MONITOR_INTERVAL_S = 30

View File

@@ -18,6 +18,7 @@ from .config import (
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS, LOG_FILE, 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, SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, API_TOKEN, MAX_SESSION_DURATION_S,
STREAMING_ENDPOINT, LOG_SHIP_ENABLED, LOG_SHIP_INTERVAL_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.captor import EventCaptorV1
from .core.executor import ActionExecutorV1 from .core.executor import ActionExecutorV1
@@ -158,6 +159,31 @@ class AgentV1:
threading.Thread(target=self._replay_poll_loop, daemon=True).start() threading.Thread(target=self._replay_poll_loop, daemon=True).start()
threading.Thread(target=self._background_heartbeat_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()
# 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) # Mini-serveur HTTP pour captures a la demande (port 5006)
self._capture_server = CaptureServer() self._capture_server = CaptureServer()
self._capture_server.start() self._capture_server.start()
@@ -441,6 +467,67 @@ class AgentV1:
logger.debug(f"[HEARTBEAT] Erreur: {e}") logger.debug(f"[HEARTBEAT] Erreur: {e}")
time.sleep(5) 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): def stop_session(self):
# Sauvegarder le session_id avant de l'annuler (pour les logs) # Sauvegarder le session_id avant de l'annuler (pour les logs)
ended_session_id = self.session_id ended_session_id = self.session_id
@@ -607,29 +694,20 @@ class AgentV1:
def run(self): def run(self):
self.ui.run() self.ui.run()
def _headless_keepalive(agent): def _install_signal_handlers(agent, watchdog) -> None:
"""Maintient le main thread vivant quand l'UI tray ne peut pas tourner. """Installe SIGTERM/SIGINT/SIGBREAK pour un arrêt propre du main thread.
Sans cela, ``agent.run()`` retourne immédiatement (pystray échoue quand Met ``agent.running=False`` (les daemon threads s'arrêtent) et réveille
Léa est lancée via SSH sans session interactive Windows), le main thread le watchdog (qui sort de sa boucle de surveillance). Sans session
se termine, et TOUS les daemon threads — y compris ``_replay_poll_loop`` interactive (pystray.Icon.stop indisponible), c'est le SEUL moyen
— meurent avec lui. Observé 3 fois en 24h les 24/05 : d'arrêter Léa proprement : ``kill -TERM <pid>`` ou Ctrl+C.
- 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.
""" """
import signal as _sig import signal as _sig
_stop = threading.Event()
def _handler(sig, frame): def _handler(sig, frame):
logger.info(f"[MAIN] Signal {sig} recu — arret propre") logger.info(f"[MAIN] Signal {sig} recu — arret propre")
_stop.set()
agent.running = False agent.running = False
watchdog.stop()
for sig_name in ("SIGTERM", "SIGINT", "SIGBREAK"): for sig_name in ("SIGTERM", "SIGINT", "SIGBREAK"):
sig_obj = getattr(_sig, sig_name, None) sig_obj = getattr(_sig, sig_name, None)
@@ -640,33 +718,78 @@ def _headless_keepalive(agent):
except (ValueError, OSError): except (ValueError, OSError):
pass pass
logger.info(
"[MAIN] Keepalive headless actif — main thread bloque pour maintenir " def _agent_should_live(agent) -> bool:
"les daemon threads (_replay_poll_loop, heartbeat, capture_server) vivants. " """Vrai tant que Léa doit vivre : agent actif ET pas de Quitter explicite.
"Pour stopper Lea : kill -TERM <pid> ou Ctrl+C."
) 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 _pending_boot_marker_exists() -> bool:
"""True si Lea.bat a posé PENDING_BOOT (boot post-MAJ à valider)."""
try: try:
_stop.wait() from .network.updater import _resolve_app_dir
except KeyboardInterrupt: return (_resolve_app_dir(None) / "PENDING_BOOT").exists()
pass except Exception:
agent.running = False return False
logger.info("[MAIN] Keepalive termine — agent.running=False, daemon threads vont s'arreter")
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(): def main():
agent = AgentV1() from .ui.session_watchdog import InteractiveSessionWatchdog
try:
agent.run()
except Exception:
logger.exception("[MAIN] agent.run() a leve une exception")
if getattr(agent, "running", False): agent = AgentV1()
logger.warning(
"[MAIN] agent.run() est sorti mais agent.running=True — " # Résilience RDP/Citrix : au lieu de bloquer le main thread pour toujours
"probablement pystray sans session interactive (SSH). " # quand pystray sort (session interactive perdue), on surveille la
"Bascule en keepalive headless." # session et on ré-affiche le tray + le chat à chaque reconnexion.
) # agent.run() (== agent.ui.run()) est ré-entrant : les threads de fond
_headless_keepalive(agent) # 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()
# 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:
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__": if __name__ == "__main__":

View File

@@ -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. les fichiers vivants. Retourne un plan d'application.
- `auto_update_enabled()` : lit le flag (défaut OFF). - `auto_update_enabled()` : lit le flag (défaut OFF).
⚠️⚠️ PARTIES DANGEREUSES — RÉSERVÉES RÉVISION HUMAINE ⚠️⚠️ ⚠️ SWAP — répartition claire des responsabilités :
Le remplacement réel des fichiers (`apply_update`), l'écriture du marker `apply_update` / `write_boot_ok_marker` ci-dessous ne font que l'ARMEMENT côté
rollback (`write_boot_ok_marker`), l'édition de `Lea.bat` et le redémarrage Python (extraction vers `agent_v1_new/` + marqueurs) — ils n'écrasent JAMAIS un
ne sont PAS implémentés ici : ce sont des STUBS no-op explicites. Un agent ne fichier vivant. Le remplacement ATOMIQUE (renames), le redémarrage et le
doit pas écrire de code qui écrase des binaires vivants ou relance un process rollback sont faits HORS-PROCESS par `Lea.bat` au démarrage (revu ligne à ligne).
sans supervision. Les points d'extension sont marqués `# TODO swap supervisé`.
Pattern d'import / résilience aligné sur `log_shipper.py` (même branche). 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 from __future__ import annotations
import hashlib import hashlib
import json
import logging import logging
import os import os
import shutil
from pathlib import Path from pathlib import Path
from typing import Callable, Optional, Tuple from typing import Callable, Optional, Tuple
@@ -238,61 +239,243 @@ def download_update(
} }
# =========================================================================== # ---------------------------------------------------------------------------
# ⚠️ ZONE DANGEREUSE — STUBS RÉSERVÉS RÉVISION HUMAINE (NE PAS IMPLÉMENTER # Interrogation serveur — checker INJECTABLE (GET /agents/update/check)
# PAR UN AGENT). Points d'extension explicites, no-op pour l'instant. # ---------------------------------------------------------------------------
# ===========================================================================
def apply_update(prepared: dict) -> dict: def _default_update_checker(local_version: str, machine_id: str):
"""STUB — application réelle de l'update (swap des fichiers). """Interroge le serveur : y a-t-il une MAJ ? (best-effort, INJECTABLE).
Réservé révision humaine : remplacer des fichiers vivants du client et GET SERVER_URL/agents/update/check?current_version=..&machine_id=..
déclencher un swap est trop risqué pour être généré par un agent. La (endpoint gated côté serveur — 503 si RPA_AUTO_UPDATE_SERVER_ENABLED OFF,
mécanique cible (design v2) est : 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.
- 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.
Returns: Returns:
{applied: False, reason: "réservé révision humaine (swap supervisé)"} Le dict réponse serveur (`should_update` sait le lire), ou None si
indisponible / gated / erreur (jamais d'exception ne remonte).
""" """
logger.info( try:
"apply_update appelé mais NON implémenté (stub réservé révision humaine) : %r", import requests # import tardif
prepared.get("target_version") if isinstance(prepared, dict) else prepared,
) 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,
app_dir=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. `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 :
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"),
}
# 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 { return {
"applied": False, "status": "armed" if armed.get("armed") else "arm_failed",
"reason": "réservé révision humaine — swap supervisé (Lea.bat), hors périmètre agent", "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),
"marker": armed.get("marker"),
"error": armed.get("error"),
} }
def write_boot_ok_marker(version: str) -> dict: # ===========================================================================
"""STUB — écriture du marker rollback `boot_ok_{version}` (R1). # 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.
# ===========================================================================
Réservé révision humaine : le marker pilote le rollback de Lea.bat au def _resolve_app_dir(app_dir) -> Path:
prochain démarrage. Sa sémantique (health-check ~60s heartbeat DGX + """Répertoire d'install (contient `agent_v1/`, `run_agent_v1.py`, `Lea.bat`).
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).
# TODO swap supervisé : écrire `%LOCALAPPDATA%\\Lea\\boot_ok_{version}` INJECTABLE (tests : tmp_path). Défaut = parent du package agent_v1.
# après ~60s de heartbeat DGX sain + session active (main.py startup). """
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
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"]` → `<app_dir>/agent_v1_new/`
(nettoyé au préalable ; garde-fou zip-slip).
2. Écrit `<app_dir>/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: Returns:
{written: False, reason: "..."} succès : {armed: True, applied: False, target_version, update_type,
marker, extracted_to}
échec : {armed: False, applied: False, error}
""" """
logger.info( if not isinstance(prepared, dict):
"write_boot_ok_marker appelé mais NON implémenté (stub R1) : version=%s", return {"armed": False, "applied": False, "error": "prepared invalide"}
version, staged_zip = prepared.get("staged_zip")
) target_version = prepared.get("target_version", "unknown")
return { update_type = _normalize_update_type(prepared.get("update_type"))
"written": False, try:
"reason": "réservé révision humaine — marker rollback (health-check), hors périmètre agent", 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, app_dir=None) -> dict:
"""Confirme un boot sain : écrit `boot_ok_{version}` + désarme le rollback.
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).
Best-effort : aucune exception ne remonte.
"""
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)}

View File

@@ -3,6 +3,7 @@ mss>=9.0.1 # Capture d'écran haute performance
pynput>=1.7.7 # Clavier/Souris Cross-plateforme pynput>=1.7.7 # Clavier/Souris Cross-plateforme
Pillow>=10.0.0 # Crops et processing image Pillow>=10.0.0 # Crops et processing image
requests>=2.31.0 # Streaming réseau 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) 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 psutil>=5.9.0 # Monitoring CPU/RAM
screeninfo>=0.8 # QW1 — détection des monitors physiques + offsets screeninfo>=0.8 # QW1 — détection des monitors physiques + offsets

View File

@@ -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.")

View File

@@ -137,6 +137,15 @@ class SmartTrayV1:
self._state_lock = threading.Lock() self._state_lock = threading.Lock()
self._stop_event = threading.Event() 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 # Notifications
self._notifier = NotificationManager() self._notifier = NotificationManager()
@@ -709,6 +718,11 @@ class SmartTrayV1:
"""Arrete proprement l'agent et quitte.""" """Arrete proprement l'agent et quitte."""
logger.info("Arret demande par l'utilisateur") 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 # Arreter la session si en cours
if self.is_recording: if self.is_recording:
self.on_stop() self.on_stop()
@@ -885,17 +899,24 @@ class SmartTrayV1:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def run(self) -> None: def run(self) -> None:
"""Demarre le tray, les threads de fond, et entre dans la boucle principale.""" """Demarre (ou ré-affiche) le tray et entre dans la boucle pystray.
# Notification d'accueil — divulgation IA (Article 50, Reglement IA)
self._notifier.greet()
# Enregistrer le hotkey global Ctrl+Shift+L (toggle chat) Ré-entrant : le watchdog de session (session_watchdog.py) rappelle
self._start_hotkey() 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 # Tooltip avec identifiant machine pour le multi-machine
tray_title = f"Agent V1 - {self.machine_id}" 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( self.icon = pystray.Icon(
"AgentV1", "AgentV1",
self._current_icon(), self._current_icon(),
@@ -903,6 +924,33 @@ class SmartTrayV1:
menu=pystray.Menu(*self._get_menu_items()), 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 # Demarrer le thread de verification connexion
if self.server_client is not None: if self.server_client is not None:
conn_thread = threading.Thread( conn_thread = threading.Thread(
@@ -924,7 +972,3 @@ class SmartTrayV1:
threading.Thread( threading.Thread(
target=self._fetch_workflows, daemon=True target=self._fetch_workflows, daemon=True
).start() ).start()
# Boucle principale pystray (bloquante)
logger.info("SmartTrayV1 demarre — entree dans la boucle pystray")
self.icon.run()

View File

@@ -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'
~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")

View File

@@ -18,8 +18,9 @@ import platform
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from PIL import Image, ImageFilter, ImageStat from PIL import Image, ImageFilter, ImageStat
import mss 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 .blur_sensitive import blur_sensitive_regions
from .capture_io import save_capture
logger = logging.getLogger(__name__) 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 # On ne crée plus self.sct ici car mss n'est pas thread-safe sous Windows
self.last_img_hash = None 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: def capture_full_context(self, name_suffix: str, force=False) -> str:
""" """
Capture l'écran complet. Capture l'écran complet.
@@ -460,9 +473,15 @@ class VisionCapturer:
if BLUR_SENSITIVE: if BLUR_SENSITIVE:
blur_sensitive_regions(img) blur_sensitive_regions(img)
path = os.path.join(self.shots_dir, f"context_{int(time.time())}_{name_suffix}.png") # Politique d'écriture : les heartbeats sont de la liveness pure
img.save(path, "PNG", quality=SCREENSHOT_QUALITY) # (le serveur vérifie juste qu'un écran a changé) → JPEG downscalé.
return path # 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: except Exception as e:
logger.error(f"Erreur Context Capture: {e}") logger.error(f"Erreur Context Capture: {e}")
return "" return ""
@@ -506,10 +525,10 @@ class VisionCapturer:
return result return result
return {} 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) # 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 w, h = TARGETED_CROP_SIZE
left = max(0, x - w // 2) left = max(0, x - w // 2)
top = max(0, y - h // 2) top = max(0, y - h // 2)
@@ -523,8 +542,11 @@ class VisionCapturer:
blur_sensitive_regions(img) blur_sensitive_regions(img)
blur_sensitive_regions(crop_img) blur_sensitive_regions(crop_img)
img.save(full_path, "PNG", quality=SCREENSHOT_QUALITY) # Politique d'écriture : full = vue contextuelle → JPEG q85 ;
crop_img.save(crop_path, "PNG", quality=SCREENSHOT_QUALITY) # 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 # Mise à jour du hash pour le prochain heartbeat
self.last_img_hash = self._compute_quick_hash(img) self.last_img_hash = self._compute_quick_hash(img)
@@ -648,11 +670,12 @@ class VisionCapturer:
if BLUR_SENSITIVE: if BLUR_SENSITIVE:
blur_sensitive_regions(window_img) blur_sensitive_regions(window_img)
# Sauvegarde # Sauvegarde — fenêtre = vue contextuelle → JPEG q85 (politique).
window_path = os.path.join( self._ensure_shots_dir()
self.shots_dir, f"{screenshot_id}_window.png" 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 = { result = {
"window_image": window_path, "window_image": window_path,

View File

@@ -7848,6 +7848,9 @@ async def lea_screen_analyze(payload: _Phase25ScreenRequest, request: Request):
# client + Lea.bat). # client + Lea.bat).
# ========================================================================= # =========================================================================
from .update_check import decide_update as _decide_update # noqa: E402 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: def _auto_update_server_enabled() -> bool:
@@ -7857,14 +7860,25 @@ def _auto_update_server_enabled() -> bool:
) )
def _latest_agent_version() -> str: def _latest_agent_version(machine_id: Optional[str] = None) -> str:
"""Dernière version d'agent disponible côté serveur. """Version d'agent cible POUR CE POSTE (canary-aware, DETTE-022 v2).
Source de vérité minimale (POC) : variable d'environnement ⭐ SÉCURITÉ flotte ⭐ — la version servie est résolue PAR MACHINE via la
RPA_AGENT_LATEST_VERSION. Permet de piloter la fleet sans rebuild. Une politique canary (`update_policy.resolve_target_version_from_env`) : un
évolution future pourra la lire d'un manifeste/DB (cf. design). 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") @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}. 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 GATED : si RPA_AUTO_UPDATE_SERVER_ENABLED n'est pas positionné → 503
(aucun effet sur le pipeline existant — anti-régression). Auth Bearer (aucun effet sur le pipeline existant — anti-régression). Auth Bearer
requise (dépendance globale `_verify_token`). requise (dépendance globale `_verify_token`).
@@ -7891,7 +7909,7 @@ async def check_agent_update(
) )
return _decide_update( return _decide_update(
current_version=current_version, current_version=current_version,
latest_version=_latest_agent_version(), latest_version=_latest_agent_version(machine_id),
update_type=update_type, update_type=update_type,
machine_id=machine_id, machine_id=machine_id,
) )

View File

@@ -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(),
)

View File

@@ -208,6 +208,7 @@ REQUIRED=(
"Lea/python-embed/Lib/site-packages/mss" "Lea/python-embed/Lib/site-packages/mss"
"Lea/python-embed/Lib/site-packages/win32" "Lea/python-embed/Lib/site-packages/win32"
"Lea/python-embed/Lib/site-packages/socketio" "Lea/python-embed/Lib/site-packages/socketio"
"Lea/python-embed/Lib/site-packages/httpx"
) )
MISSING=() MISSING=()
for f in "${REQUIRED[@]}"; do for f in "${REQUIRED[@]}"; do

View File

@@ -81,16 +81,29 @@ cd deploy/installer
wget https://www.python.org/ftp/python/3.12.8/python-3.12.8-embed-amd64.zip wget https://www.python.org/ftp/python/3.12.8/python-3.12.8-embed-amd64.zip
mkdir python-3.12-embed mkdir python-3.12-embed
unzip python-3.12.8-embed-amd64.zip -d 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 Le staging copie automatiquement ce dossier si present. Le composant
"pythonembed" devient alors selectionnable dans l'installeur. "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` 1. Patche `python312._pth` pour activer `import site`
2. Installe `pip` via `get-pip.py` 2. VERIFIE que les dependances sont deja embarquees (offline, aucun pip/reseau) —
3. Installe `requirements_agent.txt` `socketio, tkinter, mss, pynput, pystray, plyer, requests, httpx, PIL, win32api` ;
4. Reecrit `Lea.bat` pour pointer sur `python-embed\pythonw.exe` 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) ## Installation silencieuse (deploiement de masse)

View File

@@ -154,6 +154,7 @@ REQUIRED_EMBED=(
"Lib/site-packages/pystray" "Lib/site-packages/plyer" "Lib/site-packages/pystray" "Lib/site-packages/plyer"
"Lib/site-packages/requests" "Lib/site-packages/PIL" "Lib/site-packages/requests" "Lib/site-packages/PIL"
"Lib/site-packages/win32" "Lib/site-packages/win32"
"Lib/site-packages/httpx" "Lib/site-packages/httpcore" "Lib/site-packages/h11"
) )
MISSING_EMBED=() MISSING_EMBED=()
for f in "${REQUIRED_EMBED[@]}"; do for f in "${REQUIRED_EMBED[@]}"; do

View File

@@ -44,7 +44,7 @@ if ($PthFile) {
# L'embed DOIT contenir toutes les dependances runtime. # L'embed DOIT contenir toutes les dependances runtime.
# AUCUN pip, AUCUN reseau : si une dependance manque -> echec explicite. # 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 = @() $Missing = @()
foreach ($m in $RequiredModules) { foreach ($m in $RequiredModules) {
& $PythonExe -c "import $m" 2>$null & $PythonExe -c "import $m" 2>$null
@@ -76,6 +76,29 @@ if exist "lea_agent.lock" (
timeout /t 2 >nul 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" ( if exist "config.txt" (
for /f "usebackq eol=# tokens=1,* delims==" %%a in ("config.txt") do ( for /f "usebackq eol=# tokens=1,* delims==" %%a in ("config.txt") do (
if not "%%a"=="" if not "%%b"=="" set "%%a=%%b" if not "%%a"=="" if not "%%b"=="" set "%%a=%%b"

View File

@@ -20,6 +20,35 @@ if exist "lea_agent.lock" (
timeout /t 2 >nul 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 :: Verifier que l'installation a ete faite
:: --------------------------------------------------------------- :: ---------------------------------------------------------------

View File

@@ -39,3 +39,12 @@ RPA_USER_LABEL=CONFIGURE_ME
RPA_AGENT_VERSION=1.0.1 RPA_AGENT_VERSION=1.0.1
RPA_BLUR_SENSITIVE=false RPA_BLUR_SENSITIVE=false
RPA_LOG_RETENTION_DAYS=180 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

View File

@@ -5,6 +5,7 @@ mss>=9.0.1 # Capture d'ecran haute performance
pynput>=1.7.7 # Clavier/Souris pynput>=1.7.7 # Clavier/Souris
Pillow>=10.0.0 # Traitement image (crops, compression) Pillow>=10.0.0 # Traitement image (crops, compression)
requests>=2.31.0 # Communication serveur 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 psutil>=5.9.0 # Monitoring CPU/RAM
pystray>=0.19.5 # Icone systray pystray>=0.19.5 # Icone systray
plyer>=2.1.0 # Notifications toast natives plyer>=2.1.0 # Notifications toast natives

View File

@@ -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=<canary>`
+ 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_<version>` **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=<nouvelle>`, `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 <v> 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=<nouvelle>` + 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/<machine_id>` (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)

View File

@@ -83,3 +83,43 @@ class TestUpdateCheckEndpointEnabled:
"/api/v1/agents/update/check?current_version=1.0.1", "/api/v1/agents/update/check?current_version=1.0.1",
) )
assert resp.status_code == 401 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

View File

@@ -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

View File

@@ -206,20 +206,203 @@ 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 _make_zip(path, entries):
def test_apply_update_est_un_stub_non_implemente(self, mod, tmp_path): """Fabrique un ZIP {nom: contenu} pour les tests."""
# Le swap réel est réservé révision humaine : le stub NE TOUCHE RIEN import zipfile
# et signale qu'il n'est pas implémenté. with zipfile.ZipFile(path, "w") as zf:
result = mod.apply_update( for name, content in entries.items():
{"target_version": "1.0.2", "update_type": "code-only", zf.writestr(name, content)
"staged_zip": str(tmp_path / "x.zip")} return path
)
assert result["applied"] is False
assert "human" in result["reason"].lower() or "supervis" in result["reason"].lower()
def test_write_boot_ok_marker_est_un_stub(self, mod):
result = mod.write_boot_ok_marker("1.0.2") class TestApplyUpdateArm:
assert result["written"] is False 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()
# ---------------------------------------------------------------------------
# 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_arme_le_swap_mais_ne_swappe_pas(
self, mod, tmp_path, monkeypatch
):
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")
# 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",
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,
app_dir=app,
)
# 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
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")
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"

View File

@@ -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()

View File

@@ -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"