feat: journée 17 avril — tests E2E validés, dashboard fleet+audit, VWB bridge, cleaner C2
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped

Pipeline E2E complet validé :
  Capture VM → streaming → serveur → cleaner → replay → audit trail
  Mode apprentissage supervisé fonctionne (Léa échoue → humain → reprise)

Dashboard :
  - Cleanup 14→10 onglets (RCE supprimée)
  - Fleet : enregistrer/révoquer agents, tokens, ZIP pré-configuré téléchargeable
  - Audit trail MVP (/audit) : filtres, tableau, export CSV, conformité AI Act/RGPD
  - Formulaire Fleet simplifié (nom + email, machine_id auto)

VWB bridge Léa→VWB :
  - Compound décomposés en N steps (saisie + raccourci visibles)
  - Layout serpentin 3 colonnes (plus colonne verticale)
  - Badge OS 🪟/🐧, filtre OS retiré (admin Linux voit Windows)
  - Fix import SQLite readonly

Cleaner intelligent :
  - Descriptions lisibles (UIA/C2) + détection doublons
  - Logique C2 : UIElement identifié = jamais parasite
  - Patterns parasites resserrés
  - Message Léa : "Je n'y arrive pas, montrez-moi comment faire"

Config agent (INC-1 à INC-7) :
  - SERVER_URL + SERVER_BASE unifiés
  - RPA_OLLAMA_HOST séparé
  - allow_redirects=False sur POST
  - Middleware réécriture URL serveur

CI Gitea : fix token + Flask-SocketIO + ruff propre
Fleet endpoints : /agents/enroll|uninstall|fleet + agent_registry SQLite
Backup : script quotidien workflows.db + audit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-17 17:46:40 +02:00
parent 2fa864b5c7
commit 4f61741420
27 changed files with 5088 additions and 1543 deletions

View File

