fix(p0): secure agent revocation and R6 worker queue

This commit is contained in:
Dom
2026-06-02 15:52:35 +02:00
parent 2dd306724c
commit 7a1a5cb6fd
11 changed files with 2869 additions and 109 deletions

View File

@@ -15,13 +15,20 @@ if str(ROOT) not in sys.path:
@pytest.fixture
def isolated_replay_state(monkeypatch):
def isolated_replay_state(monkeypatch, tmp_path):
monkeypatch.setenv("RPA_API_TOKEN", "test_replay_single_inflight_token")
from agent_v0.server_v1 import api_stream
from agent_v0.server_v1.agent_registry import AgentRegistry
monkeypatch.setattr(api_stream, "API_TOKEN", "test_replay_single_inflight_token")
# Isoler le registre pour que _agent_registry_has_entries() retourne False
# (mode dev, aucun agent enrolle) — sinon le garde fleet bloque les tests
original_registry = api_stream.agent_registry
empty_registry = AgentRegistry(db_path=str(tmp_path / "empty_agents.db"))
monkeypatch.setattr(api_stream, "agent_registry", empty_registry)
if api_stream._replay_lock.locked():
pytest.fail(
"_replay_lock is already held at fixture setup — a previous test "
@@ -53,6 +60,7 @@ def isolated_replay_state(monkeypatch):
api_stream._machine_replay_target.update(saved_targets)
api_stream._last_heartbeat.clear()
api_stream._last_heartbeat.update(saved_heartbeat)
monkeypatch.setattr(api_stream, "agent_registry", original_registry)
def _running_replay_state(

View File

@@ -11,6 +11,7 @@ import shutil
import sys
import tempfile
import threading
import types
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -171,6 +172,271 @@ class TestLiveSessionManager:
class TestStreamProcessor:
def test_default_initialization_stays_light(self, temp_dir):
"""Par défaut, l'API HTTP ne charge pas les composants VLM/GPU."""
from agent_v0.server_v1.stream_processor import StreamProcessor
test_processor = StreamProcessor(data_dir=temp_dir)
test_processor._ensure_initialized()
assert test_processor._initialized is True
assert test_processor._screen_analyzer is None
assert test_processor._clip_embedder is None
assert test_processor._faiss_manager is None
def test_enable_vlm_initialization_loads_components(self, temp_dir, monkeypatch):
"""Le worker VLM peut explicitement charger ScreenAnalyzer/CLIP/FAISS."""
from agent_v0.server_v1.stream_processor import StreamProcessor
screen_module = types.ModuleType("core.pipeline.screen_analyzer")
clip_module = types.ModuleType("core.embedding.clip_embedder")
state_module = types.ModuleType("core.embedding.state_embedding_builder")
faiss_module = types.ModuleType("core.embedding.faiss_manager")
class FakeScreenAnalyzer:
def __init__(self, session_id=""):
self.session_id = session_id
class FakeCLIPEmbedder:
pass
class FakeStateEmbeddingBuilder:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
class FakeFAISSManager:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
self.index = MagicMock(ntotal=0)
screen_module.ScreenAnalyzer = FakeScreenAnalyzer
clip_module.CLIPEmbedder = FakeCLIPEmbedder
state_module.StateEmbeddingBuilder = FakeStateEmbeddingBuilder
faiss_module.FAISSManager = FakeFAISSManager
monkeypatch.setitem(sys.modules, "core.pipeline.screen_analyzer", screen_module)
monkeypatch.setitem(sys.modules, "core.embedding.clip_embedder", clip_module)
monkeypatch.setitem(sys.modules, "core.embedding.state_embedding_builder", state_module)
monkeypatch.setitem(sys.modules, "core.embedding.faiss_manager", faiss_module)
test_processor = StreamProcessor(data_dir=temp_dir, enable_vlm=True)
test_processor._ensure_initialized()
assert test_processor._initialized is True
assert isinstance(test_processor._screen_analyzer, FakeScreenAnalyzer)
assert isinstance(test_processor._clip_embedder, FakeCLIPEmbedder)
assert isinstance(test_processor._state_embedding_builder, FakeStateEmbeddingBuilder)
assert isinstance(test_processor._faiss_manager, FakeFAISSManager)
def test_enable_vlm_screen_analyzer_failure_does_not_cache_broken_state(
self, temp_dir, monkeypatch, caplog
):
"""N1 anti-poison : en mode VLM, si ScreenAnalyzer échoue à l'init, ne PAS
figer _initialized=True (sinon le worker reste cassé à vie, cf. blocage R6
des 5 jours). Doit logger en critical et permettre un retry au cycle suivant.
"""
import logging
from agent_v0.server_v1.stream_processor import StreamProcessor
screen_module = types.ModuleType("core.pipeline.screen_analyzer")
clip_module = types.ModuleType("core.embedding.clip_embedder")
state_module = types.ModuleType("core.embedding.state_embedding_builder")
faiss_module = types.ModuleType("core.embedding.faiss_manager")
class BrokenScreenAnalyzer:
def __init__(self, session_id=""):
raise RuntimeError("CUDA indisponible au démarrage du worker")
class HealedScreenAnalyzer:
def __init__(self, session_id=""):
self.session_id = session_id
class FakeCLIPEmbedder:
pass
class FakeStateEmbeddingBuilder:
def __init__(self, *args, **kwargs):
pass
class FakeFAISSManager:
def __init__(self, *args, **kwargs):
self.index = MagicMock(ntotal=0)
screen_module.ScreenAnalyzer = BrokenScreenAnalyzer
clip_module.CLIPEmbedder = FakeCLIPEmbedder
state_module.StateEmbeddingBuilder = FakeStateEmbeddingBuilder
faiss_module.FAISSManager = FakeFAISSManager
monkeypatch.setitem(sys.modules, "core.pipeline.screen_analyzer", screen_module)
monkeypatch.setitem(sys.modules, "core.embedding.clip_embedder", clip_module)
monkeypatch.setitem(sys.modules, "core.embedding.state_embedding_builder", state_module)
monkeypatch.setitem(sys.modules, "core.embedding.faiss_manager", faiss_module)
test_processor = StreamProcessor(data_dir=temp_dir, enable_vlm=True)
with caplog.at_level(logging.CRITICAL):
test_processor._ensure_initialized()
# Pas de cache à vie : l'état reste retry-able
assert test_processor._initialized is False
assert test_processor._screen_analyzer is None
assert any(rec.levelno == logging.CRITICAL for rec in caplog.records), (
"un log critical doit signaler le worker VLM dégradé"
)
# Retry au cycle suivant : ScreenAnalyzer réparé → init réussit cette fois
screen_module.ScreenAnalyzer = HealedScreenAnalyzer
test_processor._ensure_initialized()
assert test_processor._initialized is True
assert isinstance(test_processor._screen_analyzer, HealedScreenAnalyzer)
def test_worker_writes_health_file_with_component_status(self, tmp_path, monkeypatch):
"""N2 : le worker écrit _worker_health.json avec le statut des composants
dérivé du processor, le pid, les stats et le statut global."""
from agent_v0.server_v1 import run_worker
data_dir = tmp_path / "data" / "training"
data_dir.mkdir(parents=True)
monkeypatch.setattr(run_worker, "DATA_DIR", data_dir)
worker = run_worker.VLMWorker()
class FakeProc:
_enable_vlm = True
_screen_analyzer = object()
_clip_embedder = object()
_faiss_manager = object()
_state_embedding_builder = object()
worker._processor = FakeProc()
worker._stats["sessions_processed"] = 1
worker._stats["total_screenshots_analyzed"] = 7
worker._write_health("healthy")
health_path = data_dir / "_worker_health.json"
assert health_path.exists()
data = json.loads(health_path.read_text(encoding="utf-8"))
assert data["status"] == "healthy"
assert data["pid"] == os.getpid()
assert data["components"] == {
"screen_analyzer": True,
"clip_embedder": True,
"faiss_manager": True,
"state_embedding_builder": True,
}
assert data["stats"]["sessions_processed"] == 1
assert data["stats"]["total_screenshots_analyzed"] == 7
def test_worker_health_degraded_when_screen_analyzer_missing(self, tmp_path, monkeypatch):
"""N2 : worker VLM dont le ScreenAnalyzer est absent => status 'degraded',
même si l'appelant demande 'healthy'."""
from agent_v0.server_v1 import run_worker
data_dir = tmp_path / "data" / "training"
data_dir.mkdir(parents=True)
monkeypatch.setattr(run_worker, "DATA_DIR", data_dir)
worker = run_worker.VLMWorker()
class DegradedProc:
_enable_vlm = True
_screen_analyzer = None
_clip_embedder = object()
_faiss_manager = object()
_state_embedding_builder = None
worker._processor = DegradedProc()
worker._write_health("healthy")
data = json.loads((data_dir / "_worker_health.json").read_text(encoding="utf-8"))
assert data["status"] == "degraded"
assert data["components"]["screen_analyzer"] is False
def test_worker_health_file_contains_no_patient_data(self, tmp_path, monkeypatch):
"""N2 confidentialité : le health file ne contient que des clés autorisées —
aucune donnée patient (OCR, noms de fichiers screenshots, contenu session)."""
from agent_v0.server_v1 import run_worker
data_dir = tmp_path / "data" / "training"
data_dir.mkdir(parents=True)
monkeypatch.setattr(run_worker, "DATA_DIR", data_dir)
worker = run_worker.VLMWorker()
worker._current_session = "sess_20260529T154427_f95956"
worker._write_health("busy")
data = json.loads((data_dir / "_worker_health.json").read_text(encoding="utf-8"))
allowed_top = {
"pid", "started_at", "last_cycle", "current_session",
"queue_length", "components", "stats", "status",
}
assert set(data.keys()) <= allowed_top, f"clés inattendues: {set(data.keys()) - allowed_top}"
# current_session ne porte que l'identifiant, pas de contenu de session
assert data["current_session"] == "sess_20260529T154427_f95956"
def test_sd_notify_noop_without_socket(self, monkeypatch):
"""N3 : hors systemd (NOTIFY_SOCKET absent), _sd_notify est un no-op
silencieux qui retourne False — jamais d'exception."""
from agent_v0.server_v1 import run_worker
monkeypatch.delenv("NOTIFY_SOCKET", raising=False)
worker = run_worker.VLMWorker()
assert worker._sd_notify("WATCHDOG=1") is False
def test_sd_notify_sends_watchdog_to_socket(self, tmp_path, monkeypatch):
"""N3 : sous systemd, _sd_notify écrit l'état brut dans $NOTIFY_SOCKET."""
import socket
from agent_v0.server_v1 import run_worker
sock_path = str(tmp_path / "notify.sock")
listener = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
listener.bind(sock_path)
listener.settimeout(2)
try:
monkeypatch.setenv("NOTIFY_SOCKET", sock_path)
worker = run_worker.VLMWorker()
assert worker._sd_notify("WATCHDOG=1") is True
received = listener.recv(64)
assert received == b"WATCHDOG=1"
finally:
listener.close()
def test_vlm_worker_uses_training_root_data_dir(self, tmp_path, monkeypatch):
"""Le worker R6 doit produire workflows/embeddings sous data/training."""
from agent_v0.server_v1 import run_worker
data_dir = tmp_path / "data" / "training"
live_sessions_dir = data_dir / "live_sessions"
monkeypatch.setattr(run_worker, "DATA_DIR", data_dir)
monkeypatch.setattr(run_worker, "LIVE_SESSIONS_DIR", live_sessions_dir)
test_worker = run_worker.VLMWorker()
test_processor = test_worker._get_processor()
assert test_processor.data_dir == data_dir
assert test_processor.session_manager._live_sessions_dir == live_sessions_dir
assert test_processor._enable_vlm is True
def test_stream_worker_standalone_uses_training_root_data_dir(self, tmp_path):
"""Le StreamWorker standalone garde aussi data/training comme racine."""
from agent_v0.server_v1.worker_stream import StreamWorker
live_sessions_dir = tmp_path / "data" / "training" / "live_sessions"
test_worker = StreamWorker(live_dir=str(live_sessions_dir))
assert test_worker.processor.data_dir == live_sessions_dir.parent
assert test_worker.processor.session_manager._live_sessions_dir == live_sessions_dir
assert test_worker.processor._enable_vlm is True
def test_process_event(self, processor):
result = processor.process_event("sess_010", {
"type": "mouse_click",
@@ -181,6 +447,49 @@ class TestStreamProcessor:
session = processor.session_manager.get_session("sess_010")
assert session.last_window_info["title"] == "Chrome"
def test_restore_user_events_keeps_key_combo(self, processor, tmp_path):
session_id = "sess_restore_combo"
session_dir = tmp_path / session_id
session_dir.mkdir()
(session_dir / "live_events.jsonl").write_text(
json.dumps({
"session_id": session_id,
"timestamp": 1779900720.0,
"event": {
"type": "key_combo",
"keys": ["win", "s"],
"raw_keys": [
{"action": "release", "kind": "vk", "vk": 83, "char": "s"},
{"action": "release", "kind": "key", "name": "cmd"},
],
"timestamp": 1779900719.5,
"window": {"title": "Rechercher", "app_name": "SearchHost.exe"},
"screenshot_id": "shot_0001",
},
}) + "\n"
+ json.dumps({
"session_id": session_id,
"timestamp": 1779900725.0,
"event": {
"type": "text_input",
"text": "test",
"timestamp": 1779900725.0,
"window": {"title": "Rechercher", "app_name": "SearchHost.exe"},
},
}) + "\n",
encoding="utf-8",
)
processor.session_manager.add_event(session_id, {"type": "text_input", "text": "old"})
processor._restore_user_events(session_id, session_dir)
session = processor.session_manager.get_session(session_id)
assert [event["type"] for event in session.events] == ["key_combo", "text_input"]
assert session.events[0]["keys"] == ["win", "s"]
assert session.events[0]["raw_keys"][0]["vk"] == 83
assert session.events[0]["screenshot_id"] == "shot_0001"
def test_process_crop(self, processor):
result = processor.process_crop("sess_011", "shot_001_crop", "/tmp/crop.png")
assert result["status"] == "crop_stored"
@@ -479,6 +788,156 @@ class TestStreamProcessor:
assert first_hints.get("active_tab_label") == "test"
assert "fermer l'onglet actif 'test'" in first_spec.get("vlm_description", "")
def test_build_replay_save_as_button_gets_semantic_target(
self, tmp_path, monkeypatch,
):
"""Le clic du bouton Enregistrer dans Save As ne doit pas rester
anchor-only/positionnel.
Régression live 2026-05-25 : avec RPA_SKIP_BUILD_VISION, l'action
Save As était seulement décrite par position + crop, puis résolue par
template matching trop haut/gauche. Le builder doit encoder le bouton
primaire stable ``Enregistrer``.
"""
from agent_v0.server_v1 import stream_processor as sp
session_dir = tmp_path / "sess"
(session_dir / "shots").mkdir(parents=True)
monkeypatch.setattr(sp, "_load_crop_for_event", lambda *args, **kwargs: "abc123")
monkeypatch.setattr(
sp,
"enrich_click_from_screenshot",
lambda *args, **kwargs: {
"anchor_image_base64": "abc123",
"by_text": "",
"by_role": "",
"vlm_description": "positionnel",
},
)
monkeypatch.setattr(sp, "_attach_expected_screenshots", lambda *args, **kwargs: None)
monkeypatch.setattr(sp, "_enrich_actions_with_intentions", lambda *args, **kwargs: None)
monkeypatch.setattr(sp, "_unload_gemma4", lambda *args, **kwargs: None)
events = [
{"event": {
"type": "mouse_click",
"timestamp": 1.0,
"pos": [1329, 1265],
"button": "left",
"screenshot_id": "shot_006",
"screen_metadata": {"screen_resolution": [2560, 1600]},
"window": {"title": "Enregistrer sous", "app_name": "Notepad.exe"},
"window_capture": {
"rect": [332, 522, 1613, 1323],
"click_relative": [997, 743],
"window_size": [1281, 801],
"click_inside_window": True,
},
}},
{"event": {
"type": "window_focus_change",
"timestamp": 1.2,
"from": {"title": "Enregistrer sous", "app_name": "Notepad.exe"},
"to": {"title": "*test Bloc-notes", "app_name": "Notepad.exe"},
}},
]
actions = sp.build_replay_from_raw_events(
events,
session_id="sess_save_as_button",
session_dir=str(session_dir),
)
clicks = [a for a in actions if a.get("type") == "click"]
assert len(clicks) == 1
spec = clicks[0].get("target_spec", {})
hints = spec.get("context_hints") or {}
assert spec.get("by_text") == "Enregistrer"
assert spec.get("by_text_source") == "heuristic"
assert spec.get("by_role") == "button"
assert spec.get("window_title") == "Enregistrer sous"
assert hints.get("interaction") == "save_dialog_primary_button"
assert hints.get("expected_after_window") == "*test Bloc-notes"
assert clicks[0].get("expected_window_title") == "*test Bloc-notes"
def test_build_replay_cuts_post_save_out_of_window_click(
self, tmp_path, monkeypatch,
):
"""Le clic hors fenêtre après retour Bloc-notes est parasite.
C'est l'ancienne action finale 17/18 : coordonnées en bas à droite,
``click_inside_window=false``. Elle ne fait pas partie du coeur
"saisir et enregistrer".
"""
from agent_v0.server_v1 import stream_processor as sp
session_dir = tmp_path / "sess"
(session_dir / "shots").mkdir(parents=True)
monkeypatch.setattr(sp, "_load_crop_for_event", lambda *args, **kwargs: "abc123")
monkeypatch.setattr(
sp,
"enrich_click_from_screenshot",
lambda *args, **kwargs: {"anchor_image_base64": "abc123"},
)
monkeypatch.setattr(sp, "_attach_expected_screenshots", lambda *args, **kwargs: None)
monkeypatch.setattr(sp, "_enrich_actions_with_intentions", lambda *args, **kwargs: None)
monkeypatch.setattr(sp, "_unload_gemma4", lambda *args, **kwargs: None)
events = [
{"event": {
"type": "mouse_click",
"timestamp": 1.0,
"pos": [1329, 1265],
"button": "left",
"screenshot_id": "shot_006",
"screen_metadata": {"screen_resolution": [2560, 1600]},
"window": {"title": "Enregistrer sous", "app_name": "Notepad.exe"},
"window_capture": {
"rect": [332, 522, 1613, 1323],
"click_relative": [997, 743],
"window_size": [1281, 801],
"click_inside_window": True,
},
}},
{"event": {
"type": "window_focus_change",
"timestamp": 1.2,
"from": {"title": "Enregistrer sous", "app_name": "Notepad.exe"},
"to": {"title": "*test Bloc-notes", "app_name": "Notepad.exe"},
}},
{"event": {
"type": "mouse_click",
"timestamp": 1.5,
"pos": [2248, 1577],
"button": "left",
"screenshot_id": "shot_007",
"screen_metadata": {"screen_resolution": [2560, 1600]},
"window": {
"title": "http192.168.1.408765dossier.htmlid=.txt Bloc-notes",
"app_name": "Notepad.exe",
},
"window_capture": {
"rect": [323, 522, 2243, 1638],
"click_relative": [1925, 1055],
"window_size": [1920, 1116],
"click_inside_window": False,
},
}},
]
actions = sp.build_replay_from_raw_events(
events,
session_id="sess_post_save_cut",
session_dir=str(session_dir),
)
clicks = [a for a in actions if a.get("type") == "click"]
assert len(clicks) == 1
assert clicks[0].get("target_spec", {}).get("by_text") == "Enregistrer"
assert clicks[0].get("expected_window_title") == "*test Bloc-notes"
# =========================================================================
# StreamWorker

View File

@@ -52,12 +52,12 @@ class TestImageEndpointNotPublic:
mod = _reload_api_stream()
assert "/health" in mod._PUBLIC_PATHS
def test_replay_next_still_public(self, monkeypatch):
"""/replay/next reste public (legacy agent Rust polling)."""
def test_replay_next_removed_from_public_paths(self, monkeypatch):
"""/replay/next distribue des actions et exige desormais un Bearer."""
monkeypatch.setenv("RPA_API_TOKEN", "deadbeef" * 4)
monkeypatch.delenv("RPA_AUTH_DISABLED", raising=False)
mod = _reload_api_stream()
assert "/api/v1/traces/stream/replay/next" in mod._PUBLIC_PATHS
assert "/api/v1/traces/stream/replay/next" not in mod._PUBLIC_PATHS
# ---------------------------------------------------------------------------
@@ -157,6 +157,23 @@ class TestFailClosedTokenP0C:
asyncio.get_event_loop().run_until_complete(mod._verify_token(req))
assert exc_info.value.status_code == 401
def test_verify_token_rejects_replay_next_without_bearer(self, monkeypatch):
"""P0 révocation : GET /replay/next n'est plus public."""
import asyncio
from unittest.mock import MagicMock
from fastapi import HTTPException
monkeypatch.setenv("RPA_API_TOKEN", "validtoken" * 4)
monkeypatch.delenv("RPA_AUTH_DISABLED", raising=False)
mod = _reload_api_stream()
req = MagicMock()
req.url.path = "/api/v1/traces/stream/replay/next"
req.headers = {}
with pytest.raises(HTTPException) as exc_info:
asyncio.get_event_loop().run_until_complete(mod._verify_token(req))
assert exc_info.value.status_code == 401
@pytest.fixture(autouse=True)
def _cleanup(monkeypatch):

View File

@@ -0,0 +1,350 @@
"""
Tests des gaps de revocation fleet sur agent_v0/server_v1/api_stream.py.
Couvre :
1. test_result_guard_without_pending — le garde est appliqué sur /replay/result
meme sans _retry_pending (garde inconditionnel).
2. test_finalize_revoked_agent — enroll + revoke + finalize → 403
3. test_finalize_unknown_machine_registered — registre avec agents →
machine_id inconnu → 403
4. test_guard_default_machine_id_registered — registre avec agents →
machine_id="default" → 403
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
ROOT = str(Path(__file__).resolve().parents[2])
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
@pytest.fixture
def isolated_fleet_state(monkeypatch, tmp_path):
"""Fixture qui isole le registre AgentRegistry et les structures replay."""
monkeypatch.setenv("RPA_API_TOKEN", "test_revocation_gaps_token")
from agent_v0.server_v1 import api_stream
from agent_v0.server_v1.agent_registry import AgentRegistry
# Aligner le token attendu par le middleware
monkeypatch.setattr(api_stream, "API_TOKEN", "test_revocation_gaps_token")
# Substituer le registre global par une instance dediee au test
original_registry = api_stream.agent_registry
test_registry = AgentRegistry(db_path=str(tmp_path / "test_agents.db"))
monkeypatch.setattr(api_stream, "agent_registry", test_registry)
# Sauver et nettoyer les structures replay
saved_states = dict(api_stream._replay_states)
api_stream._replay_states.clear()
_auth_headers = {"Authorization": "Bearer test_revocation_gaps_token"}
yield api_stream, test_registry, _auth_headers
# Restauration
api_stream._replay_states.clear()
api_stream._replay_states.update(saved_states)
monkeypatch.setattr(api_stream, "agent_registry", original_registry)
# ---------------------------------------------------------------------------
# Test 1 : /replay/result — le garde est appliqué sans _retry_pending
# ---------------------------------------------------------------------------
def test_result_guard_without_pending(isolated_fleet_state, monkeypatch):
"""Le garde sur /replay/result s'applique meme sans entrée dans _retry_pending.
Scénario : un agent enrolle et actif envoie un rapport de résultat pour
une action qui n'est pas dans _retry_pending (cas nominal, pas un retry).
Le garde doit être appelé et laisser passer car l'agent est actif.
Si l'agent est révoqué, le même rapport doit être bloqué (403).
"""
api_stream, registry, auth_headers = isolated_fleet_state
# Enroller un agent actif
registry.enroll(
machine_id="test-machine-result-guard",
user_name="Test User",
hostname="TEST-HOST",
)
# Forger une session avec machine_id pour que le garde puisse le résoudre
from agent_v0.server_v1.live_session_manager import LiveSessionState
session = LiveSessionState(
session_id="sess-result-guard",
machine_id="test-machine-result-guard",
)
monkeypatch.setattr(
api_stream.processor.session_manager,
"get_session",
lambda sid: session if sid == "sess-result-guard" else None,
)
from fastapi.testclient import TestClient
client = TestClient(api_stream.app, raise_server_exceptions=False)
# Rapport sans _retry_pending — le garde doit quand meme s'appliquer
# et laisser passer car l'agent est actif
resp = client.post(
"/api/v1/traces/stream/replay/result",
json={
"session_id": "sess-result-guard",
"action_id": "act-no-retry",
"success": True,
},
headers=auth_headers,
)
# Ne doit PAS etre 403 (agent actif)
assert resp.status_code != 403, (
f"/replay/result a été bloqué (403) pour un agent actif sans retry. "
f"Body: {resp.text}"
)
# Maintenant révoquer l'agent
registry.uninstall(
machine_id="test-machine-result-guard",
reason="admin_revoke",
)
# Le meme rapport doit maintenant être bloqué
resp = client.post(
"/api/v1/traces/stream/replay/result",
json={
"session_id": "sess-result-guard",
"action_id": "act-no-retry-2",
"success": True,
},
headers=auth_headers,
)
assert resp.status_code == 403, (
f"/replay/result DOIT être bloqué (403) pour un agent révoqué. "
f"Body: {resp.text}"
)
# ---------------------------------------------------------------------------
# Test 2 : /finalize — enroll + revoke + finalize → 403
# ---------------------------------------------------------------------------
def test_finalize_revoked_agent(isolated_fleet_state, monkeypatch):
"""Un agent enrolle puis révoqué doit être bloqué sur /finalize."""
api_stream, registry, auth_headers = isolated_fleet_state
# Enroller un agent
registry.enroll(
machine_id="test-machine-revoked-finalize",
user_name="Test User",
hostname="TEST-HOST",
)
# Le révoquer
registry.uninstall(
machine_id="test-machine-revoked-finalize",
reason="admin_revoke",
)
# Forger une session pour que le finalize trouve la session
from agent_v0.server_v1.live_session_manager import LiveSessionState
session = LiveSessionState(
session_id="sess-revoked-finalize",
machine_id="test-machine-revoked-finalize",
)
monkeypatch.setattr(
api_stream.processor.session_manager,
"get_session",
lambda sid: session if sid == "sess-revoked-finalize" else None,
)
# finalize() appelle aussi finalize() sur le session_manager — mock pour éviter I/O
monkeypatch.setattr(
api_stream.processor.session_manager,
"finalize",
lambda sid: None,
)
monkeypatch.setattr(api_stream.processor, "_find_session_dir", lambda sid: None)
from fastapi.testclient import TestClient
client = TestClient(api_stream.app, raise_server_exceptions=False)
resp = client.post(
"/api/v1/traces/stream/finalize",
params={"session_id": "sess-revoked-finalize"},
headers=auth_headers,
)
assert resp.status_code == 403, (
f"/finalize DOIT être bloqué (403) pour un agent révoqué. "
f"Body: {resp.text}"
)
# ---------------------------------------------------------------------------
# Test 3 : /finalize — machine_id inconnu avec registre non vide → 403
# ---------------------------------------------------------------------------
def test_finalize_unknown_machine_registered(isolated_fleet_state, monkeypatch):
"""Quand le registre contient au moins un agent, un machine_id inconnu → 403."""
api_stream, registry, auth_headers = isolated_fleet_state
# Enroller un agent (le registre n'est donc pas vide)
registry.enroll(
machine_id="known-machine-xyz",
user_name="Known User",
hostname="KNOWN-HOST",
)
# Forger une session avec un machine_id inconnu du registre
from agent_v0.server_v1.live_session_manager import LiveSessionState
session = LiveSessionState(
session_id="sess-unknown-finalize",
machine_id="unknown-machine-abc", # Pas dans le registre
)
monkeypatch.setattr(
api_stream.processor.session_manager,
"get_session",
lambda sid: session if sid == "sess-unknown-finalize" else None,
)
monkeypatch.setattr(
api_stream.processor.session_manager,
"finalize",
lambda sid: None,
)
monkeypatch.setattr(api_stream.processor, "_find_session_dir", lambda sid: None)
from fastapi.testclient import TestClient
client = TestClient(api_stream.app, raise_server_exceptions=False)
resp = client.post(
"/api/v1/traces/stream/finalize",
params={"session_id": "sess-unknown-finalize"},
headers=auth_headers,
)
assert resp.status_code == 403, (
f"/finalize DOIT être bloqué (403) pour un machine_id inconnu "
f"quand le registre contient des agents. Body: {resp.text}"
)
body = resp.json()
assert body.get("detail", {}).get("error") == "agent_unknown", (
f"Erreur attendue 'agent_unknown', obtenu: {body}"
)
# ---------------------------------------------------------------------------
# Test 4 : _guard_agent_registry_access — machine_id="default" avec registre → 403
# ---------------------------------------------------------------------------
def test_guard_default_machine_id_registered(isolated_fleet_state):
"""Quand le registre contient des agents, machine_id='default' → 403."""
api_stream, registry, _auth_headers = isolated_fleet_state
# Enroller un agent (le registre n'est donc pas vide)
registry.enroll(
machine_id="some-enrolled-agent",
user_name="Enrolled User",
hostname="ENROLLED-HOST",
)
from fastapi import HTTPException
# Appel direct du garde avec machine_id="default"
with pytest.raises(HTTPException) as exc_info:
api_stream._guard_agent_registry_access(
"default",
endpoint="/api/v1/traces/stream/finalize",
)
assert exc_info.value.status_code == 403
detail = exc_info.value.detail
assert detail.get("error") == "agent_enrollment_required", (
f"Erreur attendue 'agent_enrollment_required', obtenu: {detail}"
)
# Idem avec machine_id="" (vide)
with pytest.raises(HTTPException) as exc_info:
api_stream._guard_agent_registry_access(
"",
endpoint="/api/v1/traces/stream/event",
)
assert exc_info.value.status_code == 403
assert exc_info.value.detail.get("error") == "agent_enrollment_required"
# Idem avec machine_id=None
with pytest.raises(HTTPException) as exc_info:
api_stream._guard_agent_registry_access(
None,
endpoint="/api/v1/traces/stream/event",
)
assert exc_info.value.status_code == 403
assert exc_info.value.detail.get("error") == "agent_enrollment_required"
# ---------------------------------------------------------------------------
# Test 5 : /replay-session — enroll + revoke → 403
# ---------------------------------------------------------------------------
def test_replay_session_revoked_agent(isolated_fleet_state, monkeypatch):
"""Un agent révoqué ne doit pas pouvoir lancer un replay-session."""
api_stream, registry, auth_headers = isolated_fleet_state
registry.enroll(
machine_id="test-machine-replay-session",
user_name="Test User",
hostname="TEST-HOST",
)
registry.uninstall(
machine_id="test-machine-replay-session",
reason="admin_revoke",
)
from fastapi.testclient import TestClient
client = TestClient(api_stream.app, raise_server_exceptions=False)
resp = client.post(
"/api/v1/traces/stream/replay-session",
params={
"session_id": "sess-some-replay",
"machine_id": "test-machine-replay-session",
},
headers=auth_headers,
)
assert resp.status_code == 403, (
f"/replay-session DOIT être bloqué (403) pour un agent révoqué. "
f"Body: {resp.text}"
)
def test_replay_session_unknown_machine_registered(isolated_fleet_state):
"""Quand le registre contient des agents, machine_id inconnu sur replay-session → 403."""
api_stream, registry, auth_headers = isolated_fleet_state
registry.enroll(
machine_id="known-machine-replay",
user_name="Known User",
hostname="KNOWN-HOST",
)
from fastapi.testclient import TestClient
client = TestClient(api_stream.app, raise_server_exceptions=False)
resp = client.post(
"/api/v1/traces/stream/replay-session",
params={
"session_id": "sess-some-replay",
"machine_id": "unknown-machine-replay",
},
headers=auth_headers,
)
assert resp.status_code == 403
assert resp.json().get("detail", {}).get("error") == "agent_unknown"

View File

@@ -26,6 +26,15 @@ def test_only_declarative_when_no_safety_level():
assert payload.checks[0]["source"] == "declarative"
def test_default_pause_message_is_structured_not_validation_required():
"""Fallback humain: jamais 'Validation requise' seul."""
payload = build_pause_payload({"type": "pause_for_human", "parameters": {}}, {}, last_screenshot=None)
lines = payload.message.splitlines()
assert len(lines) == 4
assert lines[0].startswith("J'essaie de :")
assert "Validation requise" not in payload.message
def test_hybrid_appends_llm_checks_on_medical_critical(monkeypatch):
"""safety_level=medical_critical → LLM appelé, checks concaténés."""
decl = [{"id": "c1", "label": "Vérifier IPP", "required": True}]