Compare commits
5 Commits
09f65cecbe
...
c0e4c382be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0e4c382be | ||
|
|
5c5ce747b0 | ||
|
|
b20d17882e | ||
|
|
9fb2c7bfee | ||
|
|
f7f6926410 |
@@ -28,13 +28,16 @@ Schema de la table `enrolled_agents` :
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,6 +51,19 @@ def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _new_token() -> Tuple[str, str]:
|
||||
"""WP-C : genere un token poste (clair) et son empreinte SHA-256.
|
||||
|
||||
Le clair est retourne UNE seule fois a l'appelant (resultat de enroll) ; seul
|
||||
le hash est persiste dans `token_hash`. Le clair n'est jamais journalise ni
|
||||
stocke. L'auth runtime reste inchangee (aucun branchement ici sur la
|
||||
verification de token cote api_stream).
|
||||
"""
|
||||
clear = secrets.token_hex(32)
|
||||
token_hash = hashlib.sha256(clear.encode("utf-8")).hexdigest()
|
||||
return clear, token_hash
|
||||
|
||||
|
||||
def _fleet_enroll_locked() -> bool:
|
||||
"""WP-B : parc verrouille -> aucun NOUVEAU machine_id ne peut s'enroler.
|
||||
|
||||
@@ -111,6 +127,20 @@ class AgentRegistry:
|
||||
"CREATE INDEX IF NOT EXISTS idx_enrolled_agents_machine "
|
||||
"ON enrolled_agents(machine_id)"
|
||||
)
|
||||
# WP-C Patch 1 : colonnes « token par poste », migration additive
|
||||
# idempotente. Inertes tant que l'auth par poste n'est pas branchée
|
||||
# (patchs WP-C ultérieurs). Voir DETTE-015.
|
||||
existing_cols = {
|
||||
row[1]
|
||||
for row in conn.execute(
|
||||
"PRAGMA table_info(enrolled_agents)"
|
||||
).fetchall()
|
||||
}
|
||||
for col in ("token_hash", "token_issued_at"):
|
||||
if col not in existing_cols:
|
||||
conn.execute(
|
||||
f"ALTER TABLE enrolled_agents ADD COLUMN {col} TEXT"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lecture
|
||||
@@ -143,6 +173,31 @@ class AgentRegistry:
|
||||
).fetchone()
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
def verify_token(self, token: str | None) -> Optional[str]:
|
||||
"""WP-C : verifie un token poste, retourne le machine_id actif ou None.
|
||||
|
||||
Compare le SHA-256 du token presente aux `token_hash` des agents
|
||||
`status='active'` via `hmac.compare_digest` (comparaison a temps
|
||||
constant, evite les fuites par timing). Un agent desinstalle/revoque
|
||||
n'est pas 'active' donc refuse ; la rotation a l'enrolement invalide
|
||||
l'ancien token.
|
||||
|
||||
INERTE : non branchee sur l'auth runtime (le branchement derriere flag
|
||||
sera le Patch 4). Aucun appelant runtime a ce stade.
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
with _DB_LOCK, self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT machine_id, token_hash FROM enrolled_agents "
|
||||
"WHERE status = 'active' AND token_hash IS NOT NULL"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
if hmac.compare_digest(str(row["token_hash"]), token_hash):
|
||||
return str(row["machine_id"])
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Ecriture
|
||||
# ------------------------------------------------------------------
|
||||
@@ -192,6 +247,8 @@ class AgentRegistry:
|
||||
if not allow_reactivate:
|
||||
raise AgentAlreadyEnrolledError(dict(existing))
|
||||
|
||||
# WP-C : rotation du token a chaque (re)enrolement.
|
||||
token, token_hash = _new_token()
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE enrolled_agents
|
||||
@@ -205,13 +262,17 @@ class AgentRegistry:
|
||||
enrolled_at = ?,
|
||||
last_seen_at = ?,
|
||||
uninstalled_at = NULL,
|
||||
uninstall_reason = NULL
|
||||
uninstall_reason = NULL,
|
||||
token_hash = ?,
|
||||
token_issued_at = ?
|
||||
WHERE machine_id = ?
|
||||
""",
|
||||
(
|
||||
user_name, user_email, user_id,
|
||||
hostname, os_info, version,
|
||||
now, now, machine_id,
|
||||
now, now,
|
||||
token_hash, now,
|
||||
machine_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -219,23 +280,32 @@ class AgentRegistry:
|
||||
"SELECT * FROM enrolled_agents WHERE machine_id = ?",
|
||||
(machine_id,),
|
||||
).fetchone()
|
||||
return {"created": False, "reactivated": True, "agent": dict(row)}
|
||||
return {
|
||||
"created": False,
|
||||
"reactivated": True,
|
||||
"agent": dict(row),
|
||||
"token": token,
|
||||
}
|
||||
|
||||
# Nouvelle inscription — WP-B : refusee si le parc est verrouille
|
||||
if _fleet_enroll_locked():
|
||||
raise FleetEnrollLockedError(machine_id)
|
||||
# WP-C : token poste genere a la creation.
|
||||
token, token_hash = _new_token()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO enrolled_agents (
|
||||
machine_id, user_name, user_email, user_id,
|
||||
hostname, os_info, version,
|
||||
status, enrolled_at, last_seen_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
|
||||
status, enrolled_at, last_seen_at,
|
||||
token_hash, token_issued_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
machine_id, user_name, user_email, user_id,
|
||||
hostname, os_info, version,
|
||||
now, now,
|
||||
token_hash, now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -243,7 +313,12 @@ class AgentRegistry:
|
||||
"SELECT * FROM enrolled_agents WHERE machine_id = ?",
|
||||
(machine_id,),
|
||||
).fetchone()
|
||||
return {"created": True, "reactivated": False, "agent": dict(row)}
|
||||
return {
|
||||
"created": True,
|
||||
"reactivated": False,
|
||||
"agent": dict(row),
|
||||
"token": token,
|
||||
}
|
||||
|
||||
def uninstall(
|
||||
self,
|
||||
|
||||
@@ -953,26 +953,58 @@ def _resolve_by_grounding(
|
||||
import requests as _requests
|
||||
content = ""
|
||||
|
||||
# Port vLLM configurable via env
|
||||
_vllm_port = os.environ.get("VLLM_PORT", "8100")
|
||||
_vllm_model = os.environ.get("VLLM_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct-AWQ")
|
||||
# Grounder POC validé (bench Easily réel 12→13/06, 0.933) : Qwen3-VL-4B/vLLM.
|
||||
# Activé via RPA_GROUNDING_ENGINE=qwen3vl_vllm (défaut OFF = legacy Qwen2.5-VL
|
||||
# inchangé, byte-identique). Le 0.933 est une propriété de
|
||||
# (modèle+moteur+prompt+parser+think) → ce mode reproduit le tuple validé :
|
||||
# prompt point 0-1, think=false, parse /1000 (dissout DETTE-006), method gardée.
|
||||
# Réf design : inbox_codex/2026-06-13_0210_..._DESIGN-CABLAGE-RESOLVE-ENGINE-QWEN3VL.md
|
||||
_grounding_engine = os.environ.get("RPA_GROUNDING_ENGINE", "").strip().lower()
|
||||
_use_qwen3vl = _grounding_engine == "qwen3vl_vllm"
|
||||
|
||||
if _use_qwen3vl:
|
||||
_vllm_port = os.environ.get("VLLM_PORT", "8001")
|
||||
_vllm_model = os.environ.get("VLLM_MODEL", "Qwen/Qwen3-VL-4B-Instruct")
|
||||
_sys_prompt = (
|
||||
"Tu localises une cible sur une capture d'écran d'interface. "
|
||||
"Si la cible n'est pas clairement visible, réponds par une abstention."
|
||||
)
|
||||
_user_text = (
|
||||
f"Cible : « {description} ». Donne le point de clic en FRACTIONS de "
|
||||
"l'image : x et y entre 0.0 et 1.0 (0,0 = coin haut-gauche, "
|
||||
'1,1 = coin bas-droite). Réponds UNIQUEMENT par un JSON '
|
||||
'{"x":0.xx,"y":0.xx} ou {"abstain":true} si la cible n\'est pas '
|
||||
"clairement visible."
|
||||
)
|
||||
else:
|
||||
_vllm_port = os.environ.get("VLLM_PORT", "8100")
|
||||
_vllm_model = os.environ.get("VLLM_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct-AWQ")
|
||||
_sys_prompt = "You locate UI elements on screenshots. Return coordinates."
|
||||
_user_text = prompt
|
||||
|
||||
# Essai 1 : vLLM (API OpenAI-compatible, GPU)
|
||||
try:
|
||||
_vllm_payload = {
|
||||
"model": _vllm_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": _sys_prompt},
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": _user_text},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{shot_b64}"}},
|
||||
]},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 80,
|
||||
}
|
||||
if _use_qwen3vl:
|
||||
# think=false obligatoire (Qwen3-VL/vLLM) : sinon raisonnement →
|
||||
# grounding inutilisable (observé au bench).
|
||||
_vllm_payload["chat_template_kwargs"] = {"enable_thinking": False}
|
||||
_vllm_payload["temperature"] = 0.0
|
||||
_vllm_payload["max_tokens"] = 256
|
||||
vllm_resp = _requests.post(
|
||||
f"http://localhost:{_vllm_port}/v1/chat/completions",
|
||||
json={
|
||||
"model": _vllm_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": "You locate UI elements on screenshots. Return coordinates."},
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{shot_b64}"}},
|
||||
]},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 80,
|
||||
},
|
||||
json=_vllm_payload,
|
||||
timeout=30,
|
||||
)
|
||||
if vllm_resp.ok:
|
||||
@@ -982,8 +1014,11 @@ def _resolve_by_grounding(
|
||||
except Exception as e:
|
||||
logger.debug("vLLM non disponible (%s), fallback Ollama", e)
|
||||
|
||||
# Essai 2 : Ollama (qwen2.5vl:7b pour le grounding — format bbox_2d natif)
|
||||
if not content:
|
||||
# Essai 2 : Ollama (qwen2.5vl:7b pour le grounding — format bbox_2d natif).
|
||||
# En mode qwen3vl_vllm, PAS de fallback Ollama (modèle non-viable/dangereux
|
||||
# prouvé au bench) : si vLLM échoue, on abstient (None) et la cascade externe
|
||||
# (OCR/template/SoM) prend le relais.
|
||||
if not content and not _use_qwen3vl:
|
||||
try:
|
||||
resp = _requests.post("http://localhost:11434/api/chat", json={
|
||||
"model": _grounding_model,
|
||||
@@ -1003,12 +1038,19 @@ def _resolve_by_grounding(
|
||||
elapsed = time.time() - t0
|
||||
|
||||
# Parser la réponse — délégué à core.grounding.bbox_parser
|
||||
x_pct, y_pct = parse_bbox_to_norm(content, small_w, small_h)
|
||||
if _use_qwen3vl:
|
||||
# Qwen3-VL : 0-1 (consigne respectée) OU 0-1000 natif. divisor=1000 gère
|
||||
# les DEUX (xy_json ≤1 pris tel quel ; bbox_2d / valeurs >1 → ÷1000).
|
||||
# Résolution-indépendant → dissout le bug d'échelle DETTE-006.
|
||||
x_pct, y_pct = parse_bbox_to_norm(content, 1000, 1000)
|
||||
else:
|
||||
x_pct, y_pct = parse_bbox_to_norm(content, small_w, small_h)
|
||||
|
||||
if x_pct is None or y_pct is None:
|
||||
# Fallback multi-image : screenshot + crop → grounding sans description
|
||||
# Fallback multi-image : screenshot + crop → grounding sans description.
|
||||
# Skippé en mode qwen3vl_vllm (le fallback s'appuie sur Ollama qwen2.5vl).
|
||||
anchor_b64 = target_spec.get("anchor_image_base64", "")
|
||||
if anchor_b64:
|
||||
if anchor_b64 and not _use_qwen3vl:
|
||||
try:
|
||||
prompt_mi = (
|
||||
"Image 1 is a screenshot. Image 2 shows a UI element.\n"
|
||||
@@ -1073,7 +1115,10 @@ def _resolve_by_grounding(
|
||||
|
||||
return {
|
||||
"resolved": True,
|
||||
"method": "grounding_vlm",
|
||||
# method gardée par _RESOLUTION_MIN_SCORES : en mode qwen3vl, "grounding"
|
||||
# (clé exacte, seuil 0.60) → Check-1 du validateur s'applique. Le legacy
|
||||
# garde "grounding_vlm" (non gardé aujourd'hui — bug latent, DETTE séparée).
|
||||
"method": "grounding" if _use_qwen3vl else "grounding_vlm",
|
||||
"x_pct": round(x_pct, 6),
|
||||
"y_pct": round(y_pct, 6),
|
||||
"matched_element": {
|
||||
|
||||
@@ -30,6 +30,11 @@ P0 / P1 / P2 / P3 (alignées sur convention handoffs)
|
||||
| DETTE-012 | 2026-05-09 | 2026-05-23 | P3 | OPEN | Migration backend grounding vers vLLM (option mentionnée dans plan migration mais infra absente : pas d'install vLLM, pas de service systemd dédié). Choix Transformers direct retenu pour fix DETTE-006. Migration vLLM à instruire séparément si bénéfice mesuré post-démo Kerella. | docs/MIGRATION_VLM_PLAN_2026-05-09.md + investigation infra session 2026-05-09 |
|
||||
| DETTE-013 | 2026-05-09 | 2026-05-23 | P2 | OPEN | Environnement de tests dev local cassé : pytest tests/unit/ déclenche sys.exit(1) via import api_stream sans RPA_API_TOKEN/RPA_AUTH_DISABLED définis (api_stream.py:135, fail-closed sécurité commit 93ef93e56). Combiné avec DETTE-011 (cv2 dans conftest), la batterie de tests unitaires complète n'est pas exécutable en dev local sans configuration environnement spécifique. À documenter (env vars requises) ou refactor (découpler tests purs des tests chargeant api_stream). | session 2026-05-09 (découvert pendant validation refactor bbox_parser) |
|
||||
| DETTE-014 | 2026-05-09 | 2026-05-10 | P1 | OPEN | Module core/grounding/smart_resize.py commité ce matin (commit 0d7bcd18a) calé sur la référence transformers.qwen2_vl.image_processing_qwen2_vl (factor=28, max_pixels=1_003_520). Le checkpoint Qwen3-VL-8B-Instruct utilise en réalité Qwen2VLImageProcessorFast avec patch_size=16 (factor probable 32) et convention size.longest_edge/shortest_edge. À réaligner après investigation DETTE-010 demain. Module pur, testé à 100% sur la convention actuelle — la convention reste valide en référence, mais ne s'applique pas à ce checkpoint. | commit 0d7bcd18a + investigation DETTE-010 du 2026-05-09 |
|
||||
| DETTE-015 | 2026-06-09 | 2026-06-23 | P1 | OPEN | Double stockage des workflows incohérent. La route API VWB `/api/workflows/` lit des fichiers JSON via `WorkflowDatabase("data/workflows")` (api/workflows.py:53, chemin **relatif au cwd**), alors qu'une DB SQLAlchemy propre coexiste (`visual_workflow_builder/backend/instance/workflows.db`, table `workflows` + migrations Alembic). Le worker DGX persiste les workflows appris dans `data/training/workflows/{machine_id}/` en JSON, mais ne les écrit pas dans la DB VWB : l'assimilation Léa fonctionne, mais le workflow appris reste hors source SQLAlchemy/VWB (`2026-06-12`, session M2). Deux sources de vérité non synchronisées → la divergence de `WorkingDirectory` dev (cwd=backend) vs DGX (cwd=racine) a causé le bug « 0 workflows servis » (P0-1, 2026-06-09). Contournement POC en place : symlink `data/workflows` → `backend/data/workflows` (sans sudo, réversible). Fragilités : dépendance au cwd, pas d'écriture atomique/validation schéma, 3e store legacy `data/training/workflows`, pont VWB/Léa existant mais non branché automatiquement post-finalize. Cible consolidation : unifier la persistance workflows sur la DB SQLAlchemy existante (source unique, transactions, review/édition VWB, fin des bugs de cwd), avec bascule progressive sous flag pour ne pas casser le POC DGX. | docs/PLAN_MIGRATION_WORKFLOWS_STORE_2026-06-09.md + RESULTAT-P0-DASHBOARD-CORRECTIONS (2026-06-09) + M2 assimilation DGX 2026-06-12 |
|
||||
| DETTE-016 | 2026-06-10 | 2026-06-24 | P2 | ACCEPTED | Auth agents POC avec `RPA_API_TOKEN` global partagé et `machine_id` auto-déclaré : `_verify_token()` valide le Bearer global et `_guard_agent_registry_access()` vérifie que le `machine_id` déclaré est actif, mais ne prouve pas cryptographiquement que le client est bien ce poste. Risque accepté pour POC contrôlé LAN/non exposé internet ; à reconsidérer avant distribution multi-TIM élargie, exposition réseau non maîtrisée ou exigence de révocation non contournable par poste. WP-C token par poste Patch 1-3 reste local/inerte, Patch 4 runtime annulé pour le POC. | docs/coordination/inbox_codex/2026-06-10_1450_qwen-to-codex-claude-dom_DECISION-WPC-ABANDON-DETTE.md + docs/coordination/active/2026-06-10_1440_anti-doublon-wpc-verdict.md |
|
||||
| DETTE-017 | 2026-06-12 | 2026-06-12 | P0 | OPEN | Auth Bearer **désactivée** (`RPA_AUTH_DISABLED=true`) sur streaming `5005` ET agent-chat `5004` du DGX, appliquée comme « fix » heartbeat B3 (rustine). Démontré inutile : les 3 tokens (DGX proc, DGX `.env.local`, Windows `.env`) sont identiques (SHA256 `43749362b1`, len 43) → l'auth peut être réactivée sans casser le heartbeat. Exposition `0.0.0.0:5004/5005` restreinte par iptables au seul poste `192.168.1.11` ; dashboard `5001` conserve son auth. **Exception temporaire validée par Dom (2026-06-12 09:35) pour test M2 local sur données factices.** ROLLBACK OBLIGATOIRE avant toute sortie clinique / données patient : `RPA_AUTH_DISABLED=false` dans `.env.local` DGX + `sudo systemctl restart rpa-streaming.service rpa-agent-chat.service` puis vérif (401 sans token / 200 avec / heartbeat maintenu). | docs/coordination/active/2026-06-12_0935_decision-dom-auth-off-exception-m2.md + alerte 2026-06-11_1535 |
|
||||
| DETTE-018 | 2026-06-13 | 2026-06-27 | P2 | OPEN | Garde-seuil inopérant sur le chemin grounding **legacy** : `_resolve_by_grounding` retourne `method="grounding_vlm"` (resolve_engine.py:1121, mode `RPA_GROUNDING_ENGINE` OFF), clé absente de `_RESOLUTION_MIN_SCORES` qui ne traite en **préfixe** que `memory_` (toutes les autres clés = match exact) → le Check-1 du validateur (seuil min de confiance) ne s'applique jamais à ce chemin. Le mode `qwen3vl_vllm` est lui correctement gardé (`method="grounding"`, clé exacte, seuil 0.60). Aligner le legacy (clé gardée ou renommage) tant que le mode legacy reste activable. | Découvert au câblage qwen3vl (commit 5c5ce747b) + validation E2E 2026-06-13 |
|
||||
| DETTE-019 | 2026-06-13 | 2026-06-27 | P2 | OPEN | Confiance grounding **figée à `0.85` en dur** dans le `return` de `_resolve_by_grounding` (resolve_engine.py:1128-1130 : `matched_element.confidence` et `score`), pour les DEUX modes (legacy et qwen3vl). Le garde-seuil (0.60) reçoit donc toujours 0.85 quel que soit le grounding réel → le filtre ne discrimine jamais la vraie qualité de localisation. Propager une confiance réelle (signal modèle/cascade) pour rendre le seuil opérant. | Découvert au câblage qwen3vl (commit 5c5ce747b) + validation E2E 2026-06-13 |
|
||||
|
||||
## Convention de référencement
|
||||
|
||||
|
||||
177
tests/unit/test_resolve_engine_qwen3vl_vllm_cabling.py
Normal file
177
tests/unit/test_resolve_engine_qwen3vl_vllm_cabling.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Câblage resolve_engine ← Qwen3-VL-4B/vLLM (grounder POC validé 0.933, nuit 12→13/06).
|
||||
|
||||
Contrat (approche A, env-gated, défaut OFF) : quand RPA_GROUNDING_ENGINE=qwen3vl_vllm,
|
||||
`_resolve_by_grounding` doit :
|
||||
1. parser les coordonnées Qwen3-VL en 0-1000 (divisor=1000), PAS en pixels image
|
||||
→ dissout DETTE-006 (résolution-indépendant) ;
|
||||
2. poser think=false dans le payload vLLM (chat_template_kwargs.enable_thinking=False) ;
|
||||
3. émettre une `method` GARDÉE par _RESOLUTION_MIN_SCORES (sinon Check-1 du
|
||||
validateur est sauté → clic non-gardé).
|
||||
|
||||
Réf design : inbox_codex/2026-06-13_0210_claude-to-codex_DESIGN-CABLAGE-RESOLVE-ENGINE-QWEN3VL.md
|
||||
Le 0.933 est une propriété de (modèle+moteur+prompt+parser+think), pas juste (modèle+moteur).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
def _gated(method: str, table: dict) -> bool:
|
||||
"""Reproduit la logique de matching de _validate_resolution_quality (:2751)."""
|
||||
if method in table:
|
||||
return True
|
||||
return any(p.endswith("_") and method.startswith(p) for p in table)
|
||||
|
||||
|
||||
def _make_vllm_post(captured: list):
|
||||
"""Mock requests.post : vLLM renvoie un bbox Qwen3-VL 0-1000 centré (500,500)."""
|
||||
def fake_post(url, json=None, timeout=None):
|
||||
captured.append({"url": url, "payload": json})
|
||||
resp = MagicMock()
|
||||
if "/v1/chat/completions" in url:
|
||||
resp.ok = True
|
||||
# Qwen3-VL : coordonnées normalisées 0-1000. Centre = (500,500).
|
||||
resp.json.return_value = {
|
||||
"choices": [{"message": {"content": '{"bbox_2d": [490, 490, 510, 510]}'}}]
|
||||
}
|
||||
else: # Ollama fallback ne doit pas être atteint dans ce mode
|
||||
resp.ok = False
|
||||
resp.json.return_value = {"message": {"content": ""}}
|
||||
return resp
|
||||
return fake_post
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_qwen3vl_vllm_grounding_normalise_0_1000_et_method_gardee(monkeypatch, tmp_path):
|
||||
from PIL import Image
|
||||
|
||||
# Image volontairement petite (200x120) : si le code divisait par les
|
||||
# pixels image au lieu de 1000, le centre 500 → 500/200 = 2.5 (hors bornes,
|
||||
# → None). C'est ce qui rend ce test RED sur le code actuel.
|
||||
shot = tmp_path / "shot.png"
|
||||
Image.new("RGB", (200, 120), (255, 255, 255)).save(shot)
|
||||
|
||||
monkeypatch.setenv("RPA_GROUNDING_ENGINE", "qwen3vl_vllm")
|
||||
captured: list = []
|
||||
import requests
|
||||
monkeypatch.setattr(requests, "post", _make_vllm_post(captured))
|
||||
|
||||
from agent_v0.server_v1 import resolve_engine as re_module
|
||||
|
||||
result = re_module._resolve_by_grounding(
|
||||
screenshot_path=str(shot),
|
||||
target_spec={"by_text": "Synthèse"},
|
||||
screen_width=200,
|
||||
screen_height=120,
|
||||
)
|
||||
|
||||
# (1) résolu et normalisé par /1000 → centre ~ (0.5, 0.5)
|
||||
assert result is not None, "Résolution None : coords Qwen3-VL 0-1000 mal normalisées (DETTE-006)"
|
||||
assert abs(result["x_pct"] - 0.5) < 0.02, f"x_pct={result['x_pct']} (attendu ~0.5 via /1000)"
|
||||
assert abs(result["y_pct"] - 0.5) < 0.02, f"y_pct={result['y_pct']} (attendu ~0.5 via /1000)"
|
||||
|
||||
# (3) method gardée par le seuil
|
||||
assert _gated(result["method"], re_module._RESOLUTION_MIN_SCORES), (
|
||||
f"method {result['method']!r} non gardée → Check-1 du validateur sauté (clic non-gardé)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_qwen3vl_vllm_payload_think_false(monkeypatch, tmp_path):
|
||||
from PIL import Image
|
||||
|
||||
shot = tmp_path / "shot.png"
|
||||
Image.new("RGB", (200, 120), (255, 255, 255)).save(shot)
|
||||
|
||||
monkeypatch.setenv("RPA_GROUNDING_ENGINE", "qwen3vl_vllm")
|
||||
captured: list = []
|
||||
import requests
|
||||
monkeypatch.setattr(requests, "post", _make_vllm_post(captured))
|
||||
|
||||
from agent_v0.server_v1 import resolve_engine as re_module
|
||||
re_module._resolve_by_grounding(
|
||||
screenshot_path=str(shot),
|
||||
target_spec={"by_text": "Synthèse"},
|
||||
screen_width=200,
|
||||
screen_height=120,
|
||||
)
|
||||
|
||||
vllm = [c for c in captured if "/v1/chat/completions" in c["url"]]
|
||||
assert vllm, "Aucun appel vLLM capturé"
|
||||
payload = vllm[0]["payload"]
|
||||
# think=false : pour vLLM via chat_template_kwargs.enable_thinking=False
|
||||
cek = payload.get("chat_template_kwargs", {})
|
||||
assert cek.get("enable_thinking") is False, (
|
||||
f"think non désactivé dans payload vLLM : chat_template_kwargs={cek} "
|
||||
f"(Qwen3-VL penserait → grounding inutilisable, cf. bench)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_qwen3vl_vllm_prompt_demande_fractions_0_1(monkeypatch, tmp_path):
|
||||
"""Fidélité au tuple validé (0.933) : le prompt qwen3vl demande un point de
|
||||
clic en FRACTIONS 0-1 (format {"x","y"}), pas un 'bounding box' générique."""
|
||||
from PIL import Image
|
||||
|
||||
shot = tmp_path / "shot.png"
|
||||
Image.new("RGB", (200, 120), (255, 255, 255)).save(shot)
|
||||
|
||||
monkeypatch.setenv("RPA_GROUNDING_ENGINE", "qwen3vl_vllm")
|
||||
captured: list = []
|
||||
import requests
|
||||
monkeypatch.setattr(requests, "post", _make_vllm_post(captured))
|
||||
|
||||
from agent_v0.server_v1 import resolve_engine as re_module
|
||||
re_module._resolve_by_grounding(
|
||||
screenshot_path=str(shot),
|
||||
target_spec={"by_text": "Synthèse"},
|
||||
screen_width=200,
|
||||
screen_height=120,
|
||||
)
|
||||
|
||||
vllm = [c for c in captured if "/v1/chat/completions" in c["url"]]
|
||||
assert vllm, "Aucun appel vLLM capturé"
|
||||
user_txt = ""
|
||||
for m in vllm[0]["payload"]["messages"]:
|
||||
c = m.get("content")
|
||||
if isinstance(c, list):
|
||||
user_txt += " ".join(p.get("text", "") for p in c if isinstance(p, dict))
|
||||
elif isinstance(c, str):
|
||||
user_txt += " " + c
|
||||
|
||||
assert "Synthèse" in user_txt, "cible non injectée dans le prompt"
|
||||
low = user_txt.lower()
|
||||
assert "fraction" in low or ("0.0" in user_txt and "1.0" in user_txt), (
|
||||
f"prompt qwen3vl ne demande pas un point 0-1 (tuple validé non reproduit) : {user_txt!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_qwen3vl_vllm_method_grounding_rejetee_si_score_bas():
|
||||
"""Le method qwen3vl doit activer le garde-seuil, pas sauter Check-1."""
|
||||
from agent_v0.server_v1 import resolve_engine as re_module
|
||||
|
||||
out = re_module._validate_resolution_quality(
|
||||
{
|
||||
"resolved": True,
|
||||
"method": "grounding",
|
||||
"score": 0.10,
|
||||
"x_pct": 0.50,
|
||||
"y_pct": 0.50,
|
||||
},
|
||||
0.50,
|
||||
0.50,
|
||||
)
|
||||
|
||||
assert out is not None
|
||||
assert out["resolved"] is False
|
||||
assert out["method"] == "rejected_low_score_grounding"
|
||||
assert out["original_method"] == "grounding"
|
||||
106
tests/unit/test_wpc_enroll_token.py
Normal file
106
tests/unit/test_wpc_enroll_token.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""WP-C Patch 2 — génération d'un token par poste à l'enrôlement (TDD).
|
||||
|
||||
À chaque enrôlement (création OU réactivation), le registre génère un token
|
||||
unique (`secrets.token_hex(32)`), persiste UNIQUEMENT son empreinte SHA-256 dans
|
||||
`token_hash`, renseigne `token_issued_at`, et retourne le token clair une seule
|
||||
fois dans le résultat de `enroll`. Le clair n'est jamais persisté ni journalisé.
|
||||
|
||||
Auth runtime inchangée : aucun branchement sur `api_stream._verify_token` (ce
|
||||
sera l'objet de patchs WP-C ultérieurs). Voir PLAN-WPC-TDD-EXECUTABLE / DETTE-015.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_v0.server_v1.agent_registry import AgentRegistry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry(tmp_path):
|
||||
return AgentRegistry(db_path=tmp_path / "fleet.db")
|
||||
|
||||
|
||||
def _sha256(s: str) -> str:
|
||||
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _persisted_row(db_path, machine_id):
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM enrolled_agents WHERE machine_id = ?", (machine_id,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_enroll_returns_clear_token(registry):
|
||||
"""L'enrôlement retourne un token clair (hex, 64 caractères)."""
|
||||
res = registry.enroll(machine_id="PC-1")
|
||||
token = res["token"]
|
||||
assert isinstance(token, str)
|
||||
assert len(token) == 64
|
||||
int(token, 16) # doit être hexadécimal valide
|
||||
|
||||
|
||||
def test_token_unique_per_enroll(registry):
|
||||
"""Deux enrôlements distincts produisent deux tokens différents (critère 1)."""
|
||||
t1 = registry.enroll(machine_id="PC-1")["token"]
|
||||
t2 = registry.enroll(machine_id="PC-2")["token"]
|
||||
assert t1 != t2
|
||||
|
||||
|
||||
def test_clear_token_not_persisted(registry, tmp_path):
|
||||
"""Le token clair n'est jamais persisté en base (critère 3)."""
|
||||
token = registry.enroll(machine_id="PC-1")["token"]
|
||||
row = _persisted_row(tmp_path / "fleet.db", "PC-1")
|
||||
for value in row.values():
|
||||
assert value != token
|
||||
|
||||
|
||||
def test_token_hash_persisted_as_sha256(registry, tmp_path):
|
||||
"""token_hash persisté = SHA-256 du clair (critère 4)."""
|
||||
token = registry.enroll(machine_id="PC-1")["token"]
|
||||
row = _persisted_row(tmp_path / "fleet.db", "PC-1")
|
||||
assert row["token_hash"] == _sha256(token)
|
||||
|
||||
|
||||
def test_token_issued_at_set(registry, tmp_path):
|
||||
"""token_issued_at est renseigné à l'enrôlement (critère 5)."""
|
||||
registry.enroll(machine_id="PC-1")
|
||||
row = _persisted_row(tmp_path / "fleet.db", "PC-1")
|
||||
assert row["token_issued_at"] # non NULL / non vide
|
||||
|
||||
|
||||
def test_get_never_exposes_clear_token(registry):
|
||||
"""get() ne renvoie jamais le token clair (retourné une seule fois — critère 2)."""
|
||||
token = registry.enroll(machine_id="PC-1")["token"]
|
||||
agent = registry.get("PC-1")
|
||||
assert "token" not in agent # pas de clé clair persistée
|
||||
for value in agent.values():
|
||||
assert value != token
|
||||
|
||||
|
||||
def test_reactivation_rotates_token(registry, tmp_path):
|
||||
"""La réactivation génère un nouveau token + nouveau hash (critère 1)."""
|
||||
t1 = registry.enroll(machine_id="PC-1")["token"]
|
||||
h1 = _persisted_row(tmp_path / "fleet.db", "PC-1")["token_hash"]
|
||||
registry.uninstall(machine_id="PC-1", reason="user_uninstall")
|
||||
t2 = registry.enroll(machine_id="PC-1")["token"]
|
||||
h2 = _persisted_row(tmp_path / "fleet.db", "PC-1")["token_hash"]
|
||||
assert t2 != t1
|
||||
assert h2 != h1
|
||||
assert h2 == _sha256(t2)
|
||||
|
||||
|
||||
def test_clear_token_never_logged(registry, caplog):
|
||||
"""Le token clair n'apparaît jamais dans les logs (critère 6)."""
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
token = registry.enroll(machine_id="PC-1")["token"]
|
||||
assert token not in caplog.text
|
||||
92
tests/unit/test_wpc_migration.py
Normal file
92
tests/unit/test_wpc_migration.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""WP-C Patch 1 — migration additive idempotente des colonnes « token par poste ».
|
||||
|
||||
Ajoute `token_hash` et `token_issued_at` à la table `enrolled_agents`, sans
|
||||
casser les bases existantes ni perdre de données. Comportement runtime inchangé :
|
||||
les colonnes restent inertes tant que l'auth par poste n'est pas branchée
|
||||
(patchs WP-C ultérieurs). Voir DETTE-015 / PLAN-WPC-TDD-EXECUTABLE.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from agent_v0.server_v1.agent_registry import AgentRegistry
|
||||
|
||||
|
||||
def _columns(db_path) -> set[str]:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
rows = conn.execute("PRAGMA table_info(enrolled_agents)").fetchall()
|
||||
return {r[1] for r in rows}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_token_columns_present_after_init(tmp_path):
|
||||
"""Une base neuve doit contenir les colonnes token dès l'init."""
|
||||
db = tmp_path / "fleet.db"
|
||||
AgentRegistry(db_path=db)
|
||||
cols = _columns(db)
|
||||
assert "token_hash" in cols
|
||||
assert "token_issued_at" in cols
|
||||
|
||||
|
||||
def test_token_columns_idempotent(tmp_path):
|
||||
"""Ré-initialiser plusieurs fois ne doit jamais lever (migration idempotente)."""
|
||||
db = tmp_path / "fleet.db"
|
||||
AgentRegistry(db_path=db)
|
||||
AgentRegistry(db_path=db)
|
||||
AgentRegistry(db_path=db)
|
||||
cols = _columns(db)
|
||||
assert "token_hash" in cols
|
||||
assert "token_issued_at" in cols
|
||||
|
||||
|
||||
def test_migration_preserves_existing_rows(tmp_path):
|
||||
"""Une base ancienne (sans les colonnes) est migrée sans perte de données."""
|
||||
db = tmp_path / "fleet.db"
|
||||
# Ancien schéma, sans les colonnes token, avec une ligne existante.
|
||||
conn = sqlite3.connect(str(db))
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE enrolled_agents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
machine_id TEXT NOT NULL UNIQUE,
|
||||
user_name TEXT,
|
||||
user_email TEXT,
|
||||
user_id TEXT,
|
||||
hostname TEXT,
|
||||
os_info TEXT,
|
||||
version TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
enrolled_at TEXT NOT NULL,
|
||||
last_seen_at TEXT,
|
||||
uninstalled_at TEXT,
|
||||
uninstall_reason TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO enrolled_agents (machine_id, status, enrolled_at) "
|
||||
"VALUES ('PC-LEGACY', 'active', '2026-01-01T00:00:00+00:00')"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Le démarrage du registry doit migrer la base existante.
|
||||
AgentRegistry(db_path=db)
|
||||
|
||||
cols = _columns(db)
|
||||
assert "token_hash" in cols
|
||||
assert "token_issued_at" in cols
|
||||
|
||||
conn = sqlite3.connect(str(db))
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT machine_id, token_hash FROM enrolled_agents "
|
||||
"WHERE machine_id = 'PC-LEGACY'"
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert row is not None
|
||||
assert row[0] == "PC-LEGACY"
|
||||
assert row[1] is None # colonne ajoutée, NULL par défaut
|
||||
63
tests/unit/test_wpc_verify.py
Normal file
63
tests/unit/test_wpc_verify.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""WP-C Patch 3 — vérification d'un token poste (TDD).
|
||||
|
||||
`AgentRegistry.verify_token(token)` retourne le `machine_id` de l'agent **actif**
|
||||
dont l'empreinte SHA-256 correspond, ou `None`. Comparaison à temps constant
|
||||
(`hmac.compare_digest`). Un agent désinstallé/révoqué est refusé, et la rotation
|
||||
(réenrôlement) invalide l'ancien token.
|
||||
|
||||
Méthode INERTE : non branchée sur l'auth runtime (ce sera le Patch 4, derrière
|
||||
flag). Voir PLAN-WPC-TDD-EXECUTABLE.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_v0.server_v1.agent_registry import AgentRegistry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry(tmp_path):
|
||||
return AgentRegistry(db_path=tmp_path / "fleet.db")
|
||||
|
||||
|
||||
def test_verify_token_accepts_valid(registry):
|
||||
"""Un token issu d'un enrôlement actif est reconnu (→ machine_id)."""
|
||||
token = registry.enroll(machine_id="PC-1")["token"]
|
||||
assert registry.verify_token(token) == "PC-1"
|
||||
|
||||
|
||||
def test_verify_token_rejects_unknown(registry):
|
||||
"""Un token inconnu est refusé (→ None)."""
|
||||
registry.enroll(machine_id="PC-1")
|
||||
assert registry.verify_token("deadbeef" * 8) is None
|
||||
|
||||
|
||||
def test_verify_token_rejects_empty(registry):
|
||||
"""Token vide ou None → None (pas d'exception)."""
|
||||
registry.enroll(machine_id="PC-1")
|
||||
assert registry.verify_token("") is None
|
||||
assert registry.verify_token(None) is None
|
||||
|
||||
|
||||
def test_verify_token_rejects_after_uninstall(registry):
|
||||
"""Après désinstallation, le token n'est plus accepté (agent non actif)."""
|
||||
token = registry.enroll(machine_id="PC-1")["token"]
|
||||
registry.uninstall(machine_id="PC-1", reason="user_uninstall")
|
||||
assert registry.verify_token(token) is None
|
||||
|
||||
|
||||
def test_verify_token_rotation_invalidates_old(registry):
|
||||
"""La réactivation génère un nouveau token ; l'ancien est invalidé."""
|
||||
t1 = registry.enroll(machine_id="PC-1")["token"]
|
||||
registry.uninstall(machine_id="PC-1", reason="user_uninstall")
|
||||
t2 = registry.enroll(machine_id="PC-1")["token"]
|
||||
assert registry.verify_token(t2) == "PC-1"
|
||||
assert registry.verify_token(t1) is None
|
||||
|
||||
|
||||
def test_verify_token_distinguishes_agents(registry):
|
||||
"""Chaque token actif ne reconnaît que son propre poste."""
|
||||
t1 = registry.enroll(machine_id="PC-1")["token"]
|
||||
t2 = registry.enroll(machine_id="PC-2")["token"]
|
||||
assert registry.verify_token(t1) == "PC-1"
|
||||
assert registry.verify_token(t2) == "PC-2"
|
||||
Reference in New Issue
Block a user