@@ -40,10 +40,18 @@ MACHINE_ID = os.environ.get(
BASE_DIR = Path(__file__).resolve().parent
# Endpoint du serveur Streaming (port 5005)
# SERVER_URL contient TOUJOURS /api/v1 à la fin (convention unifiée).
SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:5005/api/v1")
# Base sans /api/v1 — pour les routes à la racine (/health)
SERVER_BASE = SERVER_URL.rsplit("/api/v1", 1)[0]
UPLOAD_ENDPOINT = f"{SERVER_URL}/traces/upload"
STREAMING_ENDPOINT = f"{SERVER_URL}/traces/stream"
# Host Ollama — SÉPARÉ du serveur RPA.
# Ollama tourne en local sur la machine serveur, jamais exposé via le reverse proxy.
# Défaut : localhost (exécution locale ou accès LAN direct).
OLLAMA_HOST = os.getenv("RPA_OLLAMA_HOST", "localhost")
# Token d'authentification API (doit correspondre au token du serveur)
# Configurable via variable d'environnement RPA_API_TOKEN
API_TOKEN = os.environ.get("RPA_API_TOKEN", "")

View File

@@ -477,9 +477,15 @@ class ActionExecutorV1:
},
headers=headers,
timeout=10,
allow_redirects=False,
)
if resp.ok:
if resp.status_code in (301, 302, 307, 308):
logger.warning(
f"Redirection {resp.status_code} sur POST {url}"
f"verifiez RPA_SERVER_URL (https:// si redirect)"
)
elif resp.ok:
data = resp.json()
state = data.get("screen_state", "ok")
if state != "ok":
@@ -703,7 +709,11 @@ class ActionExecutorV1:
f"attendu '{expected_title}' → mode apprentissage"
)
try:
self.notifier.replay_wrong_window(current_title, expected_title)
self.notifier.replay_learning_mode(
raison="wrong_window",
target_description=expected_title,
window_title=current_title,
)
except Exception:
pass
@@ -935,9 +945,10 @@ class ActionExecutorV1:
# et ne trouve toujours pas. L'humain doit montrer.
print(f" [POLICY] Retry échoué → mode apprentissage")
try:
self.notifier.replay_target_not_found(
target_desc,
target_spec.get("window_title", ""),
self.notifier.replay_learning_mode(
raison="retry_failed",
target_description=target_desc,
window_title=target_spec.get("window_title", ""),
)
except Exception:
pass
@@ -993,9 +1004,10 @@ class ActionExecutorV1:
# passe en mode capture et enregistre ce que
# l'humain fait (mini-workflow de correction).
try:
self.notifier.replay_target_not_found(
target_desc,
target_spec.get("window_title", ""),
self.notifier.replay_learning_mode(
raison="supervise",
target_description=target_desc,
window_title=target_spec.get("window_title", ""),
)
except Exception:
pass
@@ -1221,7 +1233,9 @@ class ActionExecutorV1:
f"je demande de l'aide"
)
try:
self.notifier.replay_no_screen_change(action_type)
self.notifier.replay_learning_mode(
raison="no_screen_change",
)
except Exception:
pass
@@ -1377,7 +1391,13 @@ class ActionExecutorV1:
try:
print(f" [SERVER-RESOLVE] Appel serveur {server_url}...")
resp = _requests.post(url, json=payload, headers=headers, timeout=30)
resp = _requests.post(url, json=payload, headers=headers, timeout=30, allow_redirects=False)
if resp.status_code in (301, 302, 307, 308):
logger.warning(
f"Redirection {resp.status_code} sur POST {url}"
f"verifiez RPA_SERVER_URL (https:// si redirect)"
)
return None
if not resp.ok:
logger.warning(f"Server resolve HTTP {resp.status_code}")
return None
@@ -1521,7 +1541,7 @@ class ActionExecutorV1:
if not vlm_description:
return None
ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost")
ollama_host = os.environ.get("RPA_OLLAMA_HOST", "localhost")
ollama_url = f"http://{ollama_host}:11434/api/chat"
prompt = (
@@ -1657,7 +1677,7 @@ Example: x_pct=0.50, y_pct=0.30"""
if anchor_b64:
images.append(anchor_b64)
ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost")
ollama_host = os.environ.get("RPA_OLLAMA_HOST", "localhost")
ollama_url = f"http://{ollama_host}:11434/api/chat"
# Prefill pour les modèles thinking (qwen3) — évite le mode réflexion >180s
@@ -1861,8 +1881,14 @@ Example: x_pct=0.50, y_pct=0.30"""
json=report,
headers=self._auth_headers(),
timeout=10,
allow_redirects=False,
)
if resp2.ok:
if resp2.status_code in (301, 302, 307, 308):
logger.warning(
f"Redirection {resp2.status_code} sur POST {replay_result_url}"
f"verifiez RPA_SERVER_URL (https:// si redirect)"
)
elif resp2.ok:
server_resp = resp2.json()
msg = (
f"Resultat rapporte : replay_status={server_resp.get('replay_status')}, "
@@ -2128,7 +2154,7 @@ Example: x_pct=0.50, y_pct=0.30"""
"""
import requests as _requests
ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost")
ollama_host = os.environ.get("RPA_OLLAMA_HOST", "localhost")
ollama_url = f"http://{ollama_host}:11434/api/chat"
prompt = (
@@ -2575,8 +2601,8 @@ Example: x_pct=0.50, y_pct=0.30"""
f"inactivité={INACTIVITY_TIMEOUT}s, hotkey=Ctrl+Shift+L)"
)
print(
f" [APPRENTISSAGE] Montre-moi comment faire.\n"
f" Quand tu as fini → Ctrl+Shift+L\n"
f" [APPRENTISSAGE] Je n'y arrive pas, montrez-moi comment faire.\n"
f" Quand vous avez fini → Ctrl+Shift+L\n"
f" (ou j'attends {INACTIVITY_TIMEOUT}s sans action)"
)

View File

@@ -17,6 +17,7 @@ import threading
from .config import (
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS,
SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, API_TOKEN, MAX_SESSION_DURATION_S,
STREAMING_ENDPOINT,
)
from .core.captor import EventCaptorV1
from .core.executor import ActionExecutorV1
@@ -86,22 +87,23 @@ class AgentV1:
self._state.set_on_stop(self.stop_session)
# Client serveur pour le chat et les workflows
# Plus de RPA_SERVER_HOST : le LeaServerClient derive tout de SERVER_URL
self._server_client = None
if LeaServerClient is not None:
# Forcer le token API pour éviter les 401
# (le token est set par start.bat dans l'environnement)
from .config import API_TOKEN as _token
server_host = os.getenv("RPA_SERVER_HOST", "localhost")
self._server_client = LeaServerClient(server_host=server_host)
self._server_client = LeaServerClient()
if _token and not self._server_client._api_token:
self._server_client._api_token = _token
logger.info("Token API forcé dans LeaServerClient")
# Fenetre de chat Lea (tkinter natif)
# Le host est derive de SERVER_URL (plus de RPA_SERVER_HOST)
server_host = (
self._server_client.server_host
if self._server_client is not None
else os.getenv("RPA_SERVER_HOST", "localhost")
else "localhost"
)
self._chat_window = ChatWindow(
server_client=self._server_client,
@@ -363,11 +365,11 @@ class AgentV1:
continue
self._last_bg_hash = img_hash
# Envoyer au streaming server (avec token auth)
# Envoyer au streaming server (via STREAMING_ENDPOINT unifié)
headers = {"Authorization": f"Bearer {API_TOKEN}"} if API_TOKEN else {}
with open(full_path, 'rb') as f:
req.post(
f"{SERVER_URL}/traces/stream/image",
f"{STREAMING_ENDPOINT}/image",
params={
"session_id": bg_session,
"shot_id": f"heartbeat_{int(time.time())}",
@@ -376,6 +378,7 @@ class AgentV1:
headers=headers,
files={"file": ("screenshot.png", f, "image/png")},
timeout=10,
allow_redirects=False,
)
except Exception as e:
logger.debug(f"[HEARTBEAT] Erreur: {e}")

View File

@@ -544,6 +544,28 @@ class TraceStreamer:
except OSError as e:
logger.debug(f"Purge échouée : {path}{e}")
# =========================================================================
# Protection redirect POST→GET (INC-7)
# =========================================================================
@staticmethod
def _check_redirect(resp, url: str):
"""Detecter et logger une redirection sur un POST.
La lib requests transforme un POST en GET sur 301/302 (RFC 7231).
Avec allow_redirects=False, on recoit le 301/302 directement.
On log un WARNING explicite pour que l'admin corrige l'URL.
"""
if resp.status_code in (301, 302, 307, 308):
location = resp.headers.get("Location", "?")
logger.warning(
f"Redirection {resp.status_code} detectee sur POST {url} "
f"{location}. Verifiez que RPA_SERVER_URL utilise "
f"https:// si le serveur redirige."
)
return True
return False
# =========================================================================
# Envois HTTP
# =========================================================================
@@ -551,15 +573,20 @@ class TraceStreamer:
def _register_session(self):
"""Enregistrer la session auprès du serveur (avec identifiant machine)."""
try:
url = f"{STREAMING_ENDPOINT}/register"
resp = requests.post(
f"{STREAMING_ENDPOINT}/register",
url,
params={
"session_id": self.session_id,
"machine_id": self.machine_id,
},
headers=self._auth_headers(),
timeout=3,
allow_redirects=False,
)
if self._check_redirect(resp, url):
logger.warning("Enregistrement session échoué (redirect)")
return
if resp.ok:
logger.info(
f"Session {self.session_id} enregistrée sur le serveur "
@@ -579,15 +606,18 @@ class TraceStreamer:
C'est la dernière chance de sauver les données de la session.
"""
try:
url = f"{STREAMING_ENDPOINT}/finalize"
resp = requests.post(
f"{STREAMING_ENDPOINT}/finalize",
url,
params={
"session_id": self.session_id,
"machine_id": self.machine_id,
},
headers=self._auth_headers(),
timeout=30, # Le build workflow peut prendre du temps
allow_redirects=False,
)
self._check_redirect(resp, url)
if resp.ok:
result = resp.json()
logger.info(f"Session finalisée: {result}")
@@ -601,6 +631,7 @@ class TraceStreamer:
if not self._server_available:
return False
try:
url = f"{STREAMING_ENDPOINT}/event"
payload = {
"session_id": self.session_id,
"timestamp": time.time(),
@@ -608,11 +639,14 @@ class TraceStreamer:
"machine_id": self.machine_id,
}
resp = requests.post(
f"{STREAMING_ENDPOINT}/event",
url,
json=payload,
headers=self._auth_headers(),
timeout=2,
allow_redirects=False,
)
if self._check_redirect(resp, url):
return False
return resp.ok
except Exception as e:
logger.debug(f"Streaming Event échoué: {e}")
@@ -645,18 +679,22 @@ class TraceStreamer:
"machine_id": self.machine_id,
}
url = f"{STREAMING_ENDPOINT}/image"
if jpeg_buf is not None:
# Envoi du JPEG compressé (BytesIO, pas de fuite possible)
files = {
"file": (f"{shot_id}{suffix}", jpeg_buf, content_type)
}
resp = requests.post(
f"{STREAMING_ENDPOINT}/image",
url,
files=files,
params=params,
headers=self._auth_headers(),
timeout=5,
allow_redirects=False,
)
if self._check_redirect(resp, url):
return ImageSendResult.FAILED
if resp.ok:
self._purge_local_image(path)
return ImageSendResult.OK
@@ -668,12 +706,15 @@ class TraceStreamer:
"file": (f"{shot_id}.png", f, "image/png")
}
resp = requests.post(
f"{STREAMING_ENDPOINT}/image",
url,
files=files,
params=params,
headers=self._auth_headers(),
timeout=5,
allow_redirects=False,
)
if self._check_redirect(resp, url):
return ImageSendResult.FAILED
if resp.ok:
self._purge_local_image(path)
return ImageSendResult.OK

View File

@@ -293,6 +293,49 @@ def formatter_ecran_inchange(action_type: str = "") -> MessageUtilisateur:
)
def formatter_mode_apprentissage(
raison: str = "",
description_cible: str = "",
titre_fenetre: Optional[str] = None,
) -> MessageUtilisateur:
"""Message quand Léa passe en mode apprentissage (pause supervisée).
L'utilisateur doit comprendre :
1. Léa est bloquée et a besoin d'aide
2. L'utilisateur doit prendre la main et montrer comment faire
3. Ctrl+Shift+L pour signaler qu'il a fini
Le ton est humble, clair, actionnable. Pas technique.
Exemple :
Léa a besoin d'aide
Je n'y arrive pas, montrez-moi comment faire.
Quand vous avez fini, appuyez sur Ctrl+Shift+L.
"""
cible = _nettoyer_description_cible(description_cible) if description_cible else ""
app = _extraire_nom_application(titre_fenetre or "") if titre_fenetre else ""
# Construire un contexte court si disponible
contexte = ""
if cible and app:
contexte = f"{cible} » dans {app})"
elif cible:
contexte = f"{cible} »)"
corps = (
f"Je n'y arrive pas{contexte}, montrez-moi comment faire. "
f"Quand vous avez fini, appuyez sur Ctrl+Shift+L."
)
return MessageUtilisateur(
niveau=NiveauMessage.BLOCAGE,
titre="Léa a besoin d'aide",
corps=corps,
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.BLOCAGE],
persistent=True,
)
def formatter_connexion_perdue(hote_serveur: str = "") -> MessageUtilisateur:
"""Message quand la connexion avec le serveur est perdue.

View File

@@ -32,6 +32,7 @@ from .messages import (
formatter_etape_workflow,
formatter_fenetre_incorrecte,
formatter_fin_workflow,
formatter_mode_apprentissage,
formatter_ralentissement,
formatter_retry,
)
@@ -273,6 +274,20 @@ class NotificationManager:
msg = formatter_ecran_inchange(action_type)
return self.notify_message(msg)
def replay_learning_mode(
self,
raison: str = "",
target_description: str = "",
window_title: Optional[str] = None,
) -> bool:
"""Notification quand Léa passe en mode apprentissage.
Léa est bloquée et demande à l'utilisateur de montrer comment faire.
Message humble et actionnable pour un utilisateur non technique.
"""
msg = formatter_mode_apprentissage(raison, target_description, window_title)
return self.notify_message(msg)
def replay_retry(self, action_type: str = "", tentative: int = 2) -> bool:
"""Notification quand Léa retente une action."""
msg = formatter_retry(action_type, tentative)

View File

@@ -21,36 +21,33 @@ from typing import Any, Callable, Dict, List, Optional
logger = logging.getLogger("lea_ui.server_client")
def _get_server_host() -> str:
"""Recuperer l'adresse du serveur Linux.
def _get_server_url() -> str:
"""Recuperer l'URL du serveur RPA (avec /api/v1).
Ordre de resolution :
1. Variable d'environnement RPA_SERVER_HOST
2. Fichier de config agent_config.json (cle "server_host")
3. Fallback localhost
1. Import depuis agent_v1.config (source de verite unique)
2. Variable d'environnement RPA_SERVER_URL
3. Fallback http://localhost:5005/api/v1
"""
# 1. Variable d'environnement
host = os.environ.get("RPA_SERVER_HOST", "").strip()
if host:
return host
# 1. Import depuis config.py (source de verite)
try:
from agent_v1.config import SERVER_URL
return SERVER_URL
except ImportError:
pass
# 2. Fichier de config
config_paths = [
os.path.join(os.path.dirname(__file__), "..", "agent_config.json"),
os.path.join(os.path.dirname(__file__), "..", "..", "agent_config.json"),
]
for config_path in config_paths:
try:
with open(config_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
host = cfg.get("server_host", "").strip()
if host:
return host
except (OSError, json.JSONDecodeError):
continue
# 2. Variable d'environnement directe
url = os.environ.get("RPA_SERVER_URL", "").strip().rstrip("/")
if url:
return url
# 3. Fallback
return "localhost"
return "http://localhost:5005/api/v1"
def _get_server_base(server_url: str) -> str:
"""Extraire la base URL (sans /api/v1) pour les routes racine (/health)."""
return server_url.rsplit("/api/v1", 1)[0]
class LeaServerClient:
@@ -67,19 +64,22 @@ class LeaServerClient:
chat_port: int = 5004,
stream_port: int = 5005,
) -> None:
self._host = server_host or _get_server_host()
# URL unifiée : SERVER_URL contient TOUJOURS /api/v1 (convention INC-1).
# _stream_url = URL avec /api/v1 (pour les routes API)
# _stream_base = URL sans /api/v1 (pour /health uniquement)
self._stream_url = _get_server_url()
self._stream_base = _get_server_base(self._stream_url)
# Extraire le host depuis l'URL pour le chat et pour l'affichage
try:
from urllib.parse import urlparse
parsed = urlparse(self._stream_base)
self._host = parsed.hostname or "localhost"
except Exception:
self._host = server_host or "localhost"
self._chat_port = chat_port
self._stream_port = stream_port
# En prod, la base URL passe par le reverse proxy HTTPS
# (ex. https://lea.labs.laurinebazin.design). Si RPA_SERVER_URL est
# definie on l'utilise telle quelle, sinon on reconstruit http://host:port.
server_url = os.environ.get("RPA_SERVER_URL", "").strip().rstrip("/")
if server_url:
self._stream_base = server_url
else:
self._stream_base = f"http://{self._host}:{self._stream_port}"
self._chat_base = f"http://{self._host}:{self._chat_port}"
# Etat de connexion
@@ -103,8 +103,8 @@ class LeaServerClient:
self._api_token = os.environ.get("RPA_API_TOKEN", "")
logger.info(
"LeaServerClient initialise : chat=%s, stream=%s",
self._chat_base, self._stream_base,
"LeaServerClient initialise : chat=%s, stream_url=%s, stream_base=%s",
self._chat_base, self._stream_url, self._stream_base,
)
# ---------------------------------------------------------------------------
@@ -154,7 +154,11 @@ class LeaServerClient:
# ---------------------------------------------------------------------------
def check_connection(self) -> bool:
"""Tester la connexion au serveur streaming (port 5005)."""
"""Tester la connexion au serveur streaming (port 5005).
Le health check utilise _stream_base (sans /api/v1) car la route
/health est a la racine du serveur FastAPI, pas sous /api/v1.
"""
try:
import requests
resp = requests.get(
@@ -227,7 +231,7 @@ class LeaServerClient:
import requests
headers = self._auth_headers()
resp = requests.get(
f"{self._stream_base}/api/v1/traces/stream/workflows",
f"{self._stream_url}/traces/stream/workflows",
headers=headers,
timeout=10,
)
@@ -284,7 +288,7 @@ class LeaServerClient:
while self._polling:
try:
resp = req_lib.get(
f"{self._stream_base}/api/v1/traces/stream/replay/next",
f"{self._stream_url}/traces/stream/replay/next",
params={"session_id": self._poll_session_id},
headers=self._auth_headers(),
timeout=5,
@@ -318,7 +322,7 @@ class LeaServerClient:
try:
import requests
resp = requests.get(
f"{self._stream_base}/api/v1/traces/stream/replays",
f"{self._stream_url}/traces/stream/replays",
headers=self._auth_headers(),
timeout=5,
)
@@ -346,7 +350,7 @@ class LeaServerClient:
try:
import requests
requests.post(
f"{self._stream_base}/api/v1/traces/stream/replay/result",
f"{self._stream_url}/traces/stream/replay/result",
json={
"session_id": session_id,
"action_id": action_id,

View File

@@ -292,6 +292,20 @@ app.add_middleware(
)
@app.middleware("http")
async def url_compat_rewrite(request: Request, call_next):
"""Rétrocompatibilité : réécriture des anciennes URLs sans préfixe /api/v1.
Certains agents clients (Léa V1 gelée) envoient sur /traces/stream/...
au lieu de /api/v1/traces/stream/... Ce middleware redirige silencieusement.
"""
path = request.url.path
if path.startswith("/traces/stream/") and not path.startswith("/api/v1/"):
new_path = "/api/v1" + path
request.scope["path"] = new_path
return await call_next(request)
@app.middleware("http")
async def security_headers_middleware(request: Request, call_next):
"""Ajouter les headers de sécurité sur toutes les réponses."""