5 Commits

Author SHA1 Message Date
Dom
c0e4c382be docs(dette): acte DETTE-018/019 (garde-seuil grounding) + inscrit DETTE-015..017
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m45s
tests / Tests unitaires (sans GPU) (push) Failing after 1m51s
tests / Tests sécurité (critique) (push) Has been skipped
DETTE-018: method="grounding_vlm" legacy non gardé par _RESOLUTION_MIN_SCORES
(seul prefixe memory_ traité ; reste = match exact) → Check-1 seuil jamais appliqué
au chemin legacy. Mode qwen3vl ("grounding", seuil 0.60) correctement gardé.
DETTE-019: confiance figée 0.85 en dur dans _resolve_by_grounding (return) pour les
deux modes → garde-seuil (0.60) reçoit toujours 0.85, filtre inopérant.
Découvertes au câblage qwen3vl (5c5ce747b) + validation E2E 2026-06-13 (15/15, 0 dangereux).
Inscrit aussi DETTE-015/016/017 restées non commitées.

refs DETTE-018 DETTE-019

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 09:33:58 +02:00
Dom
5c5ce747b0 feat(grounding): câblage Qwen3-VL-4B/vLLM (RPA_GROUNDING_ENGINE, défaut off)
Active via RPA_GROUNDING_ENGINE=qwen3vl_vllm (défaut OFF = legacy Qwen2.5-VL
inchangé, byte-identique). Mode qwen3vl : port 8001/Qwen3-VL-4B, prompt point
0-1, think=false, parse /1000 (dissout DETTE-006), method "grounding" gardée
(seuil 0.60), pas de fallback Ollama (abstention si vLLM down). Grounder validé
au bench Easily réel (0.933, ~1s/cas). TDD : 4 tests (normalisation 0-1000,
think=false, prompt fractions 0-1, gating score bas).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:39:29 +02:00
Dom
b20d17882e feat(wp-c): méthode verify_token côté registre (patch 3, inerte)
Ajoute AgentRegistry.verify_token(token) -> machine_id|None : compare le
SHA-256 du token aux token_hash des agents 'active' via hmac.compare_digest
(temps constant). Agent désinstallé/révoqué refusé ; rotation à l'enroll
invalide l'ancien token.

Inerte au runtime : méthode non branchée sur l'auth HTTP (le branchement
derrière flag RPA_FLEET_PER_AGENT_TOKEN sera le Patch 4). api_stream.py
intouché. TDD : 6 tests + non-régression WP-C/WP-B (53 verts). Voir
PLAN-WPC-TDD-EXECUTABLE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:21:04 +02:00
Dom
9fb2c7bfee feat(wp-c): génération token par poste à l'enroll (patch 2, inerte runtime)
Génère un token unique (secrets.token_hex(32)) à chaque (ré)enrôlement,
persiste uniquement son empreinte SHA-256 dans token_hash, renseigne
token_issued_at, retourne le clair une seule fois dans le résultat de
enroll. Le clair n'est jamais journalisé ni persisté.

Inerte au runtime : api_stream.py intouché, l'endpoint /agents/enroll ne
propage ni le clair ni le hash (api_token global inchangé). Auth runtime
non modifiée. Aucun branchement _verify_token. TDD : 8 tests + non-régression
WP-B/WP-C (47 verts). Voir PLAN-WPC-TDD-EXECUTABLE / DETTE-015.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:36:44 +02:00
Dom
f7f6926410 feat(wp-c): migration colonnes token par poste (patch 1, inerte)
Ajoute token_hash + token_issued_at à enrolled_agents via ALTER TABLE
idempotent (_init_db). Colonnes inertes : aucun branchement auth, runtime
inchangé (tests WP-B verts). Base du token par poste (WP-C, cf DETTE-015).

TDD: tests/unit/test_wpc_migration.py (présence, idempotence, préservation
des données d'une base existante). 3 tests + non-régression WP-B = 9 passed.

refs DETTE-015

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:04:18 +02:00
7 changed files with 591 additions and 28 deletions

View File

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

View File

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

View File

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

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

View 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

View 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

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