diff --git a/app/ai/__init__.py b/app/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ai/danger.py b/app/ai/danger.py new file mode 100644 index 0000000..a19a4c4 --- /dev/null +++ b/app/ai/danger.py @@ -0,0 +1,37 @@ +"""Detection locale simple de commandes potentiellement destructives, proposees +par l'assistant IA. N'a pas besoin d'etre parfaite : sert a afficher un +avertissement, jamais a bloquer ou executer quoi que ce soit automatiquement.""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +_DANGEROUS_PATTERNS: list[tuple[str, re.Pattern]] = [ + ("rm -rf (suppression recursive forcee)", re.compile(r"\brm\s+(-\w*\s+)*-\w*[rf]\w*[rf]\w*\b", re.IGNORECASE)), + ("mkfs (formatage)", re.compile(r"\bmkfs(\.\w+)?\b", re.IGNORECASE)), + ("dd (ecriture bas niveau sur un disque)", re.compile(r"\bdd\s+.*\bif=", re.IGNORECASE | re.DOTALL)), + ("wipefs (effacement des signatures de systeme de fichiers)", re.compile(r"\bwipefs\b", re.IGNORECASE)), + ("shutdown (extinction)", re.compile(r"\bshutdown\b", re.IGNORECASE)), + ("reboot (redemarrage)", re.compile(r"\breboot\b", re.IGNORECASE)), + ("iptables : purge des regles", re.compile(r"\biptables\b.*(-F|--flush)\b", re.IGNORECASE)), + ("nft : purge des regles", re.compile(r"\bnft\b.*\bflush\b", re.IGNORECASE)), + ("DROP DATABASE", re.compile(r"\bDROP\s+DATABASE\b", re.IGNORECASE)), + ("DELETE FROM sans WHERE", re.compile(r"\bDELETE\s+FROM\s+\S+\s*;", re.IGNORECASE)), + ("docker system prune (suppression en masse)", re.compile(r"\bdocker\s+system\s+prune\b", re.IGNORECASE)), + ("zpool destroy", re.compile(r"\bzpool\s+destroy\b", re.IGNORECASE)), + ("lvremove (suppression de volume logique)", re.compile(r"\blvremove\b", re.IGNORECASE)), + ("vgremove (suppression de groupe de volumes)", re.compile(r"\bvgremove\b", re.IGNORECASE)), +] + + +@dataclass +class DangerMatch: + label: str + + +def check_dangerous(command: str) -> list[DangerMatch]: + return [DangerMatch(label) for label, pattern in _DANGEROUS_PATTERNS if pattern.search(command)] + + +def is_dangerous(command: str) -> bool: + return bool(check_dangerous(command)) diff --git a/app/ai/provider.py b/app/ai/provider.py new file mode 100644 index 0000000..4d55c06 --- /dev/null +++ b/app/ai/provider.py @@ -0,0 +1,154 @@ +"""Abstraction du fournisseur IA : une API compatible OpenAI (endpoint chat +completions), avec URL de base et modele configurables - pas de liste de +fournisseurs ou de modeles figee en dur, pour supporter OpenAI, OpenRouter, un +proxy interne ou un LLM local exposant la meme API. + +AIProvider porte les 4 usages metier (explain_output/diagnose/generate_command/ +ask), tous construits au-dessus de chat(), seule methode que doit implementer +un fournisseur concret (OpenAICompatibleProvider).""" +from __future__ import annotations + +import abc +import json +import urllib.error +import urllib.request + + +class AIError(Exception): + """Message deja comprehensible, pret a etre affiche a l'utilisateur.""" + + +_SYSTEM_PROMPT_TROUBLESHOOT = """Tu es un assistant specialise en administration systeme, Linux, Windows, +reseau, SSH, Docker, virtualisation et troubleshooting. + +Analyse la sortie terminal fournie par l'utilisateur. + +Explique : +1. ce que signifie la sortie ; +2. la cause probable ; +3. les verifications recommandees ; +4. les commandes eventuellement utiles. + +Ne suppose jamais qu'une commande a ete executee. +Ne pretends jamais connaitre des elements qui ne sont pas presents. +Signale explicitement les hypotheses.""" + +_SYSTEM_PROMPT_DIAGNOSE = _SYSTEM_PROMPT_TROUBLESHOOT + """ + +Structure ta reponse ainsi : +Cause probable +Explication +Verifications +Commandes proposees +Niveau de risque""" + +_SYSTEM_PROMPT_COMMAND = """Tu es un assistant specialise en administration systeme, Linux, Windows, +reseau, SSH, Docker et virtualisation. + +L'utilisateur decrit ce qu'il veut accomplir en langage naturel. Propose UNE +commande (ou une courte suite de commandes) qui repond a la demande, dans un +bloc de code, avec une breve explication de ce qu'elle fait. + +Ne suppose jamais le systeme d'exploitation ou l'environnement si l'utilisateur +ne l'a pas precise : demande-le, ou propose une variante par systeme si +pertinent. La commande proposee ne sera jamais executee automatiquement : elle +sera seulement affichee a l'utilisateur pour verification.""" + + +class AIProvider(abc.ABC): + @abc.abstractmethod + def chat(self, messages: list[dict], *, temperature: float | None = None) -> str: + """Renvoie le texte de la reponse. Leve AIError en cas d'echec.""" + + def explain_output(self, output: str) -> str: + return self.chat([ + {"role": "system", "content": _SYSTEM_PROMPT_TROUBLESHOOT}, + {"role": "user", "content": f"Explique cette sortie terminal :\n\n{output}"}, + ]) + + def diagnose(self, output: str) -> str: + return self.chat([ + {"role": "system", "content": _SYSTEM_PROMPT_DIAGNOSE}, + {"role": "user", "content": f"Diagnostique cette erreur :\n\n{output}"}, + ]) + + def propose_solution(self, output: str) -> str: + return self.chat([ + {"role": "system", "content": _SYSTEM_PROMPT_TROUBLESHOOT}, + {"role": "user", "content": ( + "Propose une solution concrete a ce probleme, avec les commandes " + f"eventuellement necessaires :\n\n{output}" + )}, + ]) + + def generate_command(self, request: str) -> str: + return self.chat([ + {"role": "system", "content": _SYSTEM_PROMPT_COMMAND}, + {"role": "user", "content": request}, + ]) + + def ask(self, question: str, context: str = "") -> str: + messages = [{"role": "system", "content": _SYSTEM_PROMPT_TROUBLESHOOT}] + if context: + messages.append({"role": "user", "content": f"Contexte (sortie terminal selectionnee) :\n{context}"}) + messages.append({"role": "user", "content": question}) + return self.chat(messages) + + +class OpenAICompatibleProvider(AIProvider): + def __init__( + self, base_url: str, api_key: str, model: str, + timeout: float = 30.0, temperature: float = 0.3, + ): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.model = model + self.timeout = timeout + self.temperature = temperature + + def chat(self, messages: list[dict], *, temperature: float | None = None) -> str: + if not self.base_url: + raise AIError("Aucune URL de base configuree pour l'assistant IA.") + if not self.model: + raise AIError("Aucun modele configure pour l'assistant IA.") + + url = f"{self.base_url}/chat/completions" + payload = { + "model": self.model, + "messages": messages, + "temperature": self.temperature if temperature is None else temperature, + } + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + request = urllib.request.Request( + url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST", + ) + + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + raw = response.read() + except urllib.error.HTTPError as exc: + raise AIError(self._describe_http_error(exc)) from exc + except urllib.error.URLError as exc: + if isinstance(exc.reason, TimeoutError): + raise AIError("Le fournisseur IA n'a pas repondu a temps (delai depasse).") from exc + raise AIError(f"Impossible de joindre le fournisseur IA : {exc.reason}") from exc + + try: + parsed = json.loads(raw.decode("utf-8")) + return parsed["choices"][0]["message"]["content"] + except (json.JSONDecodeError, KeyError, IndexError, TypeError, UnicodeDecodeError) as exc: + raise AIError(f"Reponse du fournisseur IA invalide ou inattendue : {exc}") from exc + + @staticmethod + def _describe_http_error(exc: urllib.error.HTTPError) -> str: + if exc.code == 401: + return "Authentification refusee (401) : verifiez la cle API." + if exc.code == 429: + return "Trop de requetes (429) : le fournisseur limite le debit, reessayez plus tard." + try: + body = exc.read().decode("utf-8", errors="replace") + except Exception: # noqa: BLE001 + body = "" + return f"Erreur du fournisseur IA ({exc.code}) : {(body or exc.reason)[:300]}" diff --git a/app/ai/redaction.py b/app/ai/redaction.py new file mode 100644 index 0000000..e27715b --- /dev/null +++ b/app/ai/redaction.py @@ -0,0 +1,52 @@ +"""Detection/redaction basique de secrets avant envoi a un fournisseur IA +externe : cles privees SSH, jetons Bearer, cles AWS, en-tetes Authorization, +password=/token=/api_key=, et longues chaines ressemblant a un secret. + +Heuristique volontairement simple (comme la detection de commandes +dangereuses) : mieux vaut avertir a tort sur un hash ou un identifiant anodin +que laisser passer un vrai secret.""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +_PATTERNS: list[tuple[str, re.Pattern]] = [ + ("Cle privee (PEM/OpenSSH)", re.compile( + r"-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED )?PRIVATE KEY-----" + )), + ("Jeton Bearer", re.compile(r"\bBearer\s+[A-Za-z0-9\-_.=]{10,}", re.IGNORECASE)), + ("Cle d'acces AWS", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")), + ("En-tete Authorization", re.compile(r"\bAuthorization\s*:\s*\S+", re.IGNORECASE)), + ("Mot de passe (password=)", re.compile(r"\b(?:password|passwd|pwd)\s*[:=]\s*\S+", re.IGNORECASE)), + ("Cle API (api_key=)", re.compile(r"\b(?:api[_-]?key|apikey)\s*[:=]\s*\S+", re.IGNORECASE)), + ("Jeton (token=)", re.compile(r"\btoken\s*[:=]\s*\S+", re.IGNORECASE)), + ("Chaine longue ressemblant a un secret", re.compile(r"\b[A-Za-z0-9_\-]{32,}\b")), +] + + +@dataclass +class DetectedSecret: + label: str + excerpt: str + + +def detect_secrets(text: str) -> list[DetectedSecret]: + """Renvoie les categories detectees (au plus une occurrence par categorie, + pour un resume lisible), dans l'ordre des motifs (du plus specifique au + plus generique).""" + found: list[DetectedSecret] = [] + for label, pattern in _PATTERNS: + match = pattern.search(text) + if match: + excerpt = match.group(0) + if len(excerpt) > 60: + excerpt = excerpt[:57] + "..." + found.append(DetectedSecret(label, excerpt)) + return found + + +def redact(text: str) -> str: + redacted = text + for _label, pattern in _PATTERNS: + redacted = pattern.sub("[MASQUE]", redacted) + return redacted diff --git a/app/ai/service.py b/app/ai/service.py new file mode 100644 index 0000000..75babce --- /dev/null +++ b/app/ai/service.py @@ -0,0 +1,54 @@ +"""Execution des requetes IA en arriere-plan : aucun appel reseau ne doit +jamais bloquer le thread Qt.""" +from __future__ import annotations + +from PySide6.QtCore import QThread, Signal + +from app.ai.provider import AIProvider, AIError + +KIND_EXPLAIN = "explain" +KIND_DIAGNOSE = "diagnose" +KIND_SOLUTION = "solution" +KIND_GENERATE = "generate" +KIND_ASK = "ask" +KIND_TEST = "test" + + +class AIRequestWorker(QThread): + """Une requete = un worker jetable. L'annulation est cooperative : la + requete HTTP en cours (bornee par son timeout) va a son terme, mais le + signal de resultat peut etre ignore par l'appelant (voir AIPanel).""" + + finished_ok = Signal(str) + finished_error = Signal(str) + + def __init__(self, provider: AIProvider, kind: str, payload: str, context: str = "", parent=None): + super().__init__(parent) + self.provider = provider + self.kind = kind + self.payload = payload + self.context = context + + def run(self) -> None: + try: + result = self._execute() + self.finished_ok.emit(result) + except AIError as exc: + self.finished_error.emit(str(exc)) + except Exception as exc: # noqa: BLE001 - ne jamais laisser un thread planter silencieusement + self.finished_error.emit(f"Erreur inattendue : {exc}") + + def _execute(self) -> str: + if self.kind == KIND_EXPLAIN: + return self.provider.explain_output(self.payload) + if self.kind == KIND_DIAGNOSE: + return self.provider.diagnose(self.payload) + if self.kind == KIND_SOLUTION: + return self.provider.propose_solution(self.payload) + if self.kind == KIND_GENERATE: + return self.provider.generate_command(self.payload) + if self.kind == KIND_ASK: + return self.provider.ask(self.payload, context=self.context) + if self.kind == KIND_TEST: + return self.provider.chat([{"role": "user", "content": "Reponds uniquement 'OK'."}]) + raise AIError(f"Type de requete IA inconnu : {self.kind}") diff --git a/app/ai/settings.py b/app/ai/settings.py new file mode 100644 index 0000000..12b773e --- /dev/null +++ b/app/ai/settings.py @@ -0,0 +1,85 @@ +"""Configuration de l'assistant IA (optionnel, desactive par defaut). + +La cle API n'est jamais stockee ici : seuls les parametres non sensibles +(activation, fournisseur, URL, modele, timeout, temperature) le sont, via +app.app_settings. La cle passe exclusivement par CredentialStore +(app.credentials), jamais dans settings.json/sessions.json/logs.""" +from __future__ import annotations + +from dataclasses import dataclass + +from app.app_settings import get_setting, set_setting +from app.credentials import CredentialStore +from app.ai.provider import AIProvider, OpenAICompatibleProvider + +AI_API_KEY_CREDENTIAL = "ai:api_key" + +DEFAULT_BASE_URL = "https://api.openai.com/v1" +DEFAULT_PROVIDER_NAME = "OpenAI" +DEFAULT_MODEL = "" +DEFAULT_TIMEOUT = 30.0 +DEFAULT_TEMPERATURE = 0.3 + + +@dataclass +class AISettings: + enabled: bool = False + provider_name: str = DEFAULT_PROVIDER_NAME + base_url: str = DEFAULT_BASE_URL + model: str = DEFAULT_MODEL + timeout: float = DEFAULT_TIMEOUT + temperature: float = DEFAULT_TEMPERATURE + + +def load_ai_settings() -> AISettings: + try: + timeout = float(get_setting("ai_timeout", DEFAULT_TIMEOUT)) + except (TypeError, ValueError): + timeout = DEFAULT_TIMEOUT + try: + temperature = float(get_setting("ai_temperature", DEFAULT_TEMPERATURE)) + except (TypeError, ValueError): + temperature = DEFAULT_TEMPERATURE + return AISettings( + enabled=bool(get_setting("ai_enabled", False)), + provider_name=get_setting("ai_provider_name", DEFAULT_PROVIDER_NAME) or DEFAULT_PROVIDER_NAME, + base_url=get_setting("ai_base_url", DEFAULT_BASE_URL) or DEFAULT_BASE_URL, + model=get_setting("ai_model", DEFAULT_MODEL) or DEFAULT_MODEL, + timeout=timeout, + temperature=temperature, + ) + + +def save_ai_settings(settings: AISettings) -> None: + set_setting("ai_enabled", settings.enabled) + set_setting("ai_provider_name", settings.provider_name) + set_setting("ai_base_url", settings.base_url) + set_setting("ai_model", settings.model) + set_setting("ai_timeout", settings.timeout) + set_setting("ai_temperature", settings.temperature) + + +def load_api_key(credential_store: CredentialStore) -> str: + return credential_store.get_secret(AI_API_KEY_CREDENTIAL) or "" + + +def save_api_key(credential_store: CredentialStore, api_key: str) -> bool: + if api_key: + return credential_store.set_secret(AI_API_KEY_CREDENTIAL, api_key) + credential_store.delete_secret(AI_API_KEY_CREDENTIAL) + return True + + +def build_provider_from_settings(credential_store: CredentialStore) -> AIProvider | None: + """None si l'assistant est desactive : point d'appel unique pour tous les + endroits de l'UI qui ont besoin d'un fournisseur pret a l'emploi.""" + settings = load_ai_settings() + if not settings.enabled: + return None + return OpenAICompatibleProvider( + base_url=settings.base_url, + api_key=load_api_key(credential_store), + model=settings.model, + timeout=settings.timeout, + temperature=settings.temperature, + ) diff --git a/app/controllers/connection_controller.py b/app/controllers/connection_controller.py index 3684fd7..e83399e 100644 --- a/app/controllers/connection_controller.py +++ b/app/controllers/connection_controller.py @@ -33,6 +33,7 @@ class ConnectionController(QObject): broadcast: BroadcastManager, snippet_store=None, credential_store: CredentialStore | None = None, + ai_provider_factory=None, parent_widget: QWidget | None = None, ): super().__init__(parent_widget) @@ -41,6 +42,7 @@ class ConnectionController(QObject): self.broadcast = broadcast self.snippet_store = snippet_store self.credential_store = credential_store or NullCredentialStore() + self.ai_provider_factory = ai_provider_factory self._parent_widget = parent_widget self.tabs = QTabWidget(parent_widget) @@ -108,6 +110,7 @@ class ConnectionController(QObject): return SessionTabWidget( session, secret, bastion_target=bastion_target, bastion_password=bastion_password, snippet_store=self.snippet_store, broadcast=self.broadcast, + ai_provider_factory=self.ai_provider_factory, credential_store=self.credential_store, ) def build_sftp_tab(self, session: Session) -> SFTPTabWidget | None: @@ -149,7 +152,10 @@ class ConnectionController(QObject): def new_local_terminal(self, shell: str = DEFAULT_SHELL) -> LocalTerminalTabWidget: """Cree un terminal local cable (multi-send, SFTP detecte), sans l'ajouter au QTabWidget : reutilise pour un onglet normal comme pour un panneau.""" - tab = LocalTerminalTabWidget(shell, snippet_store=self.snippet_store, broadcast=self.broadcast) + tab = LocalTerminalTabWidget( + shell, snippet_store=self.snippet_store, broadcast=self.broadcast, + ai_provider_factory=self.ai_provider_factory, credential_store=self.credential_store, + ) tab.sftp_requested.connect(self.quick_sftp) self.broadcast.register(tab) return tab diff --git a/app/dialogs/ai_command_dialog.py b/app/dialogs/ai_command_dialog.py new file mode 100644 index 0000000..4a0fbac --- /dev/null +++ b/app/dialogs/ai_command_dialog.py @@ -0,0 +1,79 @@ +"""Affiche une commande proposee par l'assistant IA : jamais executee +automatiquement, seulement copiable ou inseree dans le terminal (sans Entree) +apres verification par l'utilisateur.""" +from __future__ import annotations + +import re + +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QTextEdit, QPushButton, QLabel, QApplication, +) + +_CODE_BLOCK_RE = re.compile(r"```(?:[a-zA-Z0-9_+-]*\n)?(.*?)```", re.DOTALL) + + +def extract_command(response: str) -> str: + """Prend le contenu du premier bloc de code s'il y en a un (le prompt + systeme en demande un), sinon la reponse entiere : mieux vaut proposer un + texte un peu trop large que perdre silencieusement la commande.""" + match = _CODE_BLOCK_RE.search(response) + text = match.group(1) if match else response + return text.strip() + + +class AICommandDialog(QDialog): + def __init__(self, response: str, dangerous_labels: list[str] | None = None, parent=None): + super().__init__(parent) + self.setWindowTitle("Commande generee par l'assistant IA") + self.setMinimumSize(520, 360) + self.command = extract_command(response) + self.insert_requested = False + + layout = QVBoxLayout(self) + + if dangerous_labels: + warning = QLabel( + "⚠ Commande potentiellement destructive : " + ", ".join(dangerous_labels) + ) + warning.setWordWrap(True) + warning.setStyleSheet("color: #c0392b; font-weight: bold;") + layout.addWidget(warning) + + layout.addWidget(QLabel("Reponse complete de l'assistant :")) + self.response_view = QTextEdit(response) + self.response_view.setReadOnly(True) + layout.addWidget(self.response_view, 1) + + layout.addWidget(QLabel("Commande proposee (celle qui sera copiee/inseree) :")) + self.command_view = QTextEdit(self.command) + self.command_view.setFixedHeight(70) + layout.addWidget(self.command_view) + + self.status_label = QLabel("Cette commande ne sera jamais executee automatiquement.") + self.status_label.setObjectName("statusLabel") + layout.addWidget(self.status_label) + + btn_row = QHBoxLayout() + self.copy_btn = QPushButton("Copier") + self.insert_btn = QPushButton("Inserer dans le terminal") + self.cancel_btn = QPushButton("Annuler") + for b in (self.copy_btn, self.insert_btn, self.cancel_btn): + btn_row.addWidget(b) + layout.addLayout(btn_row) + + self.copy_btn.clicked.connect(self._copy) + self.insert_btn.clicked.connect(self._insert) + self.cancel_btn.clicked.connect(self.reject) + + def _current_command(self) -> str: + # l'utilisateur peut corriger la commande proposee avant de l'utiliser + return self.command_view.toPlainText().strip() + + def _copy(self) -> None: + QApplication.clipboard().setText(self._current_command()) + self.status_label.setText("Copie dans le presse-papiers.") + + def _insert(self) -> None: + self.command = self._current_command() + self.insert_requested = True + self.accept() diff --git a/app/dialogs/ai_prompts.py b/app/dialogs/ai_prompts.py new file mode 100644 index 0000000..e0b5caf --- /dev/null +++ b/app/dialogs/ai_prompts.py @@ -0,0 +1,70 @@ +"""Confirmations de securite avant tout envoi de contenu au fournisseur IA : +un avertissement de principe affiche une seule fois, puis une verification +systematique des secrets evidents a chaque envoi d'une selection de terminal +ou d'une demande libre.""" +from __future__ import annotations + +from PySide6.QtWidgets import QMessageBox + +from app.app_settings import get_setting, set_setting +from app.ai.redaction import detect_secrets + +_NOTICE_ACK_KEY = "ai_notice_acknowledged" + +MASK = "mask" +SEND = "send" +CANCEL = "cancel" + + +def ensure_ai_notice_acknowledged(parent) -> bool: + """Affiche l'avertissement de principe une seule fois (jamais reaffiche + ensuite, l'accord etant persiste). Renvoie False si l'utilisateur refuse : + l'appelant ne doit alors rien envoyer.""" + if get_setting(_NOTICE_ACK_KEY, False): + return True + box = QMessageBox(parent) + box.setWindowTitle("Assistant IA") + box.setIcon(QMessageBox.Information) + box.setText( + "Le texte selectionne dans le terminal (ou votre question) va etre envoye " + "au fournisseur IA que vous avez configure, en dehors de cet ordinateur.\n\n" + "PySSH tente de detecter les secrets evidents (mots de passe, cles, jetons) " + "avant chaque envoi et vous permet de les masquer, mais ne peut pas tout " + "detecter : verifiez ce que vous envoyez.\n\n" + "Ce message ne sera plus affiche." + ) + box.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) + box.setDefaultButton(QMessageBox.Ok) + accepted = box.exec() == QMessageBox.Ok + if accepted: + set_setting(_NOTICE_ACK_KEY, True) + return accepted + + +def confirm_sensitive_content(parent, text: str) -> str: + """Renvoie MASK, SEND ou CANCEL. Si aucun secret evident n'est detecte, + renvoie SEND directement sans rien afficher.""" + secrets = detect_secrets(text) + if not secrets: + return SEND + labels = "\n".join(f"- {s.label}" for s in secrets) + box = QMessageBox(parent) + box.setWindowTitle("Donnees potentiellement sensibles") + box.setIcon(QMessageBox.Warning) + box.setText( + "Des donnees potentiellement sensibles ont ete detectees dans le texte " + f"qui serait envoye au fournisseur IA :\n\n{labels}\n\n" + "« Masquer automatiquement » les remplace par [MASQUE] avant l'envoi.\n" + "« Voir le contenu » l'envoie tel quel, sans modification." + ) + mask_btn = box.addButton("Masquer automatiquement", QMessageBox.AcceptRole) + send_btn = box.addButton("Voir le contenu", QMessageBox.DestructiveRole) + box.addButton("Annuler", QMessageBox.RejectRole) + box.setDefaultButton(mask_btn) + box.exec() + clicked = box.clickedButton() + if clicked is mask_btn: + return MASK + if clicked is send_btn: + return SEND + return CANCEL diff --git a/app/dialogs/ai_settings_dialog.py b/app/dialogs/ai_settings_dialog.py new file mode 100644 index 0000000..13474cf --- /dev/null +++ b/app/dialogs/ai_settings_dialog.py @@ -0,0 +1,161 @@ +"""Configuration de l'assistant IA (optionnel, desactive par defaut) : aucune +liste figee de fournisseurs/modeles, juste une URL de base et un nom de modele +compatibles avec l'API OpenAI (OpenAI, OpenRouter, proxy interne, LLM local...).""" +from __future__ import annotations + +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QFormLayout, QLineEdit, QCheckBox, QDoubleSpinBox, + QPushButton, QDialogButtonBox, QMessageBox, QLabel, +) + +from app.ai.settings import AISettings, load_ai_settings, save_ai_settings, load_api_key, save_api_key +from app.ai.provider import OpenAICompatibleProvider +from app.ai.service import KIND_TEST, AIRequestWorker +from app.credentials import CredentialStore, NullCredentialStore + + +class AISettingsDialog(QDialog): + def __init__(self, credential_store: CredentialStore | None = None, parent=None): + super().__init__(parent) + self.credential_store = credential_store or NullCredentialStore() + self._test_worker: AIRequestWorker | None = None + self.setWindowTitle("Assistant IA") + self.setMinimumWidth(460) + + settings = load_ai_settings() + api_key = load_api_key(self.credential_store) + + layout = QVBoxLayout(self) + form = QFormLayout() + + self.enabled_check = QCheckBox("Activer l'assistant IA") + self.enabled_check.setChecked(settings.enabled) + self.enabled_check.setToolTip( + "Desactive par defaut. Aucune donnee n'est envoyee a un service externe " + "tant que cette case n'est pas cochee et qu'un modele n'est pas configure." + ) + form.addRow("", self.enabled_check) + + self.provider_name_edit = QLineEdit(settings.provider_name) + self.provider_name_edit.setPlaceholderText("ex : OpenAI, OpenRouter, LLM local...") + form.addRow("Fournisseur :", self.provider_name_edit) + + self.base_url_edit = QLineEdit(settings.base_url) + self.base_url_edit.setPlaceholderText("https://api.openai.com/v1") + form.addRow("URL de base :", self.base_url_edit) + + self.model_edit = QLineEdit(settings.model) + self.model_edit.setPlaceholderText("ex : gpt-4o-mini") + form.addRow("Modele :", self.model_edit) + + self.api_key_edit = QLineEdit(api_key) + self.api_key_edit.setEchoMode(QLineEdit.Password) + self.api_key_edit.setPlaceholderText("Laisser vide si le fournisseur n'en requiert pas") + if not self.credential_store.is_available(): + self.api_key_edit.setEnabled(False) + self.api_key_edit.setPlaceholderText("Aucun trousseau securise disponible sur ce systeme") + form.addRow("Cle API :", self.api_key_edit) + + self.timeout_spin = QDoubleSpinBox() + self.timeout_spin.setRange(1.0, 600.0) + self.timeout_spin.setSuffix(" s") + self.timeout_spin.setValue(settings.timeout) + form.addRow("Delai d'attente :", self.timeout_spin) + + self.temperature_spin = QDoubleSpinBox() + self.temperature_spin.setRange(0.0, 2.0) + self.temperature_spin.setSingleStep(0.1) + self.temperature_spin.setValue(settings.temperature) + form.addRow("Temperature :", self.temperature_spin) + + layout.addLayout(form) + + note = QLabel( + "La cle API est memorisee dans le trousseau securise du systeme, jamais " + "dans un fichier de configuration. L'assistant est entierement optionnel : " + "aucune fonctionnalite existante n'en depend." + ) + note.setWordWrap(True) + note.setObjectName("statusLabel") + layout.addWidget(note) + + test_row = QVBoxLayout() + self.test_btn = QPushButton("Tester la connexion") + self.test_btn.clicked.connect(self._test_connection) + self.test_status = QLabel("") + self.test_status.setWordWrap(True) + test_row.addWidget(self.test_btn) + test_row.addWidget(self.test_status) + layout.addLayout(test_row) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self._on_accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + # ---------- lecture du formulaire ---------- + def _form_settings(self) -> AISettings: + return AISettings( + enabled=self.enabled_check.isChecked(), + provider_name=self.provider_name_edit.text().strip() or "OpenAI", + base_url=self.base_url_edit.text().strip(), + model=self.model_edit.text().strip(), + timeout=self.timeout_spin.value(), + temperature=self.temperature_spin.value(), + ) + + def _build_provider(self) -> OpenAICompatibleProvider | None: + settings = self._form_settings() + if not settings.base_url or not settings.model: + QMessageBox.warning( + self, "Configuration incomplete", + "Renseignez au moins l'URL de base et le modele avant de tester.", + ) + return None + return OpenAICompatibleProvider( + base_url=settings.base_url, + api_key=self.api_key_edit.text().strip(), + model=settings.model, + timeout=settings.timeout, + temperature=settings.temperature, + ) + + # ---------- test de connexion ---------- + def _test_connection(self) -> None: + if self._test_worker is not None and self._test_worker.isRunning(): + return + provider = self._build_provider() + if provider is None: + return + self.test_btn.setEnabled(False) + self.test_status.setText("Test en cours...") + self._test_worker = AIRequestWorker(provider, KIND_TEST, "") + self._test_worker.finished_ok.connect(self._on_test_ok) + self._test_worker.finished_error.connect(self._on_test_error) + self._test_worker.start() + + def _on_test_ok(self, _text: str) -> None: + self.test_btn.setEnabled(True) + self.test_status.setText("✓ Connexion reussie.") + + def _on_test_error(self, message: str) -> None: + self.test_btn.setEnabled(True) + self.test_status.setText(f"✗ Echec : {message}") + + # ---------- validation ---------- + def _on_accept(self) -> None: + settings = self._form_settings() + if settings.enabled and (not settings.base_url or not settings.model): + QMessageBox.warning( + self, "Configuration incomplete", + "Renseignez au moins l'URL de base et le modele pour activer l'assistant IA.", + ) + return + save_ai_settings(settings) + save_api_key(self.credential_store, self.api_key_edit.text().strip()) + self.accept() + + def closeEvent(self, event) -> None: + if self._test_worker is not None and self._test_worker.isRunning(): + self._test_worker.wait(2000) + super().closeEvent(event) diff --git a/app/main_window.py b/app/main_window.py index 605ba4e..50c99ca 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -21,6 +21,8 @@ from app.dialogs.known_hosts_dialog import KnownHostsDialog from app.dialogs.workspace_dialog import WorkspaceDialog from app.dialogs.snippet_dialog import SnippetManagerDialog from app.dialogs.quick_connect_dialog import QuickConnectDialog +from app.dialogs.ai_settings_dialog import AISettingsDialog +from app.ai.settings import build_provider_from_settings from app.theme import THEME_LABELS, apply_theme, current_theme, save_theme from app.workspace import WorkspaceStore from app.snippets import SnippetStore @@ -52,7 +54,9 @@ class MainWindow(QMainWindow): self.broadcast = BroadcastManager(self._iter_tabs, self) self.connection_controller = ConnectionController( self.session_store, self.key_manager, self.broadcast, - snippet_store=self.snippet_store, credential_store=self.credential_store, parent_widget=self, + snippet_store=self.snippet_store, credential_store=self.credential_store, + ai_provider_factory=lambda: build_provider_from_settings(self.credential_store), + parent_widget=self, ) self.session_controller = SessionController( self.session_store, self.key_manager, self.broadcast.button, parent_widget=self, @@ -189,6 +193,11 @@ class MainWindow(QMainWindow): manage_snippets_action.triggered.connect(self.open_snippet_manager) snippets_menu.addAction(manage_snippets_action) + ai_menu = self.menuBar().addMenu("&Assistant IA") + ai_settings_action = QAction("Parametres...", self) + ai_settings_action.triggered.connect(self.open_ai_settings) + ai_menu.addAction(ai_settings_action) + help_menu = self.menuBar().addMenu("&Aide") about_action = QAction("A propos", self) about_action.triggered.connect(self.show_about) @@ -246,6 +255,10 @@ class MainWindow(QMainWindow): dialog = SnippetManagerDialog(self.snippet_store, parent=self) dialog.exec() + def open_ai_settings(self) -> None: + dialog = AISettingsDialog(credential_store=self.credential_store, parent=self) + dialog.exec() + def open_quick_connect(self) -> None: dialog = QuickConnectDialog( self.session_store, self.connection_controller, self.workspace_manager, diff --git a/app/ui/ai_panel.py b/app/ui/ai_panel.py new file mode 100644 index 0000000..2e7a913 --- /dev/null +++ b/app/ui/ai_panel.py @@ -0,0 +1,171 @@ +"""Panneau lateral de l'assistant IA : conversation en memoire uniquement +(jamais persistee, jamais journalisee), une requete a la fois. + +Point d'entree unique (`run_request`) pour toutes les actions IA d'un onglet : +c'est ici, et seulement ici, que se fait la verification de securite (avis +unique + detection de secrets) avant tout envoi au fournisseur configure. +""" +from __future__ import annotations + +import html + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QTextEdit, QPushButton, QLabel, QLineEdit, QApplication, +) + +from app.ai.service import AIRequestWorker, KIND_ASK +from app.ai.redaction import redact +from app.dialogs.ai_prompts import ensure_ai_notice_acknowledged, confirm_sensitive_content, MASK, CANCEL + +_KIND_LABELS = { + "explain": "Expliquer cette sortie", + "diagnose": "Diagnostiquer cette erreur", + "solution": "Proposer une solution", + "ask": "Question", +} + + +def _escape(text: str) -> str: + return html.escape(text).replace("\n", "
") + + +class AIPanel(QWidget): + """`provider_factory` est un callable sans argument renvoyant un AIProvider + pret a l'emploi, ou None si l'assistant est desactive/non configure - + rappele a chaque requete pour refleter immediatement un changement de + parametres, sans avoir a rouvrir l'onglet.""" + + def __init__(self, provider_factory, parent=None): + super().__init__(parent) + self.provider_factory = provider_factory + self._worker: AIRequestWorker | None = None + self._generation = 0 + self._last_response = "" + self._pending_context = "" + + layout = QVBoxLayout(self) + layout.setContentsMargins(6, 6, 6, 6) + + header = QHBoxLayout() + header.addWidget(QLabel("🤖 Assistant IA"), 1) + close_btn = QPushButton("✕") + close_btn.setFixedWidth(28) + close_btn.setToolTip("Masquer le panneau") + close_btn.clicked.connect(self.hide) + header.addWidget(close_btn) + layout.addLayout(header) + + self.conversation = QTextEdit() + self.conversation.setReadOnly(True) + layout.addWidget(self.conversation, 1) + + self.busy_label = QLabel("") + self.busy_label.setObjectName("statusLabel") + layout.addWidget(self.busy_label) + + self.question_row = QWidget() + question_layout = QHBoxLayout(self.question_row) + question_layout.setContentsMargins(0, 0, 0, 0) + self.question_edit = QLineEdit() + self.question_edit.setPlaceholderText("Votre question...") + self.question_edit.returnPressed.connect(self._send_question) + send_btn = QPushButton("Envoyer") + send_btn.clicked.connect(self._send_question) + question_layout.addWidget(self.question_edit, 1) + question_layout.addWidget(send_btn) + self.question_row.hide() + layout.addWidget(self.question_row) + + btn_row = QHBoxLayout() + self.copy_btn = QPushButton("Copier reponse") + self.new_question_btn = QPushButton("Nouvelle question") + self.clear_btn = QPushButton("Effacer la conversation") + self.copy_btn.clicked.connect(self._copy_last_response) + self.new_question_btn.clicked.connect(lambda: self.prompt_question("")) + self.clear_btn.clicked.connect(self.clear_conversation) + for b in (self.copy_btn, self.new_question_btn, self.clear_btn): + btn_row.addWidget(b) + layout.addLayout(btn_row) + + # ---------- conversation ---------- + def clear_conversation(self) -> None: + self._generation += 1 # toute reponse en vol devient perimee et sera ignoree + self.conversation.clear() + self._last_response = "" + self.busy_label.clear() + self.question_row.hide() + + def _copy_last_response(self) -> None: + if self._last_response: + QApplication.clipboard().setText(self._last_response) + + # ---------- question libre ---------- + def prompt_question(self, context: str = "") -> None: + """Affiche le panneau et la zone de saisie d'une question. `context` + est un texte optionnel (ex : selection du terminal) joint a la + question, propose a la meme verification de secrets avant l'envoi.""" + self.show() + self._pending_context = context + self.question_row.show() + self.question_edit.setFocus() + + def _send_question(self) -> None: + question = self.question_edit.text().strip() + if not question: + return + self.question_edit.clear() + self.question_row.hide() + context, self._pending_context = self._pending_context, "" + self.run_request(KIND_ASK, question, context=context, label=_KIND_LABELS["ask"]) + + # ---------- requetes ---------- + def run_request(self, kind: str, payload: str, context: str = "", label: str = "") -> None: + if self._worker is not None and self._worker.isRunning(): + self.busy_label.setText("Une requete est deja en cours, patientez...") + return + provider = self.provider_factory() if self.provider_factory else None + if provider is None: + self.show() + self.conversation.append("Assistant IA non configure (menu Assistant IA > Parametres...).") + return + + if not ensure_ai_notice_acknowledged(self): + return + to_check = payload + ("\n" + context if context else "") + decision = confirm_sensitive_content(self, to_check) + if decision == CANCEL: + return + if decision == MASK: + payload = redact(payload) + context = redact(context) if context else context + + self.show() + label = label or _KIND_LABELS.get(kind, kind) + self.conversation.append(f"{html.escape(label)}
{_escape(payload)}") + self.busy_label.setText("Interrogation du fournisseur IA...") + generation = self._generation + self._worker = AIRequestWorker(provider, kind, payload, context=context) + self._worker.finished_ok.connect(lambda text: self._on_ok(generation, text)) + self._worker.finished_error.connect(lambda message: self._on_error(generation, message)) + self._worker.start() + + def _on_ok(self, generation: int, text: str) -> None: + if generation != self._generation: + return + self.busy_label.clear() + self._last_response = text + self.conversation.append(f"
{_escape(text)}
") + + def _on_error(self, generation: int, message: str) -> None: + if generation != self._generation: + return + self.busy_label.clear() + self.conversation.append(f"Erreur : {_escape(message)}") + + def shutdown(self) -> None: + """A appeler avant la destruction du panneau (fermeture de l'onglet) : + une requete deja en vol devient perimee (voir `_generation`) et son + thread dispose d'un court delai pour se terminer proprement.""" + self._generation += 1 + if self._worker is not None and self._worker.isRunning(): + self._worker.wait(2000) diff --git a/app/ui/tabs.py b/app/ui/tabs.py index cdc977a..a43998f 100644 --- a/app/ui/tabs.py +++ b/app/ui/tabs.py @@ -15,7 +15,7 @@ from PySide6.QtCore import Qt, Signal, QTimer from PySide6.QtGui import QKeySequence, QShortcut from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QLabel, QMessageBox, QSplitter, - QFrame, QLineEdit, QFileDialog, QMenu, + QFrame, QLineEdit, QFileDialog, QMenu, QInputDialog, ) from app.session_manager import Session, AUTH_KEY, AUTH_PASSWORD @@ -30,6 +30,12 @@ from app.dialogs.connect_prompt import ask_secret, confirm_host_key, confirm_hos from app.services.ssh_target import detect_ssh_target from app.services.reconnect import compute_backoff_delay from app.snippets import Snippet, SnippetStore +from app.ai.service import KIND_EXPLAIN, KIND_DIAGNOSE, KIND_SOLUTION, KIND_GENERATE, AIRequestWorker +from app.ai.danger import check_dangerous +from app.ai.redaction import redact +from app.ui.ai_panel import AIPanel +from app.dialogs.ai_command_dialog import AICommandDialog +from app.dialogs.ai_prompts import ensure_ai_notice_acknowledged, confirm_sensitive_content, MASK, CANCEL # sequences d'echappement ANSI, retirees des journaux pour qu'ils restent lisibles ANSI_ESCAPE_RE = re.compile( @@ -48,11 +54,15 @@ class BaseTerminalTab(QWidget): def __init__( self, initial_status: str, snippet_store: SnippetStore | None = None, - broadcast=None, parent=None, + broadcast=None, ai_provider_factory=None, credential_store=None, parent=None, ): super().__init__(parent) self.snippet_store = snippet_store self.broadcast = broadcast + self.ai_provider_factory = ai_provider_factory + self.credential_store = credential_store + self.ai_panel: AIPanel | None = None + self._ai_command_worker: AIRequestWorker | None = None layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -82,6 +92,13 @@ class BaseTerminalTab(QWidget): self.snippets_btn.clicked.connect(self._show_snippets_menu) self.bar.addWidget(self.snippets_btn) + self.ai_btn = QToolButton() + self.ai_btn.setText("🤖 Assistant IA") + self.ai_btn.setToolTip("Analyser la sortie selectionnee ou generer une commande (optionnel)") + self.ai_btn.setPopupMode(QToolButton.InstantPopup) + self.ai_btn.clicked.connect(self._show_ai_menu) + self.bar.addWidget(self.ai_btn) + self.log_btn = QToolButton() self.log_btn.setText("⏺ Journal") self.log_btn.setToolTip("Enregistrer la session dans un fichier") @@ -269,6 +286,141 @@ class BaseTerminalTab(QWidget): elif self.connection: self.connection.send(data) + # ---------- assistant IA (optionnel) ---------- + def _show_ai_menu(self) -> None: + menu = self._build_ai_menu() + menu.exec(self.ai_btn.mapToGlobal(self.ai_btn.rect().bottomLeft())) + + def _build_ai_menu(self) -> QMenu: + menu = QMenu(self) + has_selection = self.terminal.has_selection() + + explain_action = menu.addAction("Expliquer cette sortie") + explain_action.setEnabled(has_selection) + explain_action.triggered.connect(self._ai_explain) + + diagnose_action = menu.addAction("Diagnostiquer cette erreur") + diagnose_action.setEnabled(has_selection) + diagnose_action.triggered.connect(self._ai_diagnose) + + solution_action = menu.addAction("Proposer une solution") + solution_action.setEnabled(has_selection) + solution_action.triggered.connect(self._ai_solution) + + ask_action = menu.addAction("Poser une question...") + ask_action.setEnabled(has_selection) + ask_action.triggered.connect(self._ai_ask) + + if not has_selection: + for action in (explain_action, diagnose_action, solution_action, ask_action): + action.setToolTip("Selectionnez d'abord du texte dans le terminal.") + + menu.addSeparator() + generate_action = menu.addAction("Generer une commande...") + generate_action.triggered.connect(self._ai_generate_command) + + menu.addSeparator() + panel_visible = self.ai_panel is not None and self.ai_panel.isVisible() + toggle_action = menu.addAction("Masquer le panneau IA" if panel_visible else "Afficher le panneau IA") + toggle_action.triggered.connect(self._toggle_ai_panel) + + menu.addSeparator() + settings_action = menu.addAction("Parametres de l'assistant IA...") + settings_action.triggered.connect(self._open_ai_settings) + return menu + + def _ensure_ai_panel(self) -> AIPanel: + if self.ai_panel is None: + self.ai_panel = AIPanel(self.ai_provider_factory) + self.splitter.addWidget(self.ai_panel) + # cree masque : sans cela, Qt affiche automatiquement un widget + # fraichement ajoute a un splitter deja visible, ce qui romprait + # l'etat "ferme par defaut" attendu du panneau. + self.ai_panel.hide() + return self.ai_panel + + def _toggle_ai_panel(self) -> None: + panel = self._ensure_ai_panel() + panel.setVisible(not panel.isVisible()) + + def _open_ai_settings(self) -> None: + from app.dialogs.ai_settings_dialog import AISettingsDialog + AISettingsDialog(credential_store=self.credential_store, parent=self).exec() + + def _ai_explain(self) -> None: + self._run_ai_selection_action(KIND_EXPLAIN, "Expliquer cette sortie") + + def _ai_diagnose(self) -> None: + self._run_ai_selection_action(KIND_DIAGNOSE, "Diagnostiquer cette erreur") + + def _ai_solution(self) -> None: + self._run_ai_selection_action(KIND_SOLUTION, "Proposer une solution") + + def _run_ai_selection_action(self, kind: str, label: str) -> None: + selection = self.terminal.selected_text() + if not selection: + return + panel = self._ensure_ai_panel() + panel.run_request(kind, selection, label=label) + + def _ai_ask(self) -> None: + selection = self.terminal.selected_text() + if not selection: + return + panel = self._ensure_ai_panel() + panel.prompt_question(context=selection) + + def _ai_generate_command(self) -> None: + if self._ai_command_worker is not None and self._ai_command_worker.isRunning(): + return + provider = self.ai_provider_factory() if self.ai_provider_factory else None + if provider is None: + QMessageBox.information( + self, "Assistant IA", + "L'assistant IA n'est pas configure (menu Assistant IA > Parametres de l'assistant IA...).", + ) + return + request, ok = QInputDialog.getMultiLineText( + self, "Generer une commande", "Decrivez ce que vous voulez accomplir :", + ) + if not ok or not request.strip(): + return + request = request.strip() + if not ensure_ai_notice_acknowledged(self): + return + decision = confirm_sensitive_content(self, request) + if decision == CANCEL: + return + if decision == MASK: + request = redact(request) + + self.ai_btn.setEnabled(False) + self.ai_btn.setText("🤖 Generation...") + self._ai_command_worker = AIRequestWorker(provider, KIND_GENERATE, request) + self._ai_command_worker.finished_ok.connect(self._on_ai_command_ok) + self._ai_command_worker.finished_error.connect(self._on_ai_command_error) + self._ai_command_worker.start() + + def _reset_ai_generate_button(self) -> None: + self.ai_btn.setEnabled(True) + self.ai_btn.setText("🤖 Assistant IA") + + def _on_ai_command_ok(self, response: str) -> None: + self._reset_ai_generate_button() + dangerous = [m.label for m in check_dangerous(response)] + dialog = AICommandDialog(response, dangerous_labels=dangerous, parent=self) + if dialog.exec() and dialog.insert_requested and dialog.command: + self.insert_snippet_text(dialog.command) + + def _on_ai_command_error(self, message: str) -> None: + self._reset_ai_generate_button() + QMessageBox.critical(self, "Assistant IA", message) + + def insert_snippet_text(self, text: str) -> None: + """N'envoie jamais Entree : le texte est ecrit, a l'utilisateur de valider.""" + if self.connection: + self.connection.send(text.encode("utf-8")) + # ---------- recherche dans l'historique ---------- def open_search(self) -> None: self.search_bar.show() @@ -290,6 +442,10 @@ class BaseTerminalTab(QWidget): def close_connection(self) -> None: self._closing = True # ne jamais reconnecter automatiquement une fermeture voulue self.stop_logging() + if self.ai_panel is not None: + self.ai_panel.shutdown() + if self._ai_command_worker is not None and self._ai_command_worker.isRunning(): + self._ai_command_worker.wait(2000) if self.connection: self.connection.close_connection() self.connection.wait(2000) @@ -300,11 +456,12 @@ class SessionTabWidget(BaseTerminalTab): def __init__( self, session: Session, secret: str, bastion_target=None, bastion_password: str = "", - snippet_store=None, broadcast=None, parent=None, + snippet_store=None, broadcast=None, ai_provider_factory=None, credential_store=None, parent=None, ): super().__init__( f"○ Connexion a {session.username}@{session.host}...", - snippet_store=snippet_store, broadcast=broadcast, parent=parent, + snippet_store=snippet_store, broadcast=broadcast, + ai_provider_factory=ai_provider_factory, credential_store=credential_store, parent=parent, ) self.session = session self._bastion_target = bastion_target @@ -594,10 +751,14 @@ class LocalTerminalTabWidget(BaseTerminalTab): sftp_requested = Signal(object) # cible ssh detectee (ou None) - def __init__(self, shell: str = SHELL_POWERSHELL, snippet_store=None, broadcast=None, parent=None): + def __init__( + self, shell: str = SHELL_POWERSHELL, snippet_store=None, broadcast=None, + ai_provider_factory=None, credential_store=None, parent=None, + ): super().__init__( f"○ Ouverture de {SHELL_LABELS.get(shell, shell)}...", - snippet_store=snippet_store, broadcast=broadcast, parent=parent, + snippet_store=snippet_store, broadcast=broadcast, + ai_provider_factory=ai_provider_factory, credential_store=credential_store, parent=parent, ) self.shell = shell diff --git a/tests/test_ai_command_dialog.py b/tests/test_ai_command_dialog.py new file mode 100644 index 0000000..654a14e --- /dev/null +++ b/tests/test_ai_command_dialog.py @@ -0,0 +1,65 @@ +"""AICommandDialog : extraction de la commande depuis la reponse de l'IA +(bloc de code s'il y en a un), et les 3 actions possibles (Copier / Inserer +dans le terminal / Annuler) - jamais d'execution automatique.""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +from PySide6.QtWidgets import QApplication + +from app.dialogs.ai_command_dialog import AICommandDialog, extract_command + +app = QApplication.instance() or QApplication(sys.argv) + +print("=== 1. extract_command() : bloc de code present ===") +response = "Pour lister les gros fichiers :\n\n```bash\nfind / -size +100M\n```\n\nCela peut prendre du temps." +assert extract_command(response) == "find / -size +100M" +print(" OK") + +print() +print("=== 2. extract_command() : bloc de code sans langage indique ===") +response = "```\ndf -h\n```" +assert extract_command(response) == "df -h" +print(" OK") + +print() +print("=== 3. extract_command() : pas de bloc de code -> reponse entiere ===") +response = "Utilisez simplement : uptime" +assert extract_command(response) == "Utilisez simplement : uptime" +print(" OK") + +print() +print("=== 4. Avertissement affiche seulement si la commande est dangereuse ===") +dialog_safe = AICommandDialog("```\ndf -h\n```") +assert dialog_safe.command == "df -h" +dialog_dangerous = AICommandDialog("```\nrm -rf /tmp/x\n```", dangerous_labels=["rm -rf (suppression recursive)"]) +assert dialog_dangerous.command == "rm -rf /tmp/x" +print(" OK (pas de verification visuelle automatisee, juste la construction sans exception)") + +print() +print("=== 5. 'Copier' place la commande (eventuellement corrigee) dans le presse-papiers ===") +dialog = AICommandDialog("```\necho bonjour\n```") +dialog.command_view.setPlainText("echo modifie") +dialog._copy() +assert QApplication.clipboard().text() == "echo modifie" +print(" OK") + +print() +print("=== 6. 'Inserer dans le terminal' : accepte le dialogue, ne l'execute jamais ===") +dialog = AICommandDialog("```\nls -la\n```") +assert dialog.insert_requested is False +dialog._insert() +assert dialog.insert_requested is True +assert dialog.command == "ls -la" +assert dialog.result() == 1 # QDialog.Accepted +print(" OK") + +print() +print("=== 7. 'Annuler' : rejette le dialogue, insert_requested reste False ===") +dialog = AICommandDialog("```\nls -la\n```") +dialog.cancel_btn.click() +assert dialog.insert_requested is False +assert dialog.result() == 0 # QDialog.Rejected +print(" OK") + +print() +print("AI COMMAND DIALOG : TOUS LES TESTS OK") diff --git a/tests/test_ai_danger.py b/tests/test_ai_danger.py new file mode 100644 index 0000000..af465cb --- /dev/null +++ b/tests/test_ai_danger.py @@ -0,0 +1,57 @@ +"""Detection locale de commandes potentiellement destructives proposees par +l'assistant IA (jamais un blocage, seulement un avertissement).""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +from app.ai.danger import is_dangerous, check_dangerous + +print("=== 1. Commandes destructrices detectees ===") +dangerous_examples = [ + "rm -rf /var/log/*", + "rm -fr /tmp/build", + "mkfs.ext4 /dev/sdb1", + "dd if=/dev/zero of=/dev/sda bs=1M", + "wipefs -a /dev/sdb", + "sudo shutdown -h now", + "sudo reboot", + "iptables -F", + "nft flush ruleset", + "DROP DATABASE production;", + "DELETE FROM users;", + "docker system prune -a --volumes", + "zpool destroy tank", + "lvremove /dev/vg0/lv0", + "vgremove vg0", +] +for cmd in dangerous_examples: + result = is_dangerous(cmd) + print(f" {'[X]' if result else '[ ]'} {cmd}") + assert result, f"aurait du etre detectee comme dangereuse : {cmd}" +print(" OK") + +print() +print("=== 2. Commandes normales non signalees ===") +safe_examples = [ + "df -h", + "systemctl status nginx", + "docker ps -a", + "ls -la /var/log", + "find /var -type f -printf '%s %p\\n' | sort -nr | head -20", + "SELECT * FROM users WHERE id = 1;", + "git rm --cached fichier.txt", # 'rm' present mais pas -rf : ne doit pas matcher +] +for cmd in safe_examples: + result = is_dangerous(cmd) + print(f" {'[X]' if result else '[ ]'} {cmd}") + assert not result, f"n'aurait pas du etre signalee : {cmd}" +print(" OK") + +print() +print("=== 3. check_dangerous() renvoie une explication exploitable ===") +matches = check_dangerous("rm -rf /") +print(" correspondances:", [m.label for m in matches]) +assert matches and "rm" in matches[0].label.lower() +print(" OK") + +print() +print("AI DANGER : TOUS LES TESTS OK") diff --git a/tests/test_ai_panel.py b/tests/test_ai_panel.py new file mode 100644 index 0000000..75d1ab1 --- /dev/null +++ b/tests/test_ai_panel.py @@ -0,0 +1,168 @@ +"""AIPanel : point d'entree unique des requetes IA d'un onglet - verifie que +la verification de securite (avis + secrets) est bien invoquee avant tout +envoi, que les reponses/erreurs mettent a jour la conversation, et qu'une +reponse perimee (generation obsolete) est ignoree.""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +from unittest.mock import patch + +from PySide6.QtCore import QCoreApplication +from PySide6.QtWidgets import QApplication + +from app.ai.provider import AIProvider, AIError +from app.ai.service import KIND_EXPLAIN, KIND_ASK +from app.dialogs.ai_prompts import MASK, SEND, CANCEL +from app.ui.ai_panel import AIPanel + +app = QApplication.instance() or QApplication(sys.argv) + + +class FakeProvider(AIProvider): + def __init__(self, reply="reponse IA", fail_with=None): + self.reply = reply + self.fail_with = fail_with + self.chat_calls = [] + + def chat(self, messages, *, temperature=None): + self.chat_calls.append(messages) + if self.fail_with: + raise self.fail_with + return self.reply + + +def pump(ms=3000): + """Laisse le worker en arriere-plan se terminer et livrer son signal.""" + QCoreApplication.processEvents() + import time + deadline = time.time() + ms / 1000 + while time.time() < deadline: + QCoreApplication.processEvents() + time.sleep(0.01) + + +NOTICE_OK = patch("app.ui.ai_panel.ensure_ai_notice_acknowledged", return_value=True) +SEND_ASIS = patch("app.ui.ai_panel.confirm_sensitive_content", return_value=SEND) + + +print("=== 1. Aucun fournisseur configure : message clair, pas de requete lancee ===") +panel = AIPanel(provider_factory=lambda: None) +panel.run_request(KIND_EXPLAIN, "erreur X", label="Expliquer cette sortie") +assert "non configure" in panel.conversation.toPlainText().lower() +assert panel._worker is None +print(" OK") + +print() +print("=== 2. Avis de principe refuse : rien n'est envoye ===") +provider = FakeProvider() +panel = AIPanel(provider_factory=lambda: provider) +with patch("app.ui.ai_panel.ensure_ai_notice_acknowledged", return_value=False): + panel.run_request(KIND_EXPLAIN, "erreur X", label="Expliquer cette sortie") +assert provider.chat_calls == [] +assert panel._worker is None +print(" OK") + +print() +print("=== 3. Secret detecte + annulation : rien n'est envoye ===") +provider = FakeProvider() +panel = AIPanel(provider_factory=lambda: provider) +with NOTICE_OK, patch("app.ui.ai_panel.confirm_sensitive_content", return_value=CANCEL): + panel.run_request(KIND_EXPLAIN, "password=abc123", label="Expliquer cette sortie") +assert provider.chat_calls == [] +print(" OK") + +print() +print("=== 4. Secret detecte + masquage : le texte envoye est redige ===") +provider = FakeProvider(reply="analyse") +panel = AIPanel(provider_factory=lambda: provider) +with NOTICE_OK, patch("app.ui.ai_panel.confirm_sensitive_content", return_value=MASK): + panel.run_request(KIND_EXPLAIN, "password=abc123", label="Expliquer cette sortie") + pump() +sent_content = provider.chat_calls[0][-1]["content"] +print(" contenu envoye:", sent_content) +assert "abc123" not in sent_content +assert "[MASQUE]" in sent_content +assert panel._last_response == "analyse" +print(" OK") + +print() +print("=== 5. Requete reussie : reponse affichee et memorisee (Copier reponse) ===") +provider = FakeProvider(reply="Tout va bien.") +panel = AIPanel(provider_factory=lambda: provider) +with NOTICE_OK, SEND_ASIS: + panel.run_request(KIND_EXPLAIN, "sortie normale", label="Expliquer cette sortie") + pump() +assert "Tout va bien." in panel.conversation.toPlainText() +panel._copy_last_response() +assert QApplication.clipboard().text() == "Tout va bien." +print(" OK") + +print() +print("=== 6. Erreur du fournisseur : affichee dans la conversation, pas d'exception ===") +provider = FakeProvider(fail_with=AIError("cle API invalide")) +panel = AIPanel(provider_factory=lambda: provider) +with NOTICE_OK, SEND_ASIS: + panel.run_request(KIND_EXPLAIN, "sortie", label="Expliquer cette sortie") + pump() +assert "cle API invalide" in panel.conversation.toPlainText() +print(" OK") + +print() +print("=== 7. Effacer la conversation : generation incrementee, contenu vide ===") +provider = FakeProvider(reply="reponse") +panel = AIPanel(provider_factory=lambda: provider) +with NOTICE_OK, SEND_ASIS: + panel.run_request(KIND_EXPLAIN, "sortie", label="Expliquer cette sortie") + pump() +gen_before = panel._generation +panel.clear_conversation() +assert panel._generation == gen_before + 1 +assert panel.conversation.toPlainText() == "" +assert panel._last_response == "" +print(" OK") + +print() +print("=== 8. Une reponse d'une generation perimee est ignoree (annulation cooperative) ===") +provider = FakeProvider(reply="reponse tardive") +panel = AIPanel(provider_factory=lambda: provider) +with NOTICE_OK, SEND_ASIS: + panel.run_request(KIND_EXPLAIN, "sortie", label="Expliquer cette sortie") + worker = panel._worker + panel.clear_conversation() # invalide la generation avant que le worker n'ait fini + worker.wait(3000) + QCoreApplication.processEvents() +assert "reponse tardive" not in panel.conversation.toPlainText() +print(" OK") + +print() +print("=== 9. Question libre (panneau) : contexte joint, question affichee ===") +provider = FakeProvider(reply="42") +panel = AIPanel(provider_factory=lambda: provider) +panel.prompt_question(context="ligne de contexte") +assert panel.question_row.isVisible() +panel.question_edit.setText("pourquoi ?") +with NOTICE_OK, SEND_ASIS: + panel._send_question() + pump() +assert not panel.question_row.isVisible() +last_messages = provider.chat_calls[-1] +assert any("ligne de contexte" in m["content"] for m in last_messages) +assert last_messages[-1]["content"] == "pourquoi ?" +assert "42" in panel.conversation.toPlainText() +print(" OK") + +print() +print("=== 10. Une requete deja en cours bloque la suivante (une a la fois) ===") +provider = FakeProvider(reply="lente") +panel = AIPanel(provider_factory=lambda: provider) +with NOTICE_OK, SEND_ASIS: + panel.run_request(KIND_EXPLAIN, "sortie 1", label="Expliquer cette sortie") + first_worker = panel._worker + panel.run_request(KIND_EXPLAIN, "sortie 2", label="Expliquer cette sortie") + assert panel._worker is first_worker, "la seconde requete n'aurait pas du demarrer un nouveau worker" + assert "deja en cours" in panel.busy_label.text().lower() + pump() +print(" OK") + +print() +print("AI PANEL : TOUS LES TESTS OK") diff --git a/tests/test_ai_prompts.py b/tests/test_ai_prompts.py new file mode 100644 index 0000000..93e83a1 --- /dev/null +++ b/tests/test_ai_prompts.py @@ -0,0 +1,96 @@ +"""Confirmations de securite avant envoi a l'assistant IA : avis de principe +affiche une seule fois (persist dans app_settings, redirige vers un fichier +temporaire), et verification des secrets a chaque envoi. QMessageBox est +simule (exec()/clickedButton() patches) : aucune boite modale reelle ne doit +bloquer les tests.""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +import tempfile +from pathlib import Path +from unittest.mock import patch + +from PySide6.QtWidgets import QApplication, QMessageBox + +import app.app_settings as app_settings +from app.dialogs.ai_prompts import ensure_ai_notice_acknowledged, confirm_sensitive_content, MASK, SEND, CANCEL + +app = QApplication.instance() or QApplication(sys.argv) + +tmp = Path(tempfile.mkdtemp()) +settings_redirect = patch.object(app_settings, "SETTINGS_FILE", tmp / "settings.json") +settings_redirect.start() + + +def click(button_text): + """Simule un clic sur le bouton `button_text` d'une QMessageBox construite + avec addButton(), sans faire tourner de boucle d'evenements reelle.""" + def fake_clicked(self): + for b in self.buttons(): + if b.text() == button_text: + return b + return None + return fake_clicked + + +print("=== 1. Avis de principe : affiche la premiere fois, refus -> False ===") +with patch("app.dialogs.ai_prompts.QMessageBox.exec", return_value=QMessageBox.Cancel) as mocked_exec: + result = ensure_ai_notice_acknowledged(None) + assert mocked_exec.called + assert result is False +assert app_settings.get_setting("ai_notice_acknowledged", False) is False +print(" OK") + +print() +print("=== 2. Avis de principe : acceptation -> True, persiste ===") +with patch("app.dialogs.ai_prompts.QMessageBox.exec", return_value=QMessageBox.Ok) as mocked_exec: + result = ensure_ai_notice_acknowledged(None) + assert mocked_exec.called + assert result is True +assert app_settings.get_setting("ai_notice_acknowledged", False) is True +print(" OK") + +print() +print("=== 3. Avis de principe : deja accepte -> plus jamais affiche ===") +with patch("app.dialogs.ai_prompts.QMessageBox.exec") as mocked_exec: + result = ensure_ai_notice_acknowledged(None) + assert not mocked_exec.called, "la boite ne doit plus s'afficher une fois l'accord persiste" + assert result is True +print(" OK") + +print() +print("=== 4. Aucun secret detecte : SEND direct, aucune boite affichee ===") +with patch("app.dialogs.ai_prompts.QMessageBox.exec") as mocked_exec: + result = confirm_sensitive_content(None, "systemctl status nginx") + assert not mocked_exec.called + assert result == SEND +print(" OK") + +print() +print("=== 5. Secret detecte + 'Masquer automatiquement' -> MASK ===") +with patch("app.dialogs.ai_prompts.QMessageBox.exec", return_value=0), \ + patch("app.dialogs.ai_prompts.QMessageBox.clickedButton", click("Masquer automatiquement")): + result = confirm_sensitive_content(None, "password=Sup3rSecret!") + assert result == MASK +print(" OK") + +print() +print("=== 6. Secret detecte + 'Voir le contenu' -> SEND (envoi tel quel) ===") +with patch("app.dialogs.ai_prompts.QMessageBox.exec", return_value=0), \ + patch("app.dialogs.ai_prompts.QMessageBox.clickedButton", click("Voir le contenu")): + result = confirm_sensitive_content(None, "password=Sup3rSecret!") + assert result == SEND +print(" OK") + +print() +print("=== 7. Secret detecte + 'Annuler' -> CANCEL ===") +with patch("app.dialogs.ai_prompts.QMessageBox.exec", return_value=0), \ + patch("app.dialogs.ai_prompts.QMessageBox.clickedButton", click("Annuler")): + result = confirm_sensitive_content(None, "password=Sup3rSecret!") + assert result == CANCEL +print(" OK") + +settings_redirect.stop() + +print() +print("AI PROMPTS : TOUS LES TESTS OK") diff --git a/tests/test_ai_provider.py b/tests/test_ai_provider.py new file mode 100644 index 0000000..26ba0d7 --- /dev/null +++ b/tests/test_ai_provider.py @@ -0,0 +1,157 @@ +"""OpenAICompatibleProvider : requete HTTP construite correctement, et gestion +des erreurs (401, 429, timeout, reseau, reponse invalide) - urllib.request.urlopen +est entierement mocke, aucun appel reseau reel.""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +import io +import json +import urllib.error +from unittest.mock import patch, MagicMock + +from app.ai.provider import AIProvider, AIError, OpenAICompatibleProvider + + +def fake_response(payload: dict): + m = MagicMock() + m.read.return_value = json.dumps(payload).encode("utf-8") + m.__enter__.return_value = m + m.__exit__.return_value = False + return m + + +def make_http_error(code: int, body: bytes = b"") -> urllib.error.HTTPError: + return urllib.error.HTTPError(url="http://x/chat/completions", code=code, msg="err", + hdrs=None, fp=io.BytesIO(body)) + + +provider = OpenAICompatibleProvider( + base_url="https://api.example.com/v1", api_key="sk-test", model="gpt-test", timeout=5, temperature=0.2, +) + +print("=== 1. Requete construite correctement (URL, methode, en-tetes, corps) ===") +with patch("app.ai.provider.urllib.request.urlopen") as mocked: + mocked.return_value = fake_response({"choices": [{"message": {"content": "bonjour"}}]}) + result = provider.chat([{"role": "user", "content": "salut"}]) + +request = mocked.call_args.args[0] +print(" URL:", request.full_url) +print(" methode:", request.get_method()) +print(" Authorization:", request.get_header("Authorization")) +body = json.loads(request.data.decode("utf-8")) +print(" corps:", body) +assert request.full_url == "https://api.example.com/v1/chat/completions" +assert request.get_method() == "POST" +assert request.get_header("Authorization") == "Bearer sk-test" +assert body["model"] == "gpt-test" +assert body["temperature"] == 0.2 +assert body["messages"] == [{"role": "user", "content": "salut"}] +assert result == "bonjour" +print(" OK") + +print() +print("=== 2. Erreur 401 -> message clair mentionnant la cle API ===") +with patch("app.ai.provider.urllib.request.urlopen", side_effect=make_http_error(401)): + try: + provider.chat([{"role": "user", "content": "x"}]) + ok = False + except AIError as exc: + ok = True + print(" message:", exc) + assert "401" in str(exc) and "cle" in str(exc).lower() +assert ok +print(" OK") + +print() +print("=== 3. Erreur 429 -> message clair mentionnant la limitation ===") +with patch("app.ai.provider.urllib.request.urlopen", side_effect=make_http_error(429)): + try: + provider.chat([{"role": "user", "content": "x"}]) + ok = False + except AIError as exc: + ok = True + print(" message:", exc) + assert "429" in str(exc) +assert ok +print(" OK") + +print() +print("=== 4. Timeout -> message clair, pas une exception brute ===") +timeout_error = urllib.error.URLError(reason=TimeoutError("timed out")) +with patch("app.ai.provider.urllib.request.urlopen", side_effect=timeout_error): + try: + provider.chat([{"role": "user", "content": "x"}]) + ok = False + except AIError as exc: + ok = True + print(" message:", exc) + assert "delai" in str(exc).lower() or "temps" in str(exc).lower() +assert ok +print(" OK") + +print() +print("=== 5. Erreur reseau (DNS, connexion refusee) -> message clair ===") +network_error = urllib.error.URLError(reason="nom ou service inconnu") +with patch("app.ai.provider.urllib.request.urlopen", side_effect=network_error): + try: + provider.chat([{"role": "user", "content": "x"}]) + ok = False + except AIError as exc: + ok = True + print(" message:", exc) +assert ok +print(" OK") + +print() +print("=== 6. Reponse invalide (JSON incoherent) -> message clair, pas de KeyError brut ===") +with patch("app.ai.provider.urllib.request.urlopen") as mocked: + mocked.return_value = fake_response({"unexpected": "shape"}) + try: + provider.chat([{"role": "user", "content": "x"}]) + ok = False + except AIError as exc: + ok = True + print(" message:", exc) +assert ok +print(" OK") + +print() +print("=== 7. Sans cle API : pas d'en-tete Authorization ===") +no_key_provider = OpenAICompatibleProvider(base_url="http://localhost:8080/v1", api_key="", model="local-model") +with patch("app.ai.provider.urllib.request.urlopen") as mocked: + mocked.return_value = fake_response({"choices": [{"message": {"content": "ok"}}]}) + no_key_provider.chat([{"role": "user", "content": "x"}]) +request2 = mocked.call_args.args[0] +assert request2.get_header("Authorization") is None +print(" OK (utile pour un LLM local sans authentification)") + +print() +print("=== 8. Les 4 usages metier construisent les bons messages (via chat()) ===") +captured = [] + + +class SpyProvider(AIProvider): + def chat(self, messages, *, temperature=None): + captured.append(messages) + return "reponse" + + +spy = SpyProvider() +spy.explain_output("erreur X") +spy.diagnose("erreur Y") +spy.propose_solution("erreur Z") +spy.generate_command("trouver les gros fichiers") +spy.ask("pourquoi ?", context="ligne de contexte") + +print(" nombre d'appels a chat():", len(captured)) +assert len(captured) == 5 +assert "erreur X" in captured[0][-1]["content"] +assert "Cause probable" in captured[1][0]["content"] # structure imposee au system prompt de diagnose +assert "erreur Z" in captured[2][-1]["content"] +assert captured[3][-1]["content"] == "trouver les gros fichiers" +assert "ligne de contexte" in captured[4][1]["content"] +assert captured[4][-1]["content"] == "pourquoi ?" +print(" OK") + +print() +print("AI PROVIDER : TOUS LES TESTS OK") diff --git a/tests/test_ai_redaction.py b/tests/test_ai_redaction.py new file mode 100644 index 0000000..7d66d81 --- /dev/null +++ b/tests/test_ai_redaction.py @@ -0,0 +1,60 @@ +"""Detection/redaction de secrets avant envoi a l'assistant IA.""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +from app.ai.redaction import detect_secrets, redact + +print("=== 1. Cle privee PEM detectee ===") +text = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----" +found = detect_secrets(text) +print(" detecte:", [f.label for f in found]) +assert any("privee" in f.label.lower() for f in found) +print(" OK") + +print() +print("=== 2. Jeton Bearer detecte ===") +found = detect_secrets("Authorization: Bearer sk-abcdefghijklmnopqrstuvwxyz123456") +labels = [f.label for f in found] +print(" detecte:", labels) +assert any("bearer" in l.lower() for l in labels) +assert any("authorization" in l.lower() for l in labels) +print(" OK") + +print() +print("=== 3. Cle AWS detectee ===") +found = detect_secrets("export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE") +assert any("aws" in f.label.lower() for f in found) +print(" OK") + +print() +print("=== 4. password= / api_key= / token= detectes ===") +assert any("mot de passe" in f.label.lower() for f in detect_secrets("password=Sup3rSecret!")) +assert any("api" in f.label.lower() for f in detect_secrets("api_key=abcd1234")) +assert any("jeton" in f.label.lower() for f in detect_secrets("token: eyJhbGciOiJIUzI1NiJ9")) +print(" OK") + +print() +print("=== 5. Chaine longue ressemblant a un secret ===") +found = detect_secrets("valeur=" + "a1b2c3d4e5f6g7h8i9j0" * 3) +assert any("longue" in f.label.lower() for f in found) +print(" OK") + +print() +print("=== 6. Texte anodin : rien detecte ===") +assert detect_secrets("systemd[1]: nginx.service: Failed with result 'exit-code'") == [] +assert detect_secrets("nginx: [emerg] bind() to 0.0.0.0:443 failed (98: Address already in use)") == [] +print(" OK") + +print() +print("=== 7. redact() masque sans lever, texte anodin inchange ===") +redacted = redact("password=abc123 et Bearer xyzxyzxyzxyzxyzxyz") +print(" resultat:", redacted) +assert "abc123" not in redacted +assert "xyzxyzxyzxyzxyzxyz" not in redacted +assert "[MASQUE]" in redacted +clean = "systemctl status nginx" +assert redact(clean) == clean +print(" OK") + +print() +print("AI REDACTION : TOUS LES TESTS OK") diff --git a/tests/test_ai_service.py b/tests/test_ai_service.py new file mode 100644 index 0000000..a4fed32 --- /dev/null +++ b/tests/test_ai_service.py @@ -0,0 +1,168 @@ +"""AIRequestWorker : execution d'une requete IA dans un vrai QThread en +arriere-plan, jamais sur le thread Qt principal - avec dispatch correct selon +le type de requete et remontee des erreurs via signal plutot qu'exception.""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +import threading + +from PySide6.QtCore import QCoreApplication +from PySide6.QtWidgets import QApplication + +from app.ai.provider import AIProvider, AIError +from app.ai.service import ( + AIRequestWorker, KIND_EXPLAIN, KIND_DIAGNOSE, KIND_SOLUTION, KIND_GENERATE, KIND_ASK, KIND_TEST, +) + +app = QApplication.instance() or QApplication(sys.argv) + + +class RecordingProvider(AIProvider): + """Enregistre la methode appelee et le thread d'execution, pour verifier + a la fois le dispatch correct et le fait que ca tourne hors du thread principal.""" + + def __init__(self, fail_with: Exception | None = None): + self.calls = [] + self.threads = [] + self.fail_with = fail_with + + def chat(self, messages, *, temperature=None): + self.calls.append(("chat", messages)) + self.threads.append(threading.current_thread()) + if self.fail_with: + raise self.fail_with + return "reponse-chat" + + def explain_output(self, output): + self.calls.append(("explain_output", output)) + self.threads.append(threading.current_thread()) + if self.fail_with: + raise self.fail_with + return "reponse-explain" + + def diagnose(self, output): + self.calls.append(("diagnose", output)) + self.threads.append(threading.current_thread()) + if self.fail_with: + raise self.fail_with + return "reponse-diagnose" + + def propose_solution(self, output): + self.calls.append(("propose_solution", output)) + self.threads.append(threading.current_thread()) + if self.fail_with: + raise self.fail_with + return "reponse-solution" + + def generate_command(self, request): + self.calls.append(("generate_command", request)) + self.threads.append(threading.current_thread()) + if self.fail_with: + raise self.fail_with + return "reponse-generate" + + def ask(self, question, context=""): + self.calls.append(("ask", (question, context))) + self.threads.append(threading.current_thread()) + if self.fail_with: + raise self.fail_with + return "reponse-ask" + + +def run_worker(worker, timeout_ms=5000): + results = {} + + def on_ok(text): + results["ok"] = text + + def on_error(text): + results["error"] = text + + worker.finished_ok.connect(on_ok) + worker.finished_error.connect(on_error) + worker.start() + worker.wait(timeout_ms) + # laisser la boucle d'evenements livrer les signaux en file d'attente (cross-thread) + QCoreApplication.processEvents() + return results + + +main_thread = threading.current_thread() + +print("=== 1. Dispatch correct par 'kind' (explain/diagnose/solution/generate/ask/test) ===") +cases = [ + (KIND_EXPLAIN, "explain_output", "sortie X", "reponse-explain"), + (KIND_DIAGNOSE, "diagnose", "erreur Y", "reponse-diagnose"), + (KIND_SOLUTION, "propose_solution", "probleme Z", "reponse-solution"), + (KIND_GENERATE, "generate_command", "trouver les gros fichiers", "reponse-generate"), +] +for kind, expected_method, payload, expected_reply in cases: + provider = RecordingProvider() + worker = AIRequestWorker(provider, kind, payload) + results = run_worker(worker) + print(f" kind={kind!r} -> methode appelee: {provider.calls[0][0]!r}, resultat: {results!r}") + assert provider.calls == [(expected_method, payload)] + assert results.get("ok") == expected_reply + assert "error" not in results +print(" OK") + +print() +print("=== 2. KIND_ASK transmet aussi le contexte ===") +provider = RecordingProvider() +worker = AIRequestWorker(provider, KIND_ASK, "pourquoi ?", context="ligne de contexte") +results = run_worker(worker) +print(" appel:", provider.calls[0], "resultat:", results) +assert provider.calls == [("ask", ("pourquoi ?", "ligne de contexte"))] +assert results.get("ok") == "reponse-ask" +print(" OK") + +print() +print("=== 3. KIND_TEST appelle chat() avec un message minimal ===") +provider = RecordingProvider() +worker = AIRequestWorker(provider, KIND_TEST, "") +results = run_worker(worker) +print(" appel:", provider.calls[0], "resultat:", results) +assert provider.calls[0][0] == "chat" +assert results.get("ok") == "reponse-chat" +print(" OK") + +print() +print("=== 4. AIError est capturee et remontee via finished_error, jamais levee ===") +provider = RecordingProvider(fail_with=AIError("cle API invalide")) +worker = AIRequestWorker(provider, KIND_EXPLAIN, "sortie") +results = run_worker(worker) +print(" resultat:", results) +assert results.get("error") == "cle API invalide" +assert "ok" not in results +print(" OK") + +print() +print("=== 5. Exception inattendue capturee aussi (jamais de crash silencieux du thread) ===") +provider = RecordingProvider(fail_with=RuntimeError("boum")) +worker = AIRequestWorker(provider, KIND_EXPLAIN, "sortie") +results = run_worker(worker) +print(" resultat:", results) +assert "error" in results and "boum" in results["error"] +print(" OK") + +print() +print("=== 6. Type de requete inconnu -> erreur claire, pas de plantage ===") +provider = RecordingProvider() +worker = AIRequestWorker(provider, "kind-inexistant", "x") +results = run_worker(worker) +print(" resultat:", results) +assert "error" in results and "inconnu" in results["error"].lower() +print(" OK") + +print() +print("=== 7. L'execution se fait bien dans un thread separe du thread principal ===") +provider = RecordingProvider() +worker = AIRequestWorker(provider, KIND_EXPLAIN, "sortie") +run_worker(worker) +print(" thread principal:", main_thread, "thread d'execution:", provider.threads[0]) +assert provider.threads[0] is not main_thread +assert isinstance(worker, __import__("PySide6.QtCore", fromlist=["QThread"]).QThread) +print(" OK") + +print() +print("AI SERVICE : TOUS LES TESTS OK") diff --git a/tests/test_ai_settings.py b/tests/test_ai_settings.py new file mode 100644 index 0000000..91cf604 --- /dev/null +++ b/tests/test_ai_settings.py @@ -0,0 +1,99 @@ +"""Configuration de l'assistant IA : parametres non sensibles persistes via +app_settings (redirige vers un fichier temporaire pour ne jamais toucher les +vrais reglages), cle API exclusivement via CredentialStore, et construction du +fournisseur a partir des deux.""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +import tempfile +from pathlib import Path +from unittest.mock import patch + +from app.credentials import CredentialStore +from app.ai.settings import ( + AISettings, load_ai_settings, save_ai_settings, load_api_key, save_api_key, + build_provider_from_settings, AI_API_KEY_CREDENTIAL, DEFAULT_BASE_URL, +) +from app.ai.provider import OpenAICompatibleProvider +import app.app_settings as app_settings + + +class FakeCredentialStore(CredentialStore): + def __init__(self): + self._data = {} + + def is_available(self): + return True + + def set_secret(self, key, secret): + self._data[key] = secret + return True + + def get_secret(self, key): + return self._data.get(key) + + def delete_secret(self, key): + self._data.pop(key, None) + + +tmp = Path(tempfile.mkdtemp()) +settings_redirect = patch.object(app_settings, "SETTINGS_FILE", tmp / "settings.json") +settings_redirect.start() + +print("=== 1. Valeurs par defaut : desactive, pas de secret expose ===") +default = load_ai_settings() +print(" ", default) +assert default.enabled is False +assert default.base_url == DEFAULT_BASE_URL +print(" OK") + +print() +print("=== 2. Sauvegarde/rechargement des parametres non sensibles ===") +settings = AISettings(enabled=True, provider_name="OpenRouter", base_url="https://openrouter.ai/api/v1", + model="anthropic/claude", timeout=45.0, temperature=0.7) +save_ai_settings(settings) +reloaded = load_ai_settings() +print(" ", reloaded) +assert reloaded == settings +print(" OK") + +print() +print("=== 3. La cle API ne transite jamais par app_settings (verification du fichier disque) ===") +raw = (tmp / "settings.json").read_text(encoding="utf-8") +assert "sk-" not in raw +assert "api_key" not in raw.lower() or "ai_api" not in raw.lower() # aucune cle presente, seul le nom du champ pourrait +print(" contenu persiste:", raw) +print(" OK") + +print() +print("=== 4. Cle API : lecture/ecriture via CredentialStore uniquement ===") +store = FakeCredentialStore() +assert load_api_key(store) == "" +save_api_key(store, "sk-test-secret") +assert load_api_key(store) == "sk-test-secret" +assert store.get_secret(AI_API_KEY_CREDENTIAL) == "sk-test-secret" +save_api_key(store, "") # vider la cle -> supprime l'entree +assert load_api_key(store) == "" +print(" OK") + +print() +print("=== 5. build_provider_from_settings() : None si desactive ===") +save_ai_settings(AISettings(enabled=False)) +assert build_provider_from_settings(store) is None +print(" OK") + +print() +print("=== 6. build_provider_from_settings() : fournisseur pret si active ===") +save_ai_settings(AISettings(enabled=True, base_url="https://api.example.com/v1", model="gpt-x")) +save_api_key(store, "sk-live") +provider = build_provider_from_settings(store) +assert isinstance(provider, OpenAICompatibleProvider) +assert provider.base_url == "https://api.example.com/v1" +assert provider.model == "gpt-x" +assert provider.api_key == "sk-live" +print(" OK") + +settings_redirect.stop() + +print() +print("AI SETTINGS : TOUS LES TESTS OK") diff --git a/tests/test_ai_tab_integration.py b/tests/test_ai_tab_integration.py new file mode 100644 index 0000000..ecff5ff --- /dev/null +++ b/tests/test_ai_tab_integration.py @@ -0,0 +1,160 @@ +"""Integration de l'assistant IA dans un onglet terminal : menu (active/desactive +selon la selection), panneau lateral cree a la demande dans le splitter (meme +mecanisme que le panneau SFTP), et generation de commande (jamais executee +automatiquement, avec avertissement si dangereuse). + +LocalTerminalConnection.start() est neutralisee comme dans test_snippets.py : +aucun sous-processus reel, send() depose seulement les octets dans une file.""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +from unittest.mock import patch + +from PySide6.QtCore import QCoreApplication +from PySide6.QtWidgets import QApplication + +from app.local_terminal import LocalTerminalConnection +from app.ai.provider import AIProvider +from app.dialogs.ai_prompts import SEND +from app.ui.ai_panel import AIPanel +from app.ui.tabs import LocalTerminalTabWidget + +app = QApplication.instance() or QApplication(sys.argv) + +NOTICE_OK = patch("app.ui.ai_panel.ensure_ai_notice_acknowledged", return_value=True) +SEND_ASIS = patch("app.ui.ai_panel.confirm_sensitive_content", return_value=SEND) +PROMPTS_OK_IN_TABS = patch("app.ui.tabs.ensure_ai_notice_acknowledged", return_value=True) +SEND_ASIS_IN_TABS = patch("app.ui.tabs.confirm_sensitive_content", return_value=SEND) + + +class FakeProvider(AIProvider): + def __init__(self, reply="reponse"): + self.reply = reply + + def chat(self, messages, *, temperature=None): + return self.reply + + +def pump(ms=3000): + import time + deadline = time.time() + ms / 1000 + while time.time() < deadline: + QCoreApplication.processEvents() + time.sleep(0.01) + + +def make_tab(ai_provider_factory=None): + return LocalTerminalTabWidget(ai_provider_factory=ai_provider_factory) + + +print("=== 1. Menu IA : actions de selection desactivees sans selection ===") +with patch.object(LocalTerminalConnection, "start", lambda self: None): + tab = make_tab() + assert not tab.terminal.has_selection() + menu = tab._build_ai_menu() + labels = [a.text() for a in menu.actions() if a.text()] + print(" actions:", labels) + assert "Expliquer cette sortie" in labels + assert "Generer une commande..." in labels + for text in ("Expliquer cette sortie", "Diagnostiquer cette erreur", "Proposer une solution", "Poser une question..."): + action = next(a for a in menu.actions() if a.text() == text) + assert not action.isEnabled(), f"{text} devrait etre desactive sans selection" + generate_action = next(a for a in menu.actions() if a.text() == "Generer une commande...") + assert generate_action.isEnabled(), "Generer une commande ne necessite pas de selection" + tab.close_connection() +print(" OK") + +print() +print("=== 2. Menu IA : actions de selection activees des qu'il y a une selection ===") +with patch.object(LocalTerminalConnection, "start", lambda self: None): + tab = make_tab() + tab.terminal.feed(b"erreur : connection refused\r\n") + tab.terminal._sel_anchor, tab.terminal._sel_focus = (0, 0), (10, 0) + assert tab.terminal.has_selection() + menu = tab._build_ai_menu() + for text in ("Expliquer cette sortie", "Diagnostiquer cette erreur", "Proposer une solution", "Poser une question..."): + action = next(a for a in menu.actions() if a.text() == text) + assert action.isEnabled(), f"{text} devrait etre active avec une selection" + tab.close_connection() +print(" OK") + +print() +print("=== 3. 'Expliquer cette sortie' : cree le panneau IA dans le splitter et lance la requete ===") +with patch.object(LocalTerminalConnection, "start", lambda self: None): + provider = FakeProvider(reply="Ceci indique une connexion refusee.") + tab = make_tab(ai_provider_factory=lambda: provider) + tab.terminal.feed(b"erreur : connection refused\r\n") + tab.terminal._sel_anchor, tab.terminal._sel_focus = (0, 0), (10, 0) + assert tab.ai_panel is None + with NOTICE_OK, SEND_ASIS: + tab._ai_explain() + pump() + assert isinstance(tab.ai_panel, AIPanel) + assert tab.splitter.indexOf(tab.ai_panel) >= 0 + assert "connexion refusee" in tab.ai_panel.conversation.toPlainText().lower() + tab.close_connection() +print(" OK") + +print() +print("=== 4. Basculer l'affichage du panneau IA (menu) ===") +with patch.object(LocalTerminalConnection, "start", lambda self: None): + tab = make_tab(ai_provider_factory=lambda: FakeProvider()) + tab.show() # isVisible() se compose avec les ancetres : il en faut un reellement affiche + tab._toggle_ai_panel() # premiere fois : cree + affiche + assert tab.ai_panel is not None and tab.ai_panel.isVisible() + tab._toggle_ai_panel() # deuxieme fois : masque + assert not tab.ai_panel.isVisible() + tab.close_connection() +print(" OK") + +print() +print("=== 5. Generer une commande : jamais auto-execute, insertion sans Entree ===") +with patch.object(LocalTerminalConnection, "start", lambda self: None): + provider = FakeProvider(reply="Pour lister les gros fichiers :\n\n```bash\nfind / -size +100M\n```") + tab = make_tab(ai_provider_factory=lambda: provider) + with PROMPTS_OK_IN_TABS, SEND_ASIS_IN_TABS, \ + patch("app.ui.tabs.QInputDialog.getMultiLineText", return_value=("trouver les gros fichiers", True)), \ + patch("app.ui.tabs.AICommandDialog.exec", lambda self: (setattr(self, "insert_requested", True), 1)[1]): + tab._ai_generate_command() + pump() + sent = tab.connection._out_queue.get_nowait() + print(" commande inseree:", sent) + assert sent == b"find / -size +100M" + assert not sent.endswith(b"\r") and not sent.endswith(b"\n") + assert tab.ai_btn.isEnabled() and tab.ai_btn.text() == "🤖 Assistant IA" + tab.close_connection() +print(" OK") + +print() +print("=== 6. Generer une commande : annulee par l'utilisateur -> rien envoye ===") +with patch.object(LocalTerminalConnection, "start", lambda self: None): + provider = FakeProvider(reply="```bash\nrm -rf /\n```") + tab = make_tab(ai_provider_factory=lambda: provider) + with patch("app.ui.tabs.QInputDialog.getMultiLineText", return_value=("", False)): + tab._ai_generate_command() + assert tab.connection._out_queue.empty() + tab.close_connection() +print(" OK") + +print() +print("=== 7. Sans fournisseur configure : message clair, pas de boite de saisie ===") +with patch.object(LocalTerminalConnection, "start", lambda self: None): + tab = make_tab(ai_provider_factory=lambda: None) + with patch("app.ui.tabs.QInputDialog.getMultiLineText") as mocked_input, \ + patch("app.ui.tabs.QMessageBox.information") as mocked_info: + tab._ai_generate_command() + assert not mocked_input.called + assert mocked_info.called + tab.close_connection() +print(" OK") + +print() +print("=== 8. Fermeture de l'onglet : le panneau IA est arrete proprement ===") +with patch.object(LocalTerminalConnection, "start", lambda self: None): + tab = make_tab(ai_provider_factory=lambda: FakeProvider()) + tab._toggle_ai_panel() + tab.close_connection() # ne doit pas lever, ni bloquer indefiniment +print(" OK") + +print() +print("AI TAB INTEGRATION : TOUS LES TESTS OK")