From 8fc026c5c71533de12122e350ba05f18981952f4 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 9 Sep 2026 06:41:56 -0400 Subject: [PATCH 01/12] [ADD] assistant : serveur LLM reconnu, catalogue gpt, sessions locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'entrée « Question IA » envoyait toute question à une seule API distante, sur un modèle figé et sans historique : aucun serveur local n'était atteignable, et rien ne disait où partait le texte. Deux règles gouvernent ce qui la remplace. Un port ne dit jamais qui répond — un seul en héberge jusqu'à trois, et une famille réémet l'API d'une autre en entier — donc l'identité se lit dans le CORPS, par une échelle ordonnée. Et une adresse ne devient jamais du texte d'invite : le détecteur du dépôt ne voit pas les noms d'hôte, d'où une poignée opaque à sa place. Vérifié : 284 tests, et contre un serveur du réseau — reconnaissance, capacités lues, conversation multi-tours, triage juste. --- EN --- The "AI question" entry sent every question to a single remote API, on a frozen model and with no history: no local server was reachable, and nothing said where the text was going. Two rules govern what replaces it. A port never says who answers — one hosts up to three products, and one family re-serves another's native API in full — so identity is read from the BODY, by an ordered ladder. And an address never becomes prompt text: the repository's detector does not see host names, hence an opaque handle in its place. Checked: 284 tests, and against a server on the network — recognition, capabilities read, a multi-turn conversation, a correct triage. Assisted-by: Claude Opus 5 --- CHANGELOG.base.md | 24 + CHANGELOG.fr.md | 10 + CHANGELOG.md | 10 + script/todo/assistant/README.base.md | 429 +++++ script/todo/assistant/README.fr.md | 217 +++ script/todo/assistant/README.md | 211 +++ script/todo/assistant/__init__.py | 31 + script/todo/assistant/backends.py | 436 +++++ script/todo/assistant/capabilities.py | 663 +++++++ script/todo/assistant/chat.py | 176 ++ script/todo/assistant/claude_sessions.py | 379 ++++ script/todo/assistant/context.py | 396 +++++ script/todo/assistant/discover.py | 674 +++++++ script/todo/assistant/fingerprint.py | 479 +++++ script/todo/assistant/gpt.py | 473 +++++ .../todo/assistant/gpt/base-md-bilingual.md | 39 + .../assistant/gpt/cloned-module-review.md | 45 + script/todo/assistant/gpt/comment-hygiene.md | 45 + script/todo/assistant/gpt/commit-message.md | 36 + script/todo/assistant/gpt/manifest-gap.md | 35 + .../todo/assistant/gpt/unit-test-failure.md | 36 + script/todo/assistant/servers.py | 277 +++ script/todo/assistant_menu.py | 1568 +++++++++++++++++ script/todo/todo.py | 64 +- script/todo/todo_i18n.py | 667 ++++++- script/todo/todo_prefs.py | 11 + test/llm_fake_server.py | 503 ++++++ test/test_assistant_capabilities.py | 488 +++++ test/test_assistant_claude_sessions.py | 330 ++++ test/test_assistant_context.py | 351 ++++ test/test_assistant_conversation.py | 637 +++++++ test/test_assistant_fingerprint.py | 286 +++ test/test_assistant_gpt_loader.py | 401 +++++ test/test_assistant_hosts.py | 438 +++++ test/test_assistant_menu.py | 561 ++++++ test/test_assistant_probe_transport.py | 207 +++ test/test_assistant_servers.py | 292 +++ test/test_assistant_sweep.py | 375 ++++ test/test_mail_menu.py | 22 +- 39 files changed, 12266 insertions(+), 56 deletions(-) create mode 100644 script/todo/assistant/README.base.md create mode 100644 script/todo/assistant/README.fr.md create mode 100644 script/todo/assistant/README.md create mode 100644 script/todo/assistant/__init__.py create mode 100644 script/todo/assistant/backends.py create mode 100644 script/todo/assistant/capabilities.py create mode 100644 script/todo/assistant/chat.py create mode 100644 script/todo/assistant/claude_sessions.py create mode 100644 script/todo/assistant/context.py create mode 100644 script/todo/assistant/discover.py create mode 100644 script/todo/assistant/fingerprint.py create mode 100644 script/todo/assistant/gpt.py create mode 100644 script/todo/assistant/gpt/base-md-bilingual.md create mode 100644 script/todo/assistant/gpt/cloned-module-review.md create mode 100644 script/todo/assistant/gpt/comment-hygiene.md create mode 100644 script/todo/assistant/gpt/commit-message.md create mode 100644 script/todo/assistant/gpt/manifest-gap.md create mode 100644 script/todo/assistant/gpt/unit-test-failure.md create mode 100644 script/todo/assistant/servers.py create mode 100644 script/todo/assistant_menu.py create mode 100644 test/llm_fake_server.py create mode 100644 test/test_assistant_capabilities.py create mode 100644 test/test_assistant_claude_sessions.py create mode 100644 test/test_assistant_context.py create mode 100644 test/test_assistant_conversation.py create mode 100644 test/test_assistant_fingerprint.py create mode 100644 test/test_assistant_gpt_loader.py create mode 100644 test/test_assistant_hosts.py create mode 100644 test/test_assistant_menu.py create mode 100644 test/test_assistant_probe_transport.py create mode 100644 test/test_assistant_servers.py create mode 100644 test/test_assistant_sweep.py diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index a254272..ab67bfa 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -39,6 +39,12 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The VPN installer looks for `vpnc-script` — a file, not a binary on the `PATH` — and names the package to install per distribution family, instead of letting the tunnel fail on an interface that never appears - A command launched by the VPN runner gets `/dev/null` on standard input, so a captured-output command can no longer be stopped by a SIGTTOU and freeze the machine's package manager - The VPN profile list marks which profiles carry a live tunnel, so connecting one already up asks first and disconnecting one already down says so instead of looking like a mistake. A profile is judged on its interface, not on the state file a tunnel killed without `down` leaves behind — a state left over used to be reported as mounted on the same screen that declared the process gone +- `Assistant › LLM` — ask a model server over several turns, local or remote. The history lives in memory and dies with the menu, `/save` being the only way to keep a trace; every command carries a leading slash, so a question pasted over several lines stays one turn. A third-party destination has to be retyped before the first send +- Server recognition across eleven ports and twelve families, identity read from the response BODY and never from the port: one port hosts up to three products, and one family re-serves another's whole native API +- Finding a server from six sources — the loopback, this machine's QEMU domains, the hosts of `~/.ssh/config`, an address or a network typed by hand, and the networks read over SSH on another machine. Anything wider than a `/24` is refused before enumeration, and two preferences bound the sweep: `assistant_sweep_workers`, `assistant_sweep_timeout` +- A gpt catalogue in `script/todo/assistant/gpt/`: one Markdown file per tool, whose declared requirements are matched against what the server announces. An unknown never greys a tool out — only a requirement contradicted by a field actually read does, with the figure that refuses it +- A declared READ-ONLY context per tool, files and allowlisted commands, shown and confirmed before the first send, capped in size and duration, and scanned for identifying data. The scan's honest limit is stated: it sees addresses, e-mails and account paths, not names +- `Execute › GPT code › Claude Code` — list the machine's sessions, ask one a question with read-only tools, or resume one in its own terminal. A copy is branched by default, since two writers on one session lose a branch @@ -50,6 +56,24 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - L'installateur VPN cherche `vpnc-script` — un fichier, et non un binaire du `PATH` — et nomme le paquet à poser par famille de distribution, au lieu de laisser le tunnel échouer sur une interface qui n'apparaît jamais - Une commande lancée par l'exécuteur VPN reçoit `/dev/null` sur son entrée standard, si bien qu'une commande à sortie capturée ne peut plus être arrêtée par un SIGTTOU et figer le gestionnaire de paquets de la machine - La liste des profils VPN marque ceux qui portent un tunnel vivant, si bien que connecter un profil déjà monté demande confirmation et que déconnecter un profil déjà tombé le dit au lieu de ressembler à une erreur. Un profil est jugé sur son interface et non sur le fichier d'état qu'un tunnel tué sans `down` laisse derrière lui — un état laissé était annoncé monté sur l'écran même qui déclarait le processus mort +- `Assistant › LLM` — interroger un serveur de modèle sur plusieurs tours, local ou distant. L'historique vit en mémoire et meurt avec le menu, `/save` étant le seul moyen d'en garder une trace ; toute commande porte une barre oblique initiale, donc une question collée sur plusieurs lignes reste un seul tour. Une destination tierce doit être retapée avant le premier envoi +- Reconnaissance du serveur sur onze ports et douze familles, l'identité étant lue dans le CORPS de la réponse et jamais dans le port : un port héberge jusqu'à trois produits, et une famille réémet l'API native d'une autre en entier +- Recherche d'un serveur depuis six sources — la boucle locale, les domaines QEMU de la machine, les hôtes de `~/.ssh/config`, une adresse ou un réseau saisi, et les réseaux lus en SSH sur une autre machine. Plus large qu'un `/24` est refusé avant énumération, et deux préférences bornent le balayage : `assistant_sweep_workers`, `assistant_sweep_timeout` +- Un catalogue d'outils gpt dans `script/todo/assistant/gpt/` : un fichier Markdown par outil, dont les exigences déclarées sont confrontées à ce que le serveur annonce. L'inconnu ne grise jamais un outil — seule une exigence contredite par un champ réellement lu le fait, avec le chiffre qui la refuse +- Un contexte LECTURE SEULE déclaré par outil, fichiers et commandes autorisées, montré et confirmé avant le premier envoi, borné en taille et en durée, et balayé à la recherche de données identifiantes. La limite du balayage est dite : il voit les adresses, les courriels et les chemins de compte, pas les noms +- `Exécution › GPT code › Claude Code` — lister les sessions de la machine, en interroger une avec des outils en lecture seule, ou la reprendre dans son propre terminal. Une copie est branchée par défaut, deux écritures sur une même session perdant une branche + + +## Changed + +## Modifié + + +- `Assistant › [1]` no longer sends every question to a single remote API on a fixed model: it asks whichever server is configured, and falls back to the remote one only when no local server answers + + + +- `Assistant › [1]` n'envoie plus chaque question à une seule API distante sur un modèle figé : elle interroge le serveur configuré, et ne retombe sur le distant que lorsqu'aucun serveur local ne répond diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 718dea6..cc925a8 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -19,6 +19,16 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - L'installateur VPN cherche `vpnc-script` — un fichier, et non un binaire du `PATH` — et nomme le paquet à poser par famille de distribution, au lieu de laisser le tunnel échouer sur une interface qui n'apparaît jamais - Une commande lancée par l'exécuteur VPN reçoit `/dev/null` sur son entrée standard, si bien qu'une commande à sortie capturée ne peut plus être arrêtée par un SIGTTOU et figer le gestionnaire de paquets de la machine - La liste des profils VPN marque ceux qui portent un tunnel vivant, si bien que connecter un profil déjà monté demande confirmation et que déconnecter un profil déjà tombé le dit au lieu de ressembler à une erreur. Un profil est jugé sur son interface et non sur le fichier d'état qu'un tunnel tué sans `down` laisse derrière lui — un état laissé était annoncé monté sur l'écran même qui déclarait le processus mort +- `Assistant › LLM` — interroger un serveur de modèle sur plusieurs tours, local ou distant. L'historique vit en mémoire et meurt avec le menu, `/save` étant le seul moyen d'en garder une trace ; toute commande porte une barre oblique initiale, donc une question collée sur plusieurs lignes reste un seul tour. Une destination tierce doit être retapée avant le premier envoi +- Reconnaissance du serveur sur onze ports et douze familles, l'identité étant lue dans le CORPS de la réponse et jamais dans le port : un port héberge jusqu'à trois produits, et une famille réémet l'API native d'une autre en entier +- Recherche d'un serveur depuis six sources — la boucle locale, les domaines QEMU de la machine, les hôtes de `~/.ssh/config`, une adresse ou un réseau saisi, et les réseaux lus en SSH sur une autre machine. Plus large qu'un `/24` est refusé avant énumération, et deux préférences bornent le balayage : `assistant_sweep_workers`, `assistant_sweep_timeout` +- Un catalogue d'outils gpt dans `script/todo/assistant/gpt/` : un fichier Markdown par outil, dont les exigences déclarées sont confrontées à ce que le serveur annonce. L'inconnu ne grise jamais un outil — seule une exigence contredite par un champ réellement lu le fait, avec le chiffre qui la refuse +- Un contexte LECTURE SEULE déclaré par outil, fichiers et commandes autorisées, montré et confirmé avant le premier envoi, borné en taille et en durée, et balayé à la recherche de données identifiantes. La limite du balayage est dite : il voit les adresses, les courriels et les chemins de compte, pas les noms +- `Exécution › GPT code › Claude Code` — lister les sessions de la machine, en interroger une avec des outils en lecture seule, ou la reprendre dans son propre terminal. Une copie est branchée par défaut, deux écritures sur une même session perdant une branche + +## Modifié + +- `Assistant › [1]` n'envoie plus chaque question à une seule API distante sur un modèle figé : elle interroge le serveur configuré, et ne retombe sur le distant que lorsqu'aucun serveur local ne répond ## [1.8.0] - 2026-09-04 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dd691c..859ca50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,16 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The VPN installer looks for `vpnc-script` — a file, not a binary on the `PATH` — and names the package to install per distribution family, instead of letting the tunnel fail on an interface that never appears - A command launched by the VPN runner gets `/dev/null` on standard input, so a captured-output command can no longer be stopped by a SIGTTOU and freeze the machine's package manager - The VPN profile list marks which profiles carry a live tunnel, so connecting one already up asks first and disconnecting one already down says so instead of looking like a mistake. A profile is judged on its interface, not on the state file a tunnel killed without `down` leaves behind — a state left over used to be reported as mounted on the same screen that declared the process gone +- `Assistant › LLM` — ask a model server over several turns, local or remote. The history lives in memory and dies with the menu, `/save` being the only way to keep a trace; every command carries a leading slash, so a question pasted over several lines stays one turn. A third-party destination has to be retyped before the first send +- Server recognition across eleven ports and twelve families, identity read from the response BODY and never from the port: one port hosts up to three products, and one family re-serves another's whole native API +- Finding a server from six sources — the loopback, this machine's QEMU domains, the hosts of `~/.ssh/config`, an address or a network typed by hand, and the networks read over SSH on another machine. Anything wider than a `/24` is refused before enumeration, and two preferences bound the sweep: `assistant_sweep_workers`, `assistant_sweep_timeout` +- A gpt catalogue in `script/todo/assistant/gpt/`: one Markdown file per tool, whose declared requirements are matched against what the server announces. An unknown never greys a tool out — only a requirement contradicted by a field actually read does, with the figure that refuses it +- A declared READ-ONLY context per tool, files and allowlisted commands, shown and confirmed before the first send, capped in size and duration, and scanned for identifying data. The scan's honest limit is stated: it sees addresses, e-mails and account paths, not names +- `Execute › GPT code › Claude Code` — list the machine's sessions, ask one a question with read-only tools, or resume one in its own terminal. A copy is branched by default, since two writers on one session lose a branch + +## Changed + +- `Assistant › [1]` no longer sends every question to a single remote API on a fixed model: it asks whichever server is configured, and falls back to the remote one only when no local server answers ## [1.8.0] - 2026-09-04 diff --git a/script/todo/assistant/README.base.md b/script/todo/assistant/README.base.md new file mode 100644 index 0000000..d962001 --- /dev/null +++ b/script/todo/assistant/README.base.md @@ -0,0 +1,429 @@ + + + + + + +# The LLM assistant — who answers, and what may be said to them + +`script/todo/assistant/` is what `Assistant › LLM` runs: it recognises the +model server listening on a port, reads what that server announces it can do, +keeps the ones you chose, and holds the conversation. + +## A port says where to knock, never who answers + +This is the one thing to understand before reading any of the code. Port 8080 +hosts llama.cpp, LocalAI **and** Open WebUI; port 5000 hosts +text-generation-webui **and** TabbyAPI; `/v1/models` is served by eleven of +the twelve families. A port therefore opens the question — it never answers +it. + +Identity is read in the **body** of a response, by a ladder of thirteen +stages over eleven ports, first agreement wins. The order of that ladder +carries the whole reasoning. LocalAI re-serves Ollama's native API **in +full** — `/api/tags`, `/api/show`, `/api/ps`, `/api/version` — down to the +`Ollama is running` string on `/`. The endpoints that look like Ollama's +therefore do not identify Ollama. LocalAI is separated **first**, by +`GET /readyz`, which Ollama does not have and answers 404 on; the Ollama +stage is reachable only because that separation already happened. Moving the +LocalAI stage further down names every LocalAI machine "ollama". + +`identify()` is pure — it takes bytes already read — so the order of the +ladder is verified without opening a socket. `collect()` is transport only, +and it emits GET alone, with no body and no `Authorization` header: a scan +must not be able to load a model or spend a token. + +## An address never becomes prompt text + +An SSH alias, a host name, an IP address, a VM name all designate machines +that announce themselves nowhere else. Each retained server therefore carries +an opaque **handle** — `server-1` — assigned by its rank at load, and that is +the only form allowed to circulate: `redacted()` renders `server-1 (ollama)`, +and that is what may reach a prompt, a command argument or a file the +repository tracks. The host, the port and the label serve the menu display +and the private configuration, and stop there. + +The restraint is structural because it **cannot** be a filter. The +repository's detector recognises addresses, e-mails and account paths, and +returns an empty list in front of a host name, an SSH alias or a database +name. What no guard rail ever sees go past must not be in a position to go +past. + +## The menu + +`Assistant › LLM` carries five entries. + +| Entry | What it does | +|-------|--------------| +| Free question | the conversation with the server in use, or the remote fallback when no local one answered | +| gpt tools | the catalogue, compatible ones first, picked by letter | +| Known servers | list, pick, add by hand, delete | +| Search for a server… | six sources, from the loopback to a typed network | +| Server card | what the server in use announces it can do | + +Entries are picked by number, the catalogue by LETTER. A second numbered list +right after a numbered menu invites retyping a menu entry, and this repository +has already paid for that once. A digit is still accepted there as a rank, +because the finger has just typed one. + +A third-party destination has to be **retyped** before the first send — +once per session, and for that destination only. A keystroke on "y" is given +by reflex; copying the address makes you look at where the text goes. + +The conversation history lives **in memory and nowhere else** — nothing in +`chat.py` opens a file. It dies with the menu, and `/save` is the only way to +keep a trace of it, which the header says before the conversation that +deserved keeping. Every command carries a leading slash and no bare number is +one: a question pasted over several lines becomes that many turns, and a +pasted line reading `0` would otherwise trigger a menu entry. + +## Finding a server that is not here + +Four sources answer "where should I look": the loopback, the QEMU domains of +this machine, the hosts of `~/.ssh/config`, and a swept `/24`. Two of them are +INJECTED — enumerating libvirt domains and resolving an SSH alias already +exist as methods of the CLI class, which this package may not import. + +A server often lives on a network this machine does not CARRY, reachable +through the gateway: when the CLI runs inside a virtual machine, the "local +network" it sees is the hypervisor's. Two sources answer that — a typed CIDR, +and the networks read over SSH on another machine then swept from here. The +neighbour table cannot: it is link-local, so a routed host never appears in +it. + +Anything wider than a `/24` is refused, and the refusal happens BEFORE +enumeration — measuring a `/8` by materialising it would cost sixteen million +addresses. The pool is sized by WAVE COUNT and never by core count: these +threads wait on the network. And the connection timeout is the one setting +here that manufactures FALSE NEGATIVES — a host reachable in one millisecond +when idle is missed at five hundredths under a thousand simultaneous +connections, so it does not follow from measured latency. + +## The gpt catalogue + +A gpt is one Markdown file: YAML front-matter, a system prompt, and a declared +READ-ONLY context. Nothing in it runs on the model's behalf. + +Its requirements are matched against what the server announces, and one rule +governs the display: UNKNOWN NEVER GREYS OUT. Only a requirement contradicted +by a field actually read from the server does — greying on the unknown would +empty the catalogue in front of a server that announces nothing, which is to +say in front of most of them. An estimated value never greys either. + +`yaml.safe_load` is no validator, and that shapes the loader. Front-matter +that is a list returns a list, a scalar returns a string, an empty file +returns nothing, and a REPEATED key resolves silently to the last — so two +`requires` blocks changed a gpt's safety class without a word. Hence a type +check and a re-read of the raw text. + +A gpt from outside the repository is forced to loopback and may declare no +command at all: a file nobody reviewed is configuration, not data. + +## What a declared context may read + +The deny list comes first and resolves the REAL path: neither "tracked by +git" nor "ignored by git" is a usable gate, since `private/` is partly tracked +and `tasks/` is in no ignore file. A symlink is therefore resolved before it +is compared. + +A command is an argv, never an interpreter string, and it is checked against +an allowlist that lives in the repository. Substitution of a typed input +happens BEFORE that check, never after: validating a template and then +injecting a value would validate what is not run. + +Every assembled byte passes the repository's detector — and the honest limit +is stated rather than hidden. It recognises addresses, e-mails and account +paths. It does NOT recognise names, unless a list enumerates them, and that +list does not usually exist. An absence of findings therefore proves nothing +about names, and a send to a third party is REFUSED in that case even with no +finding at all. + +## Where a server is written, and what is not + +Writing goes through `config_file.set_config_value()`, and through it alone. +Of the three files the read merges, it is the only one that is gitignored: + +| Path | Status | +|------|--------| +| `script/todo/todo.json` | tracked, follows the repository upstream | +| `private/todo/todo_override.json` | not ignored — committable, and public on a fork made public | +| `private/todo/todo_override_private.json` | gitignored — where a server is written | + +`set_config_value()` merges instead of overwriting, and writes atomically: a +temporary created 0600 in the same directory, then `os.replace`. One section +is written, under the key path `assistant › servers`, seven fields per +server, every one of them chosen by you or read from the server you pointed +at. The handle is not written: it is reassigned by rank at load, and a rank +frozen on disk would outlive the deletion of a neighbour. + +Nothing else is written — **no scan report, no liveness table, no negative +result, no timestamped log**. The list of who answered among the 254 +addresses of a `/24` describes machines nobody designated, where a retained +server designates exactly one, on purpose. + +## The machine's Claude Code sessions + +A session open elsewhere already holds a piece of work, and asking it one +question without retyping that is worth the trip. It sits under `GPT code` +rather than the LLM submenu: a session is a process addressed by identifier, a +server is a host addressed by port, and mixing the two in one numbered list +would make two mental models share the same digits. + +Two hazards had to be measured before offering it. A pid does not prove a +session lives — pids are recycled, so liveness needs the pid AND the process's +start time. And the tool does not REFUSE to resume a session a terminal holds, +its guard skipping interactive holders; two writers then split the transcript +and one branch is orphaned. A copy is branched by default, and writing into a +held session requires retyping the holder's pid. + +The privacy boundary is the one the system already drew. The registry is +world-readable, so pid, directory, name and identifier are no secret there. +Transcripts are not: only two STRUCTURAL fields come out of them — the working +directory and the git branch — never a title, a prompt or a message. The +working directory is read there rather than derived from the containing +directory's name, because that transformation turns separators, dots and +underscores all into dashes and so cannot be inverted. + +## The modules + +| File | What it owns | +|------|--------------| +| `__init__.py` | the two rules that govern everything below | +| `fingerprint.py` | who answers on a port: the ladder, and its transport | +| `capabilities.py` | what a server announces it can do, in three tiers of honesty, and matching a requirement against it | +| `servers.py` | the retained servers: opaque handles, reading and writing | +| `backends.py` | speaking to one destination: an HTTP server, or the `claude` CLI | +| `chat.py` | the turns of a conversation, and the commands that drive it | +| `discover.py` | which (host, port) pairs are worth a fingerprint, and the knock | +| `gpt.py` | the catalogue: loading, refusing, and never crashing the menu | +| `context.py` | what a declared context may read, and what the gate allows | +| `claude_sessions.py` | the machine's Claude Code sessions: which live, which resume | +| `../assistant_menu.py` | the mixin: asking and displaying, outside the package | + +None of these modules imports `todo.py`, which costs close to a second and +prints on its way in. The menu mixin is what wires them onto the CLI, and a +subprocess test in `test_assistant_menu.py` fails the day one of them +reaches for it. + + +# L'assistant LLM — qui répond, et ce qu'on a le droit de lui dire + +`script/todo/assistant/` est ce que lance `Assistant › LLM` : il reconnaît le +serveur de modèle qui écoute sur un port, lit ce que ce serveur annonce +savoir faire, garde ceux qu'on a choisis, et tient la conversation. + +## Un port dit où frapper, jamais qui répond + +C'est la seule chose à comprendre avant de lire une ligne du code. Le port +8080 héberge llama.cpp, LocalAI **et** Open WebUI ; le port 5000 héberge +text-generation-webui **et** TabbyAPI ; `/v1/models` est servi par onze des +douze familles. Un port ouvre donc la question — il n'y répond jamais. + +L'identité se lit dans le **corps** d'une réponse, par une échelle de treize +étages sur onze ports, arrêt au premier accord. L'ordre de cette échelle +porte tout le raisonnement. LocalAI réémet l'API native d'Ollama **en +entier** — `/api/tags`, `/api/show`, `/api/ps`, `/api/version` — jusqu'à la +chaîne `Ollama is running` sur `/`. Les points de terminaison qui ressemblent +à ceux d'Ollama n'identifient donc pas Ollama. LocalAI s'écarte le +**premier**, par `GET /readyz`, qu'Ollama ne possède pas et où il rend 404 ; +l'étage Ollama n'est atteignable que parce que cet écart a déjà eu lieu. +Déplacer l'étage LocalAI plus bas nomme « ollama » toutes les machines +LocalAI. + +`identify()` est pure — elle ne reçoit que des octets déjà lus — donc l'ordre +de l'échelle se vérifie sans ouvrir une socket. `collect()` ne fait que le +transport, et n'émet que des GET, sans corps et sans en-tête +`Authorization` : un balayage ne doit pouvoir ni charger un modèle, ni +dépenser un jeton. + +## Une adresse ne devient jamais du texte de prompt + +Un alias SSH, un nom d'hôte, une adresse IP, un nom de VM désignent des +machines qui ne s'annoncent nulle part ailleurs. Chaque serveur retenu porte +donc une **poignée** opaque — `server-1` — attribuée par son rang au +chargement, et c'est la seule forme qui a le droit de circuler : `redacted()` +rend `server-1 (ollama)`, et c'est cela qui peut atteindre une invite, un +argument de commande ou un fichier que le dépôt suit. L'hôte, le port et le +libellé servent à l'affichage du menu et à la configuration privée, et +s'arrêtent là. + +La retenue est structurelle parce qu'elle ne **peut pas** être un filtre. Le +détecteur du dépôt reconnaît les adresses, les courriels et les chemins de +compte, et rend une liste vide devant un nom d'hôte, un alias SSH ou un nom +de base de données. Ce qu'aucun garde-fou ne voit passer ne doit pas être en +position de passer. + +## Le menu + +`Assistant › LLM` porte cinq entrées. + +| Entrée | Ce qu'elle fait | +|--------|-----------------| +| Question libre | la conversation avec le serveur en usage, ou le repli distant quand aucun local n'a répondu | +| Outils gpt | le catalogue, les compatibles en tête, choisis par lettre | +| Serveurs connus | lister, choisir, ajouter à la main, supprimer | +| Chercher un serveur… | six sources, de la boucle locale à un réseau saisi | +| Fiche du serveur | ce que le serveur en usage annonce savoir faire | + +Les entrées se choisissent par numéro, le catalogue par LETTRE. Une seconde +liste numérotée juste après un menu numéroté invite à retaper une entrée de +menu, et ce dépôt l'a déjà payé une fois. Un chiffre y reste accepté comme +rang, parce que le doigt vient d'en taper un. + +Une destination tierce doit être **retapée** avant le premier envoi — une +fois par session, et pour cette destination seulement. Une frappe sur « o » +se donne par réflexe ; recopier l'adresse oblige à regarder où part le +texte. + +L'historique de la conversation vit **en mémoire et nulle part ailleurs** — +rien dans `chat.py` n'ouvre un fichier. Il meurt avec le menu, et `/save` est +le seul moyen d'en garder une trace, ce que l'en-tête dit AVANT la +conversation qui méritait d'être gardée. Toute commande porte une barre +oblique initiale et aucun nombre nu n'en est une : une question collée sur +plusieurs lignes devient autant de tours, et une ligne collée valant `0` +déclencherait sinon une entrée de menu. + +La découverte d'hôtes au-delà de la boucle locale — domaines QEMU, +`~/.ssh/config`, balayage du `/24` local — repose sur quatre sources, dont +deux sont INJECTÉES : l'énumération des domaines libvirt et la résolution d'un +alias SSH existent déjà comme méthodes de la classe du CLI, que ce paquet n'a +pas le droit d'importer. + +Un serveur vit souvent sur un réseau que cette machine ne PORTE pas, joignable +par la passerelle : quand le CLI tourne dans une machine virtuelle, le +« réseau local » qu'il voit est celui de l'hyperviseur. Deux sources y +répondent — un CIDR saisi, et les réseaux lus en SSH sur une autre machine +puis balayés d'ici. La table de voisinage, elle, ne peut pas : elle est +link-local, donc un hôte routé n'y figure jamais. + +Plus large qu'un `/24` est refusé, et le refus précède l'énumération — +mesurer un `/8` en le matérialisant coûterait seize millions d'adresses. La +piscine se dimensionne par NOMBRE DE VAGUES et jamais par nombre de cœurs : +ces fils attendent le réseau. Et le délai de connexion est le seul réglage +d'ici qui fabrique des FAUX NÉGATIFS — un hôte joignable en une milliseconde +au repos se manque à cinq centièmes sous mille connexions simultanées, donc il +ne se déduit pas de la latence mesurée. + +## Le catalogue d'outils gpt + +Un gpt est un fichier Markdown : en-tête YAML, invite système, contexte +READ-ONLY déclaré. Rien n'y s'exécute au nom du modèle. + +Ses exigences sont confrontées à ce que le serveur annonce, et une règle +gouverne l'affichage : L'INCONNU NE GRISE JAMAIS. Seule une exigence +contredite par un champ réellement lu sur le serveur grise — griser sur +l'inconnu viderait le catalogue devant un serveur qui n'annonce rien, +c'est-à-dire devant la plupart. Une valeur estimée ne grise pas non plus. + +`yaml.safe_load` n'est pas un validateur, et cela façonne le chargeur. Un +en-tête qui est une liste rend une liste, un scalaire rend une chaîne, un +fichier vide ne rend rien, et une clé RÉPÉTÉE est résolue en silence sur la +dernière — deux blocs `requires` changeaient donc la classe de sûreté d'un gpt +sans un mot. D'où un contrôle de type et une relecture du texte brut. + +Un gpt hors du dépôt est forcé en boucle locale et ne peut déclarer aucune +commande : un fichier que personne n'a relu est de la configuration, pas une +donnée. + +## Ce qu'un contexte déclaré a le droit de lire + +La liste de refus passe la première et se résout sur le chemin RÉEL : ni +« suivi par git » ni « ignoré par git » n'est une porte utilisable, puisque +`private/` est partiellement suivi et que `tasks/` n'est dans aucun fichier +d'exclusion. Un lien symbolique est donc résolu avant d'être comparé. + +Une commande est un argv, jamais une chaîne d'interpréteur, et elle est +confrontée à une liste d'autorisation qui vit dans le dépôt. La substitution +d'une entrée saisie précède ce contrôle, jamais l'inverse : vérifier un +gabarit puis y injecter une valeur vérifierait ce qu'on n'exécute pas. + +Chaque octet assemblé passe par le détecteur du dépôt — et sa limite est dite +plutôt que cachée. Il reconnaît les adresses, les courriels et les chemins de +compte. Il ne reconnaît PAS les noms, sauf si une liste les énumère, et cette +liste n'existe pas d'ordinaire. Une absence de trouvaille ne prouve donc rien +sur les noms, et un envoi vers un tiers est REFUSÉ dans ce cas, même sans +aucune trouvaille. + +## Où s'écrit un serveur, et ce qui ne s'écrit pas + +L'écriture passe par `config_file.set_config_value()`, et par lui seul. Des +trois fichiers que la lecture fusionne, c'est le seul qui soit gitignored : + +| Chemin | État | +|--------|------| +| `script/todo/todo.json` | suivi, il suit le dépôt en amont | +| `private/todo/todo_override.json` | non ignoré — commitable, et public sur un fork rendu public | +| `private/todo/todo_override_private.json` | gitignored — là où s'écrit un serveur | + +`set_config_value()` fusionne au lieu d'écraser, et écrit atomiquement : un +temporaire créé en 0600 dans le même dossier, puis `os.replace`. Une seule +section est écrite, sous le chemin de clés « assistant › servers », sept +champs par serveur, tous fournis par l'utilisateur ou lus sur le serveur +qu'il a désigné. La poignée n'est pas écrite : elle se rattribue par le rang +au chargement, et un rang figé sur le disque survivrait à la suppression d'un +voisin. + +Rien d'autre n'est écrit — **ni rapport de balayage, ni table de qui est +vivant, ni résultat négatif, ni journal horodaté**. La liste de qui a répondu +parmi les 254 adresses d'un `/24` décrit des machines que personne n'a +désignées, là où un serveur retenu en désigne une seule, volontairement. + +## Les sessions Claude Code de la machine + +Une session ouverte ailleurs porte déjà le contexte d'un travail, et lui poser +une question sans le retaper vaut le détour. Elle vit sous « GPT code » et non +sous le sous-menu LLM : une session est un processus adressé par identifiant, +un serveur est un hôte adressé par port, et les mêler dans une seule liste +numérotée ferait partager les mêmes chiffres à deux modèles mentaux. + +Deux dangers ont dû être mesurés avant de le proposer. Un pid ne prouve pas +qu'une session vit — les pids se recyclent, donc la vivacité exige le pid ET +le moment de démarrage du processus. Et l'outil ne REFUSE pas de reprendre une +session qu'un terminal tient, son garde-fou écartant les détenteurs +interactifs ; deux écritures scindent alors la transcription et une branche est +orpheline. Une copie est branchée par défaut, et écrire dans une session tenue +exige de retaper le pid du détenteur. + +La frontière de vie privée est celle que le système a déjà tracée. Le registre +est lisible par tous, donc pid, répertoire, nom et identifiant n'y sont pas des +secrets. Les transcriptions ne le sont pas : il n'en sort que deux champs de +STRUCTURE — le répertoire de travail et la branche git — jamais un titre, une +invite ou un message. Le répertoire y est LU plutôt que dérivé du nom du +répertoire qui la contient, parce que cette transformation change les +séparateurs, les points et les tirets bas en tirets et ne s'inverse donc pas. + +## Les modules + +| Fichier | Ce qu'il porte | +|---------|----------------| +| `__init__.py` | les deux règles qui gouvernent tout ce qui suit | +| `fingerprint.py` | qui répond sur un port : l'échelle, et son transport | +| `capabilities.py` | ce qu'un serveur annonce savoir faire, en trois paliers d'honnêteté, et l'appariement d'une exigence | +| `servers.py` | les serveurs retenus : poignées opaques, lecture et écriture | +| `backends.py` | parler à une destination : un serveur HTTP, ou le CLI `claude` | +| `chat.py` | les tours d'une conversation, et les commandes qui la pilotent | +| `discover.py` | quels couples (hôte, port) méritent une reconnaissance, et la frappe | +| `gpt.py` | le catalogue : charger, refuser, et ne jamais casser le menu | +| `context.py` | ce qu'un contexte déclaré peut lire, et ce que la porte autorise | +| `claude_sessions.py` | les sessions Claude Code de la machine : lesquelles vivent | +| `../assistant_menu.py` | le mixin : demander et afficher, hors du paquet | + +Aucun de ces modules n'importe `todo.py`, qui coûte près d'une seconde et +imprime en arrivant. C'est le mixin du menu qui les branche sur le CLI, et un +test par sous-processus dans `test_assistant_menu.py` tombe en rouge le jour +où l'un d'eux y touche. + + +## Tests + + +## Tests + + +```bash +make test_unit_file F=test/test_assistant_fingerprint.py +PYTHONPATH=. ./.venv.erplibre/bin/python test/test_assistant_servers.py +for f in test/test_assistant_*.py; do + PYTHONPATH=. ./.venv.erplibre/bin/python "$f" +done +``` diff --git a/script/todo/assistant/README.fr.md b/script/todo/assistant/README.fr.md new file mode 100644 index 0000000..8a0d338 --- /dev/null +++ b/script/todo/assistant/README.fr.md @@ -0,0 +1,217 @@ + +# L'assistant LLM — qui répond, et ce qu'on a le droit de lui dire + +`script/todo/assistant/` est ce que lance `Assistant › LLM` : il reconnaît le +serveur de modèle qui écoute sur un port, lit ce que ce serveur annonce +savoir faire, garde ceux qu'on a choisis, et tient la conversation. + +## Un port dit où frapper, jamais qui répond + +C'est la seule chose à comprendre avant de lire une ligne du code. Le port +8080 héberge llama.cpp, LocalAI **et** Open WebUI ; le port 5000 héberge +text-generation-webui **et** TabbyAPI ; `/v1/models` est servi par onze des +douze familles. Un port ouvre donc la question — il n'y répond jamais. + +L'identité se lit dans le **corps** d'une réponse, par une échelle de treize +étages sur onze ports, arrêt au premier accord. L'ordre de cette échelle +porte tout le raisonnement. LocalAI réémet l'API native d'Ollama **en +entier** — `/api/tags`, `/api/show`, `/api/ps`, `/api/version` — jusqu'à la +chaîne `Ollama is running` sur `/`. Les points de terminaison qui ressemblent +à ceux d'Ollama n'identifient donc pas Ollama. LocalAI s'écarte le +**premier**, par `GET /readyz`, qu'Ollama ne possède pas et où il rend 404 ; +l'étage Ollama n'est atteignable que parce que cet écart a déjà eu lieu. +Déplacer l'étage LocalAI plus bas nomme « ollama » toutes les machines +LocalAI. + +`identify()` est pure — elle ne reçoit que des octets déjà lus — donc l'ordre +de l'échelle se vérifie sans ouvrir une socket. `collect()` ne fait que le +transport, et n'émet que des GET, sans corps et sans en-tête +`Authorization` : un balayage ne doit pouvoir ni charger un modèle, ni +dépenser un jeton. + +## Une adresse ne devient jamais du texte de prompt + +Un alias SSH, un nom d'hôte, une adresse IP, un nom de VM désignent des +machines qui ne s'annoncent nulle part ailleurs. Chaque serveur retenu porte +donc une **poignée** opaque — `server-1` — attribuée par son rang au +chargement, et c'est la seule forme qui a le droit de circuler : `redacted()` +rend `server-1 (ollama)`, et c'est cela qui peut atteindre une invite, un +argument de commande ou un fichier que le dépôt suit. L'hôte, le port et le +libellé servent à l'affichage du menu et à la configuration privée, et +s'arrêtent là. + +La retenue est structurelle parce qu'elle ne **peut pas** être un filtre. Le +détecteur du dépôt reconnaît les adresses, les courriels et les chemins de +compte, et rend une liste vide devant un nom d'hôte, un alias SSH ou un nom +de base de données. Ce qu'aucun garde-fou ne voit passer ne doit pas être en +position de passer. + +## Le menu + +`Assistant › LLM` porte cinq entrées. + +| Entrée | Ce qu'elle fait | +|--------|-----------------| +| Question libre | la conversation avec le serveur en usage, ou le repli distant quand aucun local n'a répondu | +| Outils gpt | le catalogue, les compatibles en tête, choisis par lettre | +| Serveurs connus | lister, choisir, ajouter à la main, supprimer | +| Chercher un serveur… | six sources, de la boucle locale à un réseau saisi | +| Fiche du serveur | ce que le serveur en usage annonce savoir faire | + +Les entrées se choisissent par numéro, le catalogue par LETTRE. Une seconde +liste numérotée juste après un menu numéroté invite à retaper une entrée de +menu, et ce dépôt l'a déjà payé une fois. Un chiffre y reste accepté comme +rang, parce que le doigt vient d'en taper un. + +Une destination tierce doit être **retapée** avant le premier envoi — une +fois par session, et pour cette destination seulement. Une frappe sur « o » +se donne par réflexe ; recopier l'adresse oblige à regarder où part le +texte. + +L'historique de la conversation vit **en mémoire et nulle part ailleurs** — +rien dans `chat.py` n'ouvre un fichier. Il meurt avec le menu, et `/save` est +le seul moyen d'en garder une trace, ce que l'en-tête dit AVANT la +conversation qui méritait d'être gardée. Toute commande porte une barre +oblique initiale et aucun nombre nu n'en est une : une question collée sur +plusieurs lignes devient autant de tours, et une ligne collée valant `0` +déclencherait sinon une entrée de menu. + +La découverte d'hôtes au-delà de la boucle locale — domaines QEMU, +`~/.ssh/config`, balayage du `/24` local — repose sur quatre sources, dont +deux sont INJECTÉES : l'énumération des domaines libvirt et la résolution d'un +alias SSH existent déjà comme méthodes de la classe du CLI, que ce paquet n'a +pas le droit d'importer. + +Un serveur vit souvent sur un réseau que cette machine ne PORTE pas, joignable +par la passerelle : quand le CLI tourne dans une machine virtuelle, le +« réseau local » qu'il voit est celui de l'hyperviseur. Deux sources y +répondent — un CIDR saisi, et les réseaux lus en SSH sur une autre machine +puis balayés d'ici. La table de voisinage, elle, ne peut pas : elle est +link-local, donc un hôte routé n'y figure jamais. + +Plus large qu'un `/24` est refusé, et le refus précède l'énumération — +mesurer un `/8` en le matérialisant coûterait seize millions d'adresses. La +piscine se dimensionne par NOMBRE DE VAGUES et jamais par nombre de cœurs : +ces fils attendent le réseau. Et le délai de connexion est le seul réglage +d'ici qui fabrique des FAUX NÉGATIFS — un hôte joignable en une milliseconde +au repos se manque à cinq centièmes sous mille connexions simultanées, donc il +ne se déduit pas de la latence mesurée. + +## Le catalogue d'outils gpt + +Un gpt est un fichier Markdown : en-tête YAML, invite système, contexte +READ-ONLY déclaré. Rien n'y s'exécute au nom du modèle. + +Ses exigences sont confrontées à ce que le serveur annonce, et une règle +gouverne l'affichage : L'INCONNU NE GRISE JAMAIS. Seule une exigence +contredite par un champ réellement lu sur le serveur grise — griser sur +l'inconnu viderait le catalogue devant un serveur qui n'annonce rien, +c'est-à-dire devant la plupart. Une valeur estimée ne grise pas non plus. + +`yaml.safe_load` n'est pas un validateur, et cela façonne le chargeur. Un +en-tête qui est une liste rend une liste, un scalaire rend une chaîne, un +fichier vide ne rend rien, et une clé RÉPÉTÉE est résolue en silence sur la +dernière — deux blocs `requires` changeaient donc la classe de sûreté d'un gpt +sans un mot. D'où un contrôle de type et une relecture du texte brut. + +Un gpt hors du dépôt est forcé en boucle locale et ne peut déclarer aucune +commande : un fichier que personne n'a relu est de la configuration, pas une +donnée. + +## Ce qu'un contexte déclaré a le droit de lire + +La liste de refus passe la première et se résout sur le chemin RÉEL : ni +« suivi par git » ni « ignoré par git » n'est une porte utilisable, puisque +`private/` est partiellement suivi et que `tasks/` n'est dans aucun fichier +d'exclusion. Un lien symbolique est donc résolu avant d'être comparé. + +Une commande est un argv, jamais une chaîne d'interpréteur, et elle est +confrontée à une liste d'autorisation qui vit dans le dépôt. La substitution +d'une entrée saisie précède ce contrôle, jamais l'inverse : vérifier un +gabarit puis y injecter une valeur vérifierait ce qu'on n'exécute pas. + +Chaque octet assemblé passe par le détecteur du dépôt — et sa limite est dite +plutôt que cachée. Il reconnaît les adresses, les courriels et les chemins de +compte. Il ne reconnaît PAS les noms, sauf si une liste les énumère, et cette +liste n'existe pas d'ordinaire. Une absence de trouvaille ne prouve donc rien +sur les noms, et un envoi vers un tiers est REFUSÉ dans ce cas, même sans +aucune trouvaille. + +## Où s'écrit un serveur, et ce qui ne s'écrit pas + +L'écriture passe par `config_file.set_config_value()`, et par lui seul. Des +trois fichiers que la lecture fusionne, c'est le seul qui soit gitignored : + +| Chemin | État | +|--------|------| +| `script/todo/todo.json` | suivi, il suit le dépôt en amont | +| `private/todo/todo_override.json` | non ignoré — commitable, et public sur un fork rendu public | +| `private/todo/todo_override_private.json` | gitignored — là où s'écrit un serveur | + +`set_config_value()` fusionne au lieu d'écraser, et écrit atomiquement : un +temporaire créé en 0600 dans le même dossier, puis `os.replace`. Une seule +section est écrite, sous le chemin de clés « assistant › servers », sept +champs par serveur, tous fournis par l'utilisateur ou lus sur le serveur +qu'il a désigné. La poignée n'est pas écrite : elle se rattribue par le rang +au chargement, et un rang figé sur le disque survivrait à la suppression d'un +voisin. + +Rien d'autre n'est écrit — **ni rapport de balayage, ni table de qui est +vivant, ni résultat négatif, ni journal horodaté**. La liste de qui a répondu +parmi les 254 adresses d'un `/24` décrit des machines que personne n'a +désignées, là où un serveur retenu en désigne une seule, volontairement. + +## Les sessions Claude Code de la machine + +Une session ouverte ailleurs porte déjà le contexte d'un travail, et lui poser +une question sans le retaper vaut le détour. Elle vit sous « GPT code » et non +sous le sous-menu LLM : une session est un processus adressé par identifiant, +un serveur est un hôte adressé par port, et les mêler dans une seule liste +numérotée ferait partager les mêmes chiffres à deux modèles mentaux. + +Deux dangers ont dû être mesurés avant de le proposer. Un pid ne prouve pas +qu'une session vit — les pids se recyclent, donc la vivacité exige le pid ET +le moment de démarrage du processus. Et l'outil ne REFUSE pas de reprendre une +session qu'un terminal tient, son garde-fou écartant les détenteurs +interactifs ; deux écritures scindent alors la transcription et une branche est +orpheline. Une copie est branchée par défaut, et écrire dans une session tenue +exige de retaper le pid du détenteur. + +La frontière de vie privée est celle que le système a déjà tracée. Le registre +est lisible par tous, donc pid, répertoire, nom et identifiant n'y sont pas des +secrets. Les transcriptions ne le sont pas : il n'en sort que deux champs de +STRUCTURE — le répertoire de travail et la branche git — jamais un titre, une +invite ou un message. Le répertoire y est LU plutôt que dérivé du nom du +répertoire qui la contient, parce que cette transformation change les +séparateurs, les points et les tirets bas en tirets et ne s'inverse donc pas. + +## Les modules + +| Fichier | Ce qu'il porte | +|---------|----------------| +| `__init__.py` | les deux règles qui gouvernent tout ce qui suit | +| `fingerprint.py` | qui répond sur un port : l'échelle, et son transport | +| `capabilities.py` | ce qu'un serveur annonce savoir faire, en trois paliers d'honnêteté, et l'appariement d'une exigence | +| `servers.py` | les serveurs retenus : poignées opaques, lecture et écriture | +| `backends.py` | parler à une destination : un serveur HTTP, ou le CLI `claude` | +| `chat.py` | les tours d'une conversation, et les commandes qui la pilotent | +| `discover.py` | quels couples (hôte, port) méritent une reconnaissance, et la frappe | +| `gpt.py` | le catalogue : charger, refuser, et ne jamais casser le menu | +| `context.py` | ce qu'un contexte déclaré peut lire, et ce que la porte autorise | +| `claude_sessions.py` | les sessions Claude Code de la machine : lesquelles vivent | +| `../assistant_menu.py` | le mixin : demander et afficher, hors du paquet | + +Aucun de ces modules n'importe `todo.py`, qui coûte près d'une seconde et +imprime en arrivant. C'est le mixin du menu qui les branche sur le CLI, et un +test par sous-processus dans `test_assistant_menu.py` tombe en rouge le jour +où l'un d'eux y touche. + +## Tests + +```bash +make test_unit_file F=test/test_assistant_fingerprint.py +PYTHONPATH=. ./.venv.erplibre/bin/python test/test_assistant_servers.py +for f in test/test_assistant_*.py; do + PYTHONPATH=. ./.venv.erplibre/bin/python "$f" +done +``` \ No newline at end of file diff --git a/script/todo/assistant/README.md b/script/todo/assistant/README.md new file mode 100644 index 0000000..b3463f8 --- /dev/null +++ b/script/todo/assistant/README.md @@ -0,0 +1,211 @@ + +# The LLM assistant — who answers, and what may be said to them + +`script/todo/assistant/` is what `Assistant › LLM` runs: it recognises the +model server listening on a port, reads what that server announces it can do, +keeps the ones you chose, and holds the conversation. + +## A port says where to knock, never who answers + +This is the one thing to understand before reading any of the code. Port 8080 +hosts llama.cpp, LocalAI **and** Open WebUI; port 5000 hosts +text-generation-webui **and** TabbyAPI; `/v1/models` is served by eleven of +the twelve families. A port therefore opens the question — it never answers +it. + +Identity is read in the **body** of a response, by a ladder of thirteen +stages over eleven ports, first agreement wins. The order of that ladder +carries the whole reasoning. LocalAI re-serves Ollama's native API **in +full** — `/api/tags`, `/api/show`, `/api/ps`, `/api/version` — down to the +`Ollama is running` string on `/`. The endpoints that look like Ollama's +therefore do not identify Ollama. LocalAI is separated **first**, by +`GET /readyz`, which Ollama does not have and answers 404 on; the Ollama +stage is reachable only because that separation already happened. Moving the +LocalAI stage further down names every LocalAI machine "ollama". + +`identify()` is pure — it takes bytes already read — so the order of the +ladder is verified without opening a socket. `collect()` is transport only, +and it emits GET alone, with no body and no `Authorization` header: a scan +must not be able to load a model or spend a token. + +## An address never becomes prompt text + +An SSH alias, a host name, an IP address, a VM name all designate machines +that announce themselves nowhere else. Each retained server therefore carries +an opaque **handle** — `server-1` — assigned by its rank at load, and that is +the only form allowed to circulate: `redacted()` renders `server-1 (ollama)`, +and that is what may reach a prompt, a command argument or a file the +repository tracks. The host, the port and the label serve the menu display +and the private configuration, and stop there. + +The restraint is structural because it **cannot** be a filter. The +repository's detector recognises addresses, e-mails and account paths, and +returns an empty list in front of a host name, an SSH alias or a database +name. What no guard rail ever sees go past must not be in a position to go +past. + +## The menu + +`Assistant › LLM` carries five entries. + +| Entry | What it does | +|-------|--------------| +| Free question | the conversation with the server in use, or the remote fallback when no local one answered | +| gpt tools | the catalogue, compatible ones first, picked by letter | +| Known servers | list, pick, add by hand, delete | +| Search for a server… | six sources, from the loopback to a typed network | +| Server card | what the server in use announces it can do | + +Entries are picked by number, the catalogue by LETTER. A second numbered list +right after a numbered menu invites retyping a menu entry, and this repository +has already paid for that once. A digit is still accepted there as a rank, +because the finger has just typed one. + +A third-party destination has to be **retyped** before the first send — +once per session, and for that destination only. A keystroke on "y" is given +by reflex; copying the address makes you look at where the text goes. + +The conversation history lives **in memory and nowhere else** — nothing in +`chat.py` opens a file. It dies with the menu, and `/save` is the only way to +keep a trace of it, which the header says before the conversation that +deserved keeping. Every command carries a leading slash and no bare number is +one: a question pasted over several lines becomes that many turns, and a +pasted line reading `0` would otherwise trigger a menu entry. + +## Finding a server that is not here + +Four sources answer "where should I look": the loopback, the QEMU domains of +this machine, the hosts of `~/.ssh/config`, and a swept `/24`. Two of them are +INJECTED — enumerating libvirt domains and resolving an SSH alias already +exist as methods of the CLI class, which this package may not import. + +A server often lives on a network this machine does not CARRY, reachable +through the gateway: when the CLI runs inside a virtual machine, the "local +network" it sees is the hypervisor's. Two sources answer that — a typed CIDR, +and the networks read over SSH on another machine then swept from here. The +neighbour table cannot: it is link-local, so a routed host never appears in +it. + +Anything wider than a `/24` is refused, and the refusal happens BEFORE +enumeration — measuring a `/8` by materialising it would cost sixteen million +addresses. The pool is sized by WAVE COUNT and never by core count: these +threads wait on the network. And the connection timeout is the one setting +here that manufactures FALSE NEGATIVES — a host reachable in one millisecond +when idle is missed at five hundredths under a thousand simultaneous +connections, so it does not follow from measured latency. + +## The gpt catalogue + +A gpt is one Markdown file: YAML front-matter, a system prompt, and a declared +READ-ONLY context. Nothing in it runs on the model's behalf. + +Its requirements are matched against what the server announces, and one rule +governs the display: UNKNOWN NEVER GREYS OUT. Only a requirement contradicted +by a field actually read from the server does — greying on the unknown would +empty the catalogue in front of a server that announces nothing, which is to +say in front of most of them. An estimated value never greys either. + +`yaml.safe_load` is no validator, and that shapes the loader. Front-matter +that is a list returns a list, a scalar returns a string, an empty file +returns nothing, and a REPEATED key resolves silently to the last — so two +`requires` blocks changed a gpt's safety class without a word. Hence a type +check and a re-read of the raw text. + +A gpt from outside the repository is forced to loopback and may declare no +command at all: a file nobody reviewed is configuration, not data. + +## What a declared context may read + +The deny list comes first and resolves the REAL path: neither "tracked by +git" nor "ignored by git" is a usable gate, since `private/` is partly tracked +and `tasks/` is in no ignore file. A symlink is therefore resolved before it +is compared. + +A command is an argv, never an interpreter string, and it is checked against +an allowlist that lives in the repository. Substitution of a typed input +happens BEFORE that check, never after: validating a template and then +injecting a value would validate what is not run. + +Every assembled byte passes the repository's detector — and the honest limit +is stated rather than hidden. It recognises addresses, e-mails and account +paths. It does NOT recognise names, unless a list enumerates them, and that +list does not usually exist. An absence of findings therefore proves nothing +about names, and a send to a third party is REFUSED in that case even with no +finding at all. + +## Where a server is written, and what is not + +Writing goes through `config_file.set_config_value()`, and through it alone. +Of the three files the read merges, it is the only one that is gitignored: + +| Path | Status | +|------|--------| +| `script/todo/todo.json` | tracked, follows the repository upstream | +| `private/todo/todo_override.json` | not ignored — committable, and public on a fork made public | +| `private/todo/todo_override_private.json` | gitignored — where a server is written | + +`set_config_value()` merges instead of overwriting, and writes atomically: a +temporary created 0600 in the same directory, then `os.replace`. One section +is written, under the key path `assistant › servers`, seven fields per +server, every one of them chosen by you or read from the server you pointed +at. The handle is not written: it is reassigned by rank at load, and a rank +frozen on disk would outlive the deletion of a neighbour. + +Nothing else is written — **no scan report, no liveness table, no negative +result, no timestamped log**. The list of who answered among the 254 +addresses of a `/24` describes machines nobody designated, where a retained +server designates exactly one, on purpose. + +## The machine's Claude Code sessions + +A session open elsewhere already holds a piece of work, and asking it one +question without retyping that is worth the trip. It sits under `GPT code` +rather than the LLM submenu: a session is a process addressed by identifier, a +server is a host addressed by port, and mixing the two in one numbered list +would make two mental models share the same digits. + +Two hazards had to be measured before offering it. A pid does not prove a +session lives — pids are recycled, so liveness needs the pid AND the process's +start time. And the tool does not REFUSE to resume a session a terminal holds, +its guard skipping interactive holders; two writers then split the transcript +and one branch is orphaned. A copy is branched by default, and writing into a +held session requires retyping the holder's pid. + +The privacy boundary is the one the system already drew. The registry is +world-readable, so pid, directory, name and identifier are no secret there. +Transcripts are not: only two STRUCTURAL fields come out of them — the working +directory and the git branch — never a title, a prompt or a message. The +working directory is read there rather than derived from the containing +directory's name, because that transformation turns separators, dots and +underscores all into dashes and so cannot be inverted. + +## The modules + +| File | What it owns | +|------|--------------| +| `__init__.py` | the two rules that govern everything below | +| `fingerprint.py` | who answers on a port: the ladder, and its transport | +| `capabilities.py` | what a server announces it can do, in three tiers of honesty, and matching a requirement against it | +| `servers.py` | the retained servers: opaque handles, reading and writing | +| `backends.py` | speaking to one destination: an HTTP server, or the `claude` CLI | +| `chat.py` | the turns of a conversation, and the commands that drive it | +| `discover.py` | which (host, port) pairs are worth a fingerprint, and the knock | +| `gpt.py` | the catalogue: loading, refusing, and never crashing the menu | +| `context.py` | what a declared context may read, and what the gate allows | +| `claude_sessions.py` | the machine's Claude Code sessions: which live, which resume | +| `../assistant_menu.py` | the mixin: asking and displaying, outside the package | + +None of these modules imports `todo.py`, which costs close to a second and +prints on its way in. The menu mixin is what wires them onto the CLI, and a +subprocess test in `test_assistant_menu.py` fails the day one of them +reaches for it. + +## Tests + +```bash +make test_unit_file F=test/test_assistant_fingerprint.py +PYTHONPATH=. ./.venv.erplibre/bin/python test/test_assistant_servers.py +for f in test/test_assistant_*.py; do + PYTHONPATH=. ./.venv.erplibre/bin/python "$f" +done +``` \ No newline at end of file diff --git a/script/todo/assistant/__init__.py b/script/todo/assistant/__init__.py new file mode 100644 index 0000000..0106e6d --- /dev/null +++ b/script/todo/assistant/__init__.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Assistant LLM du CLI TODO : quel serveur répond, et que lui dire. + +Le paquet est découpé par responsabilité : `fingerprint` reconnaît QUI répond +sur un port, `capabilities` traduit cette reconnaissance en ce que le modèle +sait faire, `servers` garde les serveurs retenus, `backends` parle à l'un +d'eux, `chat` tient la conversation. Aucun de ces modules n'importe `todo.py` ; +c'est le mixin `script/todo/assistant_menu.py` qui les branche sur le CLI. + +La frontière est tenue par un test — importer ce paquet ne doit jamais tirer +`script.todo.todo`, qui coûte près d'une seconde et imprime sur la sortie. + +Deux règles gouvernent tout ce qui suit, et aucune n'est une précaution de +style. + +**Une adresse ne devient jamais du texte de prompt.** Un alias SSH, un nom +d'hôte, une adresse IP, un nom de VM désignent des machines qui ne +s'annoncent nulle part ailleurs. Un serveur porte donc une POIGNÉE opaque — +`server-1` — et c'est elle qui circule ; l'adresse vit dans l'affichage du +menu et dans la configuration privée, jamais dans une invite, un argument de +commande ou un fichier que le dépôt suit. Le détecteur du dépôt reconnaît les +adresses, les courriels et les chemins de compte ; il ne reconnaît PAS les +noms, ce qui rend le filtrage insuffisant et la structure nécessaire. + +**Le port dit où frapper, jamais qui répond.** Trois logiciels écoutent sur +8080, deux sur 5000, et l'un d'eux réémet l'API d'un autre à l'identique. La +reconnaissance se lit dans le CORPS d'une réponse, dans un ordre fixe dont +`fingerprint` porte la raison. +""" diff --git a/script/todo/assistant/backends.py b/script/todo/assistant/backends.py new file mode 100644 index 0000000..bfe92de --- /dev/null +++ b/script/todo/assistant/backends.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Parler à UNE destination : un serveur HTTP, ou le CLI `claude`. + +Un backend ne connaît ni l'historique, ni les commandes, ni l'affichage : il +prend une liste de messages, rend le texte de la réponse et un dictionnaire de +faits (modèle qui a répondu, jetons, raison d'arrêt). `chat.py` tient la +conversation au-dessus, le menu demande et affiche. + +Deux formes, et la différence porte un piège que `keeps_history` nomme : +`HttpBackend` est SANS mémoire — l'historique complet repart à chaque tour — +là où une session `claude` tient sa propre histoire côté processus. Renvoyer +l'historique local à une session qui la garde déjà la doublerait. + +**La clé ne quitte jamais le processus.** Elle va à `openai.OpenAI(api_key=…)` +en mémoire, jamais sur une ligne de commande ni dans une variable +d'environnement : un argv se lit par n'importe quel compte local dès que +`/proc` est monté sans `hidepid`, et le masquage de +`script/execute/execute.py` ne reconnaît que `PASSWORD|PASSWD|SECRET|TOKEN`, +donc ni `OPENAI_API_KEY=` ni `Authorization: Bearer`. + +**L'invite de `claude` part sur l'entrée standard**, jamais en positionnel, +pour la même raison. `claude_argv` bâtit donc l'argv SANS l'invite, et +l'appelant écrit la question sur stdin. + +Onze des douze familles de serveurs exposent `/v1/chat/completions` à +l'identique : un seul client `openai` les couvre toutes, pointé sur ce que +`servers.base_url()` rend. C'est aussi ce client qu'un test injecte pour +parler à un vrai serveur de boucle locale plutôt qu'à un double. +""" +from __future__ import annotations + +import json +import subprocess +from typing import Protocol + +# La lecture est plafonnée pour qu'un corps d'erreur de plusieurs mégaoctets +# reste un message d'une ligne. +MAX_DETAIL = 500 + +# `openai.OpenAI` refuse de se construire sans clé, et un serveur local n'en +# vérifie aucune : cette chaîne occupe la place sans rien ouvrir. +NO_KEY = "no-key" + +# Un modèle local qui rend 700 jetons sur un processeur prend des minutes ; +# le délai n'est là que pour borner un serveur qui ne répondra jamais. +TIMEOUT = 300.0 + +# Une seule reprise : au-delà d'une panne de connexion, réessayer un envoi +# relance une génération entière chez qui la paie. +MAX_RETRIES = 1 + +# Le budget d'un aller-retour `claude -p`, qui inclut le démarrage du CLI. +CLAUDE_TIMEOUT = 600 + +# Les outils de lecture, et rien d'autre. Ce sont des drapeaux, et non une +# phrase d'invite système : un drapeau est ce qui tient l'engagement. +READ_ONLY_TOOLS = "Read,Glob,Grep" + + +class BackendError(Exception): + """Une panne à montrer sur une ligne : le corps du serveur y est repris. + + Le texte porte déjà le détail utile — code de statut, message du serveur, + cause de la connexion refusée — pour que l'appelant l'imprime tel quel + sans avoir à lire une trace. + """ + + +class Interrupted(Exception): + """Une lecture de flux coupée en route, qui porte ce qui est déjà arrivé. + + `partial` est le texte reçu avant la coupure et `meta` les faits déjà + connus. Ce qui a été reçu a été payé : l'appelant le garde plutôt que de + le jeter. + """ + + def __init__(self, partial: str = "", meta: dict | None = None): + super().__init__(partial) + self.partial = partial + self.meta = meta or {} + + +class Backend(Protocol): + """Ce qu'une destination doit savoir faire. + + `keeps_history` dit si la destination garde l'histoire de son côté : quand + il vaut vrai, l'appelant n'envoie QUE le nouveau tour. + + `send` rend `(texte, faits)`. Quand `on_chunk` est fourni, chaque fragment + lui est passé au fil de l'arrivée et la somme des fragments EST le texte + rendu — l'appelant imprime les fragments ou le texte, jamais les deux. + Une panne lève `BackendError` ; une coupure lève `Interrupted`. + """ + + keeps_history: bool + + def send( + self, messages, *, on_chunk=None + ) -> tuple[str, dict]: # pragma: no cover - contrat + ... + + +def one_line(texte: str) -> str: + """Un texte de panne ramené à une ligne et coupé à `MAX_DETAIL`. + + Un serveur qui rend une page d'erreur entière ne doit pas dérouler + l'écran, et un message sur plusieurs lignes se confond avec une trace. + """ + plat = " ".join((texte or "").split()) + if len(plat) > MAX_DETAIL: + return f"{plat[:MAX_DETAIL]} …" + return plat + + +def readable(exc: Exception) -> str: + """Une panne réduite à une ligne, cause comprise, jamais une trace. + + La cause est ajoutée quand elle apprend quelque chose : une erreur de + connexion du client `openai` ne dit que « Connection error. », et c'est + sa cause qui nomme le refus. + """ + detail = str(exc).strip() or type(exc).__name__ + cause = exc.__cause__ + if cause is not None: + extra = str(cause).strip() + if extra and extra not in detail: + detail = f"{detail} ({extra})" + return one_line(detail) + + +def _usage(usage) -> dict: + """Les compteurs de jetons en types simples, {} quand ils manquent. + + Un serveur local en omet souvent une partie, et le menu doit pouvoir les + imprimer sans vérifier chaque champ. + """ + if usage is None: + return {} + faits = {} + for champ in ("prompt_tokens", "completion_tokens", "total_tokens"): + valeur = getattr(usage, champ, None) + if isinstance(valeur, int): + faits[champ] = valeur + return faits + + +class HttpBackend: + """Un serveur qui expose `/v1/chat/completions`. + + Sans mémoire : l'historique complet part à chaque tour, ce que + `keeps_history = False` annonce à l'appelant. + + `client` est le client `openai` injecté ; laissé à `None`, il se construit + paresseusement sur `servers.base_url(server)` au premier envoi, ce qui + rend cette classe importable et testable sans serveur ni socket. + `params` porte les réglages du gpt (`temperature`, `max_tokens`) et part + tel quel dans l'appel. + """ + + keeps_history = False + + def __init__( + self, server, model, *, api_key=None, params=None, client=None + ): + self.server = server + self.model = model + self.params = dict(params or {}) + self._api_key = api_key + self._client = client + + def client(self): + """Le client `openai`, construit au premier besoin. + + La clé va dans le constructeur, en mémoire ; l'absence de clé devient + `NO_KEY` parce que le client refuse de se bâtir sans rien et qu'un + serveur local n'en lit aucune. + """ + if self._client is None: + from openai import OpenAI + + from script.todo.assistant.servers import base_url + + self._client = OpenAI( + base_url=base_url(self.server), + api_key=self._api_key or NO_KEY, + timeout=TIMEOUT, + max_retries=MAX_RETRIES, + ) + return self._client + + def send(self, messages, *, on_chunk=None) -> tuple[str, dict]: + """Un aller-retour de génération. Lève `BackendError` sur panne.""" + from openai import OpenAIError + + # Le modèle et les messages écrasent `params` : le serveur choisi + # décide du modèle, et un `params` de gpt qui nommerait l'un des deux + # ferait lever le constructeur au lieu de répondre. + appel = dict(self.params) + appel["model"] = self.model + appel["messages"] = list(messages) + try: + if on_chunk is None: + return self._whole(appel) + return self._streamed(appel, on_chunk) + except OpenAIError as panne: + raise BackendError(readable(panne)) from panne + + def _create(self, appel, **extra): + """L'appel de génération, avec un refus de paramètre nommé. + + Un `params` de gpt qui porte un réglage propre à un serveur — `num_ctx` + pour l'un, `top_k` pour un autre — fait lever le client sur un argument + inattendu : nommer le paramètre vaut mieux qu'une trace, et c'est le + fichier du gpt qui se corrige. + """ + try: + reponse = self.client().chat.completions.create(**appel, **extra) + except TypeError as refus: + raise BackendError(one_line(f"{self.model}: {refus}")) from refus + if isinstance(reponse, (str, bytes)): + # Un corps qui n'est pas du JSON — la page d'administration d'un + # routeur sur un port partagé — traverse le client comme du + # texte : il n'a ni choix à lire ni flux à dérouler. + raise BackendError(one_line(f"{self.model}: {reponse!r}")) + return reponse + + def _whole(self, appel) -> tuple[str, dict]: + """La réponse d'un seul bloc, quand personne n'écoute les fragments.""" + reponse = self._create(appel) + choix = list(getattr(reponse, "choices", None) or ()) + if not choix: + raise BackendError( + one_line(f"{self.model}: no choice in the answer — {reponse}") + ) + texte = getattr(choix[0].message, "content", None) or "" + raison = getattr(choix[0], "finish_reason", "") or "" + if not texte.strip(): + # Un serveur dont le moteur de modèle s'arrête en cours de route + # rend un 200 avec un contenu VIDE, et l'afficher tel quel se + # confond avec un modèle qui n'a rien à dire. La cause du silence + # est dans `finish_reason`, donc il est nommé : sans cela, une + # panne de ressources sur l'hôte du modèle se lit comme un défaut + # du menu. + raise BackendError( + one_line( + f"{self.model}: empty answer" + f" (finish_reason: {raison or 'none'})" + ) + ) + faits = { + "model": getattr(reponse, "model", "") or self.model, + "usage": _usage(getattr(reponse, "usage", None)), + "finish_reason": raison, + } + return texte, faits + + def _streamed(self, appel, on_chunk) -> tuple[str, dict]: + """La réponse fragment par fragment. + + Une interruption ferme le flux et lève `Interrupted` avec ce qui est + déjà arrivé : la socket ne doit pas rester ouverte derrière, et le + texte reçu est gardé. + """ + morceaux: list[str] = [] + faits = {"model": self.model, "usage": {}, "finish_reason": ""} + flux = self._create(appel, stream=True) + try: + for evenement in flux: + faits["model"] = ( + getattr(evenement, "model", "") or faits["model"] + ) + usage = _usage(getattr(evenement, "usage", None)) + if usage: + faits["usage"] = usage + for choix in evenement.choices or (): + delta = getattr(choix, "delta", None) + morceau = getattr(delta, "content", None) or "" + if morceau: + morceaux.append(morceau) + on_chunk(morceau) + fin = getattr(choix, "finish_reason", None) + if fin: + faits["finish_reason"] = fin + except KeyboardInterrupt: + # La fermeture rend la socket ; l'échec de cette fermeture ne doit + # pas coûter le texte déjà reçu, qui est ce qu'on vient garder. + try: + flux.close() + except Exception: + pass + raise Interrupted("".join(morceaux), faits) from None + return "".join(morceaux), faits + + +def claude_argv(*, session_id, cwd, fork, read_only=True) -> list[str]: + """L'argv d'un `claude -p`, SANS l'invite : elle part sur stdin. + + `--output-format json` rend une enveloppe qui nomme la session, le + résultat, l'erreur, le nombre de tours, le coût et le modèle qui a + répondu ; c'est la seule forme lisible par un programme. + + `fork` ajoute `--fork-session`, et c'est le défaut pour questionner une + session vivante : reprendre une session tenue par un terminal interactif + n'est PAS refusée par le CLI, et deux écritures concurrentes sur un même + identifiant forkent la transcription en silence — une branche devient + orpheline. Sans `session_id` il n'y a rien à brancher, donc rien à + ajouter. + + `read_only` impose la lecture seule par des DRAPEAUX — `--tools` et + `--permission-mode dontAsk` — et non par une phrase d'invite système, qui + n'engage rien. `--add-dir` ouvre le répertoire à lire quand il est connu. + """ + argv = ["claude", "-p", "--output-format", "json"] + if session_id: + argv += ["--resume", str(session_id)] + if fork: + argv.append("--fork-session") + if read_only: + argv += ["--tools", READ_ONLY_TOOLS] + argv += ["--permission-mode", "dontAsk"] + if cwd: + argv += ["--add-dir", str(cwd)] + return argv + + +def _run_stdin(argv, stdin_text): + """Lance `argv` en écrivant `stdin_text` sur son entrée standard. + + Rend `(code, sortie, erreur)`. Le sous-processus n'hérite d'aucun terminal + et l'invite ne passe par aucun argument : c'est tout l'intérêt de ce + chemin. + """ + fini = subprocess.run( + argv, + input=stdin_text, + capture_output=True, + text=True, + timeout=CLAUDE_TIMEOUT, + check=False, + ) + return fini.returncode, fini.stdout, fini.stderr + + +class ClaudeCliBackend: + """Une session `claude` locale, questionnée par `claude -p`. + + `keeps_history = True` : la session garde son histoire côté processus, + donc seul le nouveau tour lui est envoyé. + + Après un envoi réussi, la session adoptée est celle que l'enveloppe + nomme et `fork` retombe à faux : sans cela, chaque tour re-brancherait la + session d'origine et le second tour ne verrait pas le premier. + + `run` est le lanceur injecté — `(argv, stdin) -> (code, sortie, erreur)` ; + laissé à `None`, il lance un vrai sous-processus. + """ + + keeps_history = True + + def __init__(self, *, session_id=None, cwd=None, fork=True, run=None): + self.session_id = session_id + self.cwd = cwd + self.fork = fork + self._run = run + + def argv(self) -> list[str]: + return claude_argv( + session_id=self.session_id, cwd=self.cwd, fork=self.fork + ) + + def send(self, messages, *, on_chunk=None) -> tuple[str, dict]: + """Envoie le dernier tour utilisateur et rend `(résultat, faits)`. + + Un message `system` n'est pas transmis : la session porte son propre + système, et pousser du contexte en masse par `--append-system-prompt` + est refusé. + + `claude -p --output-format json` ne diffuse rien ; `on_chunk` reçoit + donc le résultat en un fragment, pour que l'appelant garde un seul + chemin d'affichage. + """ + invite = self._prompt(messages) + lanceur = self._run or _run_stdin + try: + code, sortie, erreur = lanceur(self.argv(), invite) + except FileNotFoundError as absent: + raise BackendError("claude is not on the PATH.") from absent + except subprocess.TimeoutExpired as expire: + raise BackendError(readable(expire)) from expire + enveloppe = self._envelope(code, sortie, erreur) + resultat = str(enveloppe.get("result") or "") + if enveloppe.get("is_error"): + raise BackendError(one_line(resultat or erreur)) + session = enveloppe.get("session_id") + if session: + self.session_id = session + self.fork = False + faits = { + "session_id": self.session_id or "", + "num_turns": enveloppe.get("num_turns"), + "total_cost_usd": enveloppe.get("total_cost_usd"), + "modelUsage": enveloppe.get("modelUsage") or {}, + } + if on_chunk is not None and resultat: + on_chunk(resultat) + return resultat, faits + + @staticmethod + def _prompt(messages) -> str: + """Le texte du dernier tour utilisateur, "" s'il n'y en a aucun.""" + for message in reversed(list(messages or ())): + if message.get("role") == "user": + return str(message.get("content") or "") + return "" + + @staticmethod + def _envelope(code, sortie, erreur) -> dict: + """L'enveloppe JSON de `claude -p`, ou une panne qui cite la sortie. + + Une sortie qui n'est pas du JSON est le cas d'un CLI qui a refusé + avant de commencer : le début du texte est ce qui l'explique, et il + vaut mieux le montrer que de lever sur l'analyse. + """ + texte = (sortie or "").strip() + try: + enveloppe = json.loads(texte) + except ValueError: + detail = texte or (erreur or "").strip() + raise BackendError( + one_line(f"claude rc={code}: {detail}") + ) from None + if not isinstance(enveloppe, dict): + raise BackendError(f"claude rc={code}: unexpected envelope") + return enveloppe diff --git a/script/todo/assistant/capabilities.py b/script/todo/assistant/capabilities.py new file mode 100644 index 0000000..862e3a6 --- /dev/null +++ b/script/todo/assistant/capabilities.py @@ -0,0 +1,663 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ce qu'un serveur ANNONCE savoir faire, et ce qu'un gpt exige de lui. + +La lecture des capacités a trois degrés d'honnêteté, et les confondre est le +mode de défaillance qui compte. AUTOMATIQUE — Ollama, LocalAI par sa +compatibilité Ollama, llama.cpp — publie ses capacités, sa longueur de +contexte et son nombre de paramètres : la lecture est un fait. PARTIEL — LM +Studio donne le contexte et la vision mais aucun drapeau d'outils, KoboldCpp +des booléens à l'échelle du serveur mais aucun contexte. MUET — vLLM, +text-generation-webui, Jan, Open WebUI, TabbyAPI, GPT4All et l'API OpenAI +distante, dont le schéma de modèle ne porte aucun champ de capacité : TOUT +vaut alors `None`, et c'est le cas du point de terminaison le plus courant. + +Un champ vaut `None` tant qu'il n'a pas été LU dans une réponse. Une valeur +tirée du NOM d'un modèle est un indice et non une lecture : elle est remplie, +et son nom entre dans `estimated`. + +D'où la règle que `match` applique : **l'inconnu ne grise jamais**. Seule une +exigence contredite par une valeur réellement lue rend « no ». Un champ vide +rend « unknown » : l'entrée reste lançable, et l'exigence se répète par son +nom au moment de l'envoi. Une valeur estimée ne grise pas davantage — +refuser un montage qui fonctionne sur une devinette est un refus que +l'utilisateur ne peut pas discuter. Griser sur l'inconnu viderait le +catalogue devant un serveur générique compatible OpenAI, qui n'annonce rien. + +`hosting` est la seule exigence qui ne peut jamais être inconnue : elle ne +demande aucune requête, donc elle peut griser dès le premier contact. Elle +porte deux pièges. `ipaddress` rapporte la boucle locale comme privée AUSSI, +d'où une échelle qui teste `is_loopback` d'abord, `is_private` ensuite. Et +`ip_address` lève sur un nom d'hôte : un nom se résout d'abord, et un nom qui +ne résout pas se lit comme `global`, la lecture pessimiste, jamais comme +satisfait. +""" +from __future__ import annotations + +import ipaddress +import json +import re +import socket +import urllib.request +from dataclasses import dataclass, replace + +from script.todo.assistant.fingerprint import Fingerprint + +# Les six clés d'exigence, dans l'ordre où une raison se rend. `hosting` +# d'abord parce que c'est la seule qui grise sans avoir parlé au serveur. +REQUIREMENT_KEYS = ( + "hosting", + "context_window", + "parameters", + "tool_calling", + "vision", + "json_output", +) + +# L'échelle d'hébergement est ORDONNÉE : loopback satisfait lan, qui satisfait +# any. Une seule clé exprime « doit être local » ET « pas de tiers », qui sont +# deux demandes différentes — une machine du réseau local n'est ni l'une ni +# l'autre. +HOSTING_ORDER = ("loopback", "lan", "global") +ANY_HOSTING = "any" +DEFAULT_HOSTING = "lan" + +# Les raisons sont des CLÉS i18n, et `t()` rend une clé absente inchangée : une +# clé oubliée s'affiche en anglais correct. Les `%s` se remplissent par +# l'appelant, qui tient déjà `requires` et les capacités. +REASON_HOSTING = "asks for a local server, this one is %s" +REASON_CONTEXT = "asks for %s of context, this server announces %s" +REASON_PARAMETERS = "asks for %s B of parameters, this server announces %s B" +REASON_TOOLS = "asks for tool calling, this server does not announce it" +REASON_VISION = "asks for vision, this server does not announce it" +REASON_JSON = "asks for JSON output, this server does not announce it" + +# La raison d'un « unknown » NOMME l'exigence qu'on n'a pas pu vérifier : +# l'envoi la répète, et « une exigence non vérifiée » sans son nom n'apprend +# rien. Les six chaînes sont écrites une par une pour qu'un balayage des clés +# i18n les trouve, là où une clé assemblée à l'exécution serait invisible. +REASON_UNCHECKED = { + "hosting": "hosting could not be checked", + "context_window": "context_window could not be checked on this server", + "parameters": "parameters could not be checked on this server", + "tool_calling": "tool_calling could not be checked on this server", + "vision": "vision could not be checked on this server", + "json_output": "json_output could not be checked on this server", +} +REASON_UNCHECKED_OTHER = "a requirement could not be checked" + +# Le corps d'une réponse de capacités pèse quelques kilo-octets. Le plafond +# existe pour qu'un mauvais chemin — une page d'administration, un flux qui ne +# finit pas — ne puisse pas remplir la mémoire. +BODY_LIMIT = 1_000_000 + +# La lecture arrive après que l'utilisateur a désigné un serveur : elle peut +# attendre plus longtemps qu'un balayage, et charger un modèle prend du temps. +BUDGET = 3.0 + +# Un nombre suivi de « b » dans un texte : « 7.2B » d'un serveur, « 7b » d'un +# nom de modèle. La barrière devant le nombre écarte le numéro de version d'un +# nom comme « modele2.5:7b », où le chiffre voulu est le dernier. +PARAMETER_SIZE = re.compile(r"(? str: + """La classe d'hébergement de `host` : loopback, lan ou global. + + Rend toujours l'une des trois, et la plus pessimiste quand un nom porte + plusieurs adresses : une destination n'est locale que si toutes ses + adresses le sont. + + `resolve(host)` rend les adresses en chaînes et vaut `None` par défaut, + résolu au résolveur du système. Un résolveur qui échoue, un nom qui ne + résout pas, un hôte vide comptent pour `global` — la lecture pessimiste + est la seule qui ne présente jamais un tiers comme local. + """ + addresses = _addresses(host, resolve) + if not addresses: + return HOSTING_ORDER[-1] + return HOSTING_ORDER[max(_rank_of_address(a) for a in addresses)] + + +def match(requires: dict, caps: Capabilities, hosting: str) -> tuple[str, str]: + """L'appariement d'un gpt à un serveur : (verdict, clé de raison). + + Le verdict est « ok », « unknown » ou « no », et la raison une clé i18n, + vide sur « ok ». Un « no » l'emporte sur un « unknown », et à verdict égal + c'est la première exigence de `REQUIREMENT_KEYS` qui donne la raison. + + `requires["hosting"]` vaut `lan` en son absence : un gpt qui ne dit rien + n'autorise pas pour autant un tiers. Les autres clés absentes ne sont pas + des exigences. Une clé hors de l'ensemble fermé rend « unknown » plutôt que + d'être ignorée en silence. + """ + requires = requires or {} + verdicts = [ + _check(key, requires, caps, hosting) + for key in _requirement_order(requires) + ] + for wanted in ("no", "unknown"): + for verdict, reason in verdicts: + if verdict == wanted: + return verdict, reason + return "ok", "" + + +def read( + fingerprint: Fingerprint, + host: str, + port: int, + *, + http_post=None, + http_get=None, +) -> Capabilities: + """Les capacités du modèle reconnu par `fingerprint` sur `host:port`. + + Ne consulte de l'empreinte que `software`, qui choisit le lecteur, et + `models`, dont le premier nom désigne le modèle interrogé. Ne lève jamais : + un corps qui n'est pas du JSON, un statut autre que 200, un champ absent + laissent le champ à `None`, parce qu'un menu qui plante sur une réponse + inattendue est pire qu'un menu qui dit ne pas savoir. + + `http_get(host, port, path)` et `http_post(host, port, path, payload)` + rendent `(statut, corps)` ou `None`, valent `None` par défaut et se + résolvent au transport de ce module. Ce transport est le seul du paquet à + émettre un POST : la découverte n'émet que des GET pour qu'un balayage ne + puisse jamais déclencher une génération, alors que la lecture des capacités + arrive après qu'un serveur a été désigné. + """ + software = getattr(fingerprint, "software", "") or "" + models = tuple(getattr(fingerprint, "models", ()) or ()) + # Muet et non reconnu se rendent pareil, et sans aucune requête : il n'y a + # rien à demander à un serveur dont le schéma ne porte pas de capacité. + if software in SILENT or software not in READERS: + return NOTHING_ANNOUNCED + caps = READERS[software]( + models, + _getter(host, port, http_get), + _poster(host, port, http_post), + ) + return _estimate_parameters(caps, models[0] if models else "") + + +def _addresses(host, resolve): + """Les adresses de `host`, en chaînes ; vide si rien ne se résout.""" + host = _bare_host(host) + if not host: + return [] + if _is_address(host): + return [host] + if resolve is None: + resolve = _resolve_addresses + try: + return [str(a) for a in resolve(host) or ()] + except Exception: + # Un résolveur peut lever autre chose qu'une erreur système : celui du + # système lève `OSError`, un résolveur injecté ce qu'il veut. Toutes + # ces issues disent la même chose, que le nom n'est pas résolu. + return [] + + +def _bare_host(host): + """L'hôte débarrassé de ce qui empêche `ip_address` de le lire. + + Les crochets d'une adresse IPv6 littérale et l'identifiant de zone qu'un + résolveur accroche à une adresse de lien local font tous deux lever + `ip_address`, alors que l'adresse elle-même est parfaitement classable. + """ + host = (host or "").strip() + if host.startswith("[") and host.endswith("]"): + host = host[1:-1] + return host.split("%", 1)[0].strip() + + +def _is_address(text): + try: + ipaddress.ip_address(text) + except ValueError: + return False + return True + + +def _resolve_addresses(host): + """Les adresses de `host` selon le résolveur du système.""" + return [info[4][0] for info in socket.getaddrinfo(host, None)] + + +def _rank_of_address(text): + """Le rang de `text` dans `HOSTING_ORDER`. + + La boucle locale est testée AVANT le privé : `ipaddress` rapporte + 127.0.0.1 comme privé aussi, et l'ordre inverse classerait la machine même + comme du réseau local. Ce qui n'est ni l'un ni l'autre — une plage de + transition d'opérateur autant qu'une adresse publique — est global : le + rang le plus haut est celui qui ne promet rien. + """ + try: + address = ipaddress.ip_address(_bare_host(text)) + except ValueError: + return len(HOSTING_ORDER) - 1 + if address.is_loopback: + return 0 + if address.is_private: + return 1 + return len(HOSTING_ORDER) - 1 + + +def _requirement_order(requires): + """Les clés à vérifier : l'ensemble fermé, puis ce qu'il ne couvre pas.""" + extra = sorted(k for k in requires if k not in REQUIREMENT_KEYS) + return REQUIREMENT_KEYS + tuple(extra) + + +def _check(key, requires, caps, hosting): + """Le verdict d'UNE exigence : (verdict, clé de raison). + + Deux clés se comportent à part. `hosting` est vérifié même absent, sur son + défaut. Toute autre clé absente n'est pas une exigence et rend « ok », ce + qui distingue « ce gpt ne demande rien là-dessus » de « le serveur ne dit + rien là-dessus », qui rend « unknown ». + """ + if key == "hosting": + return _check_hosting(requires.get(key, DEFAULT_HOSTING), hosting) + if key not in requires: + return "ok", "" + if key == "context_window": + return _check_number(key, requires[key], caps, REASON_CONTEXT) + if key == "parameters": + return _check_number(key, requires[key], caps, REASON_PARAMETERS) + if key == "tool_calling": + return _check_flag(key, requires[key], caps, REASON_TOOLS) + if key == "vision": + return _check_flag(key, requires[key], caps, REASON_VISION) + if key == "json_output": + return _check_flag(key, requires[key], caps, REASON_JSON) + return "unknown", REASON_UNCHECKED_OTHER + + +def _check_hosting(required, actual): + """L'échelle d'hébergement, la seule qui grise sans requête. + + Un barreau que l'échelle ne connaît pas rend « unknown » : ce n'est pas une + contradiction, et griser un gpt sur une faute de frappe dans son en-tête + serait un refus sans recours. + """ + wanted = _hosting_rank(required) + reached = _hosting_rank(actual) + if wanted is None or reached is None: + return "unknown", REASON_UNCHECKED["hosting"] + if reached > wanted: + return "no", REASON_HOSTING + return "ok", "" + + +def _hosting_rank(value): + """Le rang d'un barreau, `any` au plus permissif ; None si inconnu.""" + if value == ANY_HOSTING: + return len(HOSTING_ORDER) - 1 + if value in HOSTING_ORDER: + return HOSTING_ORDER.index(value) + return None + + +def _check_number(key, required, caps, reason): + """Un seuil : satisfait quand la valeur LUE atteint ce qui est demandé.""" + try: + wanted = float(required) + except (TypeError, ValueError): + return "unknown", REASON_UNCHECKED[key] + announced = getattr(caps, key) + if announced is None or key in caps.estimated: + return "unknown", REASON_UNCHECKED[key] + if float(announced) < wanted: + return "no", reason + return "ok", "" + + +def _check_flag(key, required, caps, reason): + """Un booléen. Un gpt qui n'en a pas besoin est satisfait par tout.""" + if not required: + return "ok", "" + announced = getattr(caps, key) + if announced is None or key in caps.estimated: + return "unknown", REASON_UNCHECKED[key] + if not announced: + return "no", reason + return "ok", "" + + +def _authority(host, port): + """`hôte:port`, l'hôte entre crochets quand c'est une adresse IPv6.""" + host = _bare_host(host) + if ":" in host: + host = f"[{host}]" + return f"{host}:{port}" + + +def _http(host, port, path, payload=None): + """`(statut, corps)` d'une requête, ou `None` si elle n'aboutit pas. + + N'envoie AUCUN en-tête d'autorisation : un serveur qui exige une clé + n'annonce rien plutôt que de s'en voir présenter une, une clé se + configurant contre un serveur qu'on a nommé. Le corps est lu jusqu'au + plafond, jamais jusqu'à la fin. + """ + url = f"http://{_authority(host, port)}{path}" + data = None + headers = {} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + request = urllib.request.Request(url, data=data, headers=headers) + try: + with urllib.request.urlopen(request, timeout=BUDGET) as answer: + return answer.status, answer.read(BODY_LIMIT) + except Exception: + # Une socket refusée, un délai dépassé, un statut d'erreur, un corps + # coupé : la lecture n'a rien appris, et le champ reste vide. + return None + + +def _getter(host, port, http_get): + """Un lecteur de chemin, qui rend le corps JSON d'un GET ou `None`.""" + + def fetch(path): + call = http_get if http_get is not None else _http + return _json(call(host, port, path)) + + return fetch + + +def _poster(host, port, http_post): + """Un lecteur de chemin, qui rend le corps JSON d'un POST ou `None`.""" + + def fetch(path, payload): + if http_post is not None: + return _json(http_post(host, port, path, payload)) + return _json(_http(host, port, path, payload)) + + return fetch + + +def _json(answer): + """Le dictionnaire d'une réponse 200, `None` pour tout le reste. + + Un statut de démarrage ou un défi d'authentification est un serveur vivant, + mais il n'annonce aucune capacité ; du HTML et un corps tronqué non plus. + """ + if not answer: + return None + status, body = answer + if status != 200: + return None + if isinstance(body, (bytes, bytearray)): + body = bytes(body).decode("utf-8", "replace") + try: + parsed = json.loads(body) + except (TypeError, ValueError): + return None + return parsed if isinstance(parsed, dict) else None + + +def _integer(value): + """La valeur en entier positif, `None` si ce n'en est pas un. + + Un booléen est écarté avant tout : `isinstance(True, int)` est vrai, et un + drapeau lu comme la longueur 1 serait une capacité inventée. Un zéro ou un + négatif n'annonce rien non plus. + """ + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return int(value) if value > 0 else None + if isinstance(value, str): + try: + return _integer(int(value.strip())) + except ValueError: + return None + return None + + +def _billions(count): + """Un nombre de paramètres COMPTÉ, rendu en milliards.""" + value = _integer(count) + return None if value is None else round(value / 1e9, 2) + + +def _billions_from_text(text): + """Le nombre de milliards écrit dans un texte, `None` s'il n'y en a pas.""" + if not isinstance(text, str): + return None + found = PARAMETER_SIZE.search(text) + if not found: + return None + try: + return float(found.group(1).replace(",", ".")) + except ValueError: + return None + + +def _parameter_size(details): + """Le `parameter_size` d'un bloc de détails, en milliards.""" + if not isinstance(details, dict): + return None + return _billions_from_text(details.get("parameter_size")) + + +def _entries(body, field): + """La liste de dictionnaires que `body[field]` porte, sinon vide.""" + if not isinstance(body, dict): + return [] + return [e for e in body.get(field) or () if isinstance(e, dict)] + + +def _entry_named(entries, name, fields): + """L'entrée que le serveur publie pour `name`, sinon la première. + + Un serveur qui tient plusieurs modèles chargés les publie dans l'ordre + qu'il veut : chercher le nom d'abord évite de lire le contexte du voisin. + """ + for entry in entries: + if name and any(entry.get(field) == name for field in fields): + return entry + return entries[0] if entries else None + + +def _first_bool(mapping, fields): + """Le premier de `fields` que `mapping` publie en booléen, sinon `None`.""" + if not isinstance(mapping, dict): + return None + for field in fields: + if isinstance(mapping.get(field), bool): + return mapping[field] + return None + + +def _context_from_model_info(info): + """La longueur de contexte de `model_info`, `None` si elle n'y est pas. + + La clé porte le nom de l'architecture, que `general.architecture` donne. + Le repli sur n'importe quelle clé qui finit par « .context_length » couvre + un serveur qui nomme l'architecture autrement dans les deux champs. + """ + if not isinstance(info, dict): + return None + architecture = info.get("general.architecture") + if isinstance(architecture, str): + found = _integer(info.get(f"{architecture}.context_length")) + if found is not None: + return found + for key, value in info.items(): + if isinstance(key, str) and key.endswith(".context_length"): + found = _integer(value) + if found is not None: + return found + return None + + +def _read_ollama(models, get, post): + """Ollama, et LocalAI qui émule son API native. + + `POST /api/show` porte l'énumération de capacités et le bloc `model_info` + d'où sortent le contexte et le compte de paramètres. `GET /api/tags` est le + repli sur le `parameter_size` que le serveur publie déjà pour le modèle : + c'est une lecture, pas une déduction, donc elle n'est pas estimée. + """ + name = models[0] if models else "" + context = parameters = tools = vision = None + shown = post("/api/show", {"model": name}) if name else None + if isinstance(shown, dict): + announced = shown.get("capabilities") + if isinstance(announced, list): + tools = "tools" in announced + vision = any(mark in announced for mark in VISION_MARKS) + info = shown.get("model_info") + context = _context_from_model_info(info) + if isinstance(info, dict): + parameters = _billions(info.get("general.parameter_count")) + if parameters is None: + parameters = _parameter_size(shown.get("details")) + if parameters is None: + entries = _entries(get("/api/tags"), "models") + entry = _entry_named(entries, name, ("name", "model")) + parameters = _parameter_size(entry.get("details")) if entry else None + return Capabilities( + context_window=context, + parameters=parameters, + tool_calling=tools, + vision=vision, + ) + + +def _read_llamacpp(models, get, post): + """llama.cpp : les drapeaux dans `/props`, les nombres dans `/v1/models`. + + `/props` porte les capacités du gabarit de conversation, `/v1/models` les + nombres du modèle chargé : deux corps pour une seule lecture. + """ + props = get("/props") + tools = _first_bool( + props.get("chat_template_caps") if isinstance(props, dict) else None, + LLAMACPP_TOOL_FLAGS, + ) + vision = _first_bool( + props.get("modalities") if isinstance(props, dict) else None, + ("vision",), + ) + context = parameters = None + entries = _entries(get("/v1/models"), "data") + entry = _entry_named(entries, models[0] if models else "", ("id",)) + meta = entry.get("meta") if entry else None + if isinstance(meta, dict): + context = _integer(meta.get("n_ctx_train")) + parameters = _billions(meta.get("n_params")) + return Capabilities( + context_window=context, + parameters=parameters, + tool_calling=tools, + vision=vision, + ) + + +def _read_lmstudio(models, get, post): + """LM Studio : le contexte et la vision, jamais les outils. + + Le serveur ne publie aucun drapeau d'outils. `tool_calling` reste donc + vide, et non `False` : un `False` inventé griserait un serveur qui appelle + des outils en vrai. + """ + entries = _entries(get("/api/v0/models"), "data") + entry = _entry_named(entries, models[0] if models else "", ("id",)) + if entry is None: + return NOTHING_ANNOUNCED + kind = entry.get("type") + return Capabilities( + context_window=_integer(entry.get("max_context_length")), + vision=(kind == "vlm") if isinstance(kind, str) else None, + ) + + +def _read_koboldcpp(models, get, post): + """KoboldCpp : des booléens à l'échelle du serveur, aucun contexte.""" + return Capabilities( + vision=_first_bool(get("/api/extra/version"), ("vision",)) + ) + + +def _estimate_parameters(caps, name): + """Le nombre de paramètres deviné dans le NOM du modèle, marqué estimé. + + Ne remplit que ce qui n'a pas été lu, et n'existe que pour `parameters` : + un nom de modèle porte souvent sa taille, jamais sa longueur de contexte, + qui reste donc vide plutôt que devinée. Le nom entre dans `estimated`, ce + qui empêche `match` d'en tirer un refus. + """ + if caps.parameters is not None: + return caps + guessed = _billions_from_text(name) + if guessed is None: + return caps + return replace( + caps, + parameters=guessed, + estimated=caps.estimated | {"parameters"}, + ) + + +# Un lecteur par famille, tous de la même forme `(models, get, post)`, ce qui +# rend la table lisible comme la liste des serveurs qui annoncent quelque +# chose. Ceux qui n'y sont pas n'annoncent rien. +READERS = { + "ollama": _read_ollama, + "localai": _read_ollama, + "llamacpp": _read_llamacpp, + "lmstudio": _read_lmstudio, + "koboldcpp": _read_koboldcpp, +} + +# Reconnus, et muets : leur schéma de modèle ne porte aucun champ de capacité. +# L'API OpenAI distante en fait partie, ce qui est la raison d'être de la règle +# « l'inconnu ne grise jamais » — sinon le catalogue serait vide sur le chemin +# de repli. +SILENT = frozenset( + { + "vllm", + "textgen_webui", + "jan", + "open_webui", + "tabbyapi", + "gpt4all", + "openai", + } +) diff --git a/script/todo/assistant/chat.py b/script/todo/assistant/chat.py new file mode 100644 index 0000000..d6021f3 --- /dev/null +++ b/script/todo/assistant/chat.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""La conversation : les tours, en mémoire, et les commandes qui la pilotent. + +L'historique vit dans cet objet et NULLE PART ailleurs : rien ici n'ouvre un +fichier. Il meurt avec le menu, et `/save` — dont l'écriture appartient au +menu — est le seul moyen d'en garder une trace, ce qui doit se dire AVANT la +conversation qui méritait d'être gardée. + +Deux subtilités que la forme de la boucle impose : + +**`keeps_history`.** Une session `claude` tient son histoire de son côté. La +conversation demande donc au backend s'il la garde et, dans ce cas, n'envoie +que le nouveau tour : rejouer l'historique local doublerait chaque échange et +ferait payer deux fois les mêmes jetons. + +**Toute commande porte une barre oblique initiale, et aucun nombre nu n'est +une commande.** Une question collée sur plusieurs lignes devient autant de +tours, et une ligne collée valant `0` déclencherait une action de menu si les +nombres décidaient. `parse_command` ne reconnaît donc qu'un mot d'une seule +barre oblique et rend tout le reste comme du texte. + +Les valeurs de `COMMANDS` SONT les clés i18n : `t()` rend une clé absente +inchangée, donc une commande non traduite s'affiche en anglais correct. La +traduction se fait à l'affichage, dans le menu. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +from script.todo.assistant.backends import BackendError, Interrupted + +# La forme d'une commande : une barre oblique, puis des lettres ou « ? », et +# rien d'autre. Une seconde barre oblique fait de la ligne un chemin, et une +# question qui commence par un chemin reste une question. +COMMAND_SHAPE = re.compile(r"^/[a-z?]{1,12}$") + +# L'ordre est celui de l'affichage de `/?` : partir, repartir, changer, voir. +COMMANDS: dict[str, str] = { + "/q": "back to the menu", + "/new": "clear the history (same server, same tool)", + "/gpt": "change tool, history kept", + "/srv": ( + "change server, history CLEARED — the model is no longer the same" + ), + "/ctx": "show again what was sent", + "/m": 'multi-line entry, end with a single "." line', + "/save": "write the conversation to a file", + "/?": "list the commands", +} + + +@dataclass +class Turn: + """Un tour : qui parle, ce qui a été dit, et si la réponse a été coupée. + + `role` vaut `user`, `assistant` ou `error`. Un tour `error` n'entre jamais + dans l'historique : il rapporte une panne, pas un échange. + """ + + role: str + text: str + interrupted: bool = False + + +def parse_command(line: str) -> tuple[str | None, str]: + """La commande d'une ligne saisie, et ce qui la suit. + + Rend `(None, line)` — la ligne INTACTE, collage compris — pour tout ce qui + n'est pas de la forme d'une commande : l'appelant traite cela comme du + texte à envoyer. Rend sinon `(commande, reste)`, la commande en + minuscules ; l'appelant confronte la commande à `COMMANDS` et nomme + celle qu'il ne connaît pas plutôt que de l'envoyer au modèle. + """ + texte = (line or "").strip() + if not texte.startswith("/"): + return None, line + tete, _, reste = texte.partition(" ") + tete = tete.lower() + if not COMMAND_SHAPE.match(tete): + return None, line + return tete, reste.strip() + + +class Conversation: + """Les tours d'un échange avec un backend, en mémoire seulement. + + `system` prime sur celui du gpt, ce qui permet au menu de composer une + invite système sans toucher au catalogue. `last_sent` porte les messages + du dernier envoi — c'est ce que `/ctx` réaffiche, y compris après une + panne, parce que ce qui est parti est parti. + + L'historique ne garde que les échanges qui portent du texte : une panne + n'y laisse rien, et retaper la question EST la reprise. + """ + + def __init__(self, backend, *, gpt=None, system=None): + self.backend = backend + self.gpt = gpt + if system is None: + system = getattr(gpt, "system", "") or "" + self.system = system + self.turns: list[Turn] = [] + self.last_sent: list[dict] = [] + self.last_meta: dict = {} + + def _messages(self, text) -> list[dict]: + """Les messages du prochain envoi. + + Un backend qui garde son histoire ne reçoit que le nouveau tour — ni + l'historique, ni l'invite système, qu'il porte déjà de son côté. + """ + if getattr(self.backend, "keeps_history", False): + return [{"role": "user", "content": text}] + messages = [] + if self.system: + messages.append({"role": "system", "content": self.system}) + for tour in self.turns: + messages.append({"role": tour.role, "content": tour.text}) + messages.append({"role": "user", "content": text}) + return messages + + def ask(self, text, *, on_chunk=None) -> Turn: + """Un tour de conversation, et le tour rendu par le backend. + + Rend un tour `error` quand l'envoi échoue : le message tient sur une + ligne et l'historique reste tel qu'il était. Une coupure rend un tour + marqué `interrupted` et garde le texte déjà reçu — il a été payé. + """ + question = Turn("user", text) + messages = self._messages(text) + self.last_sent = messages + try: + reponse, faits = self.backend.send(messages, on_chunk=on_chunk) + except (Interrupted, KeyboardInterrupt) as coupure: + partiel = getattr(coupure, "partial", "") + self.last_meta = getattr(coupure, "meta", {}) or {} + tour = Turn("assistant", partiel, interrupted=True) + if partiel: + self.turns.extend((question, tour)) + return tour + except BackendError as panne: + return Turn("error", str(panne)) + self.last_meta = faits or {} + tour = Turn("assistant", reponse) + self.turns.extend((question, tour)) + return tour + + def reset(self) -> int: + """Vide l'historique et rend le nombre de tours jetés. + + Chaque question et chaque réponse compte pour un tour : un échange + complet en vaut deux. + """ + jetes = len(self.turns) + self.turns = [] + self.last_sent = [] + self.last_meta = {} + return jetes + + def transcript(self) -> str: + """La conversation en Markdown, prête pour `/save`. + + Porte les tours et le nom du gpt, pas l'invite système : `/ctx` + montre ce qui est parti, ce fichier montre ce qui s'est dit. + """ + lignes = [] + nom = getattr(self.gpt, "name", "") + if nom: + lignes += [f"# {nom}", ""] + for tour in self.turns: + marque = " (interrupted)" if tour.interrupted else "" + lignes += [f"## {tour.role}{marque}", tour.text, ""] + return "\n".join(lignes) diff --git a/script/todo/assistant/claude_sessions.py b/script/todo/assistant/claude_sessions.py new file mode 100644 index 0000000..f659e15 --- /dev/null +++ b/script/todo/assistant/claude_sessions.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Les sessions Claude Code de la machine : lesquelles vivent, lesquelles +se reprennent. + +Ce module ne fait que LISTER. Construire la ligne de commande qui interroge +une session appartient à `backends.py`, qui garde l'invite hors de l'argv et +impose la lecture seule par des drapeaux. + +**Deux sources, et la première fait autorité.** `claude agents --json` est le +listage que l'outil publie ; il n'exige pas de terminal et rend pid, +répertoire, genre, identifiant, nom et état. Le registre par processus, sous +le répertoire de configuration, y ajoute la version et le moment de démarrage. +Un `claude -p` en cours n'est dans NI l'un NI l'autre : seules les sessions +interactives et d'arrière-plan s'y inscrivent, donc l'absence d'une session +de la liste ne prouve pas qu'aucune ne tourne. + +**Un pid ne suffit pas à dire qu'une session vit.** Les pids se recyclent, et +une entrée laissée par un arrêt brutal désignerait alors le processus d'un +autre. Le registre porte le moment de démarrage du processus ; la vivacité se +prouve donc par pid vivant ET démarrage identique, jamais par le pid seul. + +**Ce que l'affichage a le droit de montrer.** Le registre est lisible par tout +compte de la machine — pid, répertoire, nom, identifiant n'y sont donc pas des +secrets. Les TRANSCRIPTIONS, elles, sont sous un répertoire fermé à leur +propriétaire seul, et c'est une frontière que le système a déjà tracée : ni +titre, ni invite, ni message n'en sort ici. Un transcript n'est lu que pour +deux champs de STRUCTURE — le répertoire de travail et la branche git — parce +que le nom du répertoire qui les contient est une transformation à perte : les +séparateurs, les points et les tirets bas y deviennent tous des tirets, donc +deux dépôts voisins s'y confondent. + +**Une transcription ne se charge jamais en entier.** La plus grosse de cette +machine se compte en dizaines de mégaoctets ; seules les premières lignes sont +lues, et le listage se garde de les relire à chaque affichage. +""" +from __future__ import annotations + +import json +import os +import subprocess +from dataclasses import dataclass, replace +from pathlib import Path + +# Le listage que l'outil publie. Sans terminal, il refuse sa forme lisible et +# renvoie explicitement vers celle-ci, qui est donc la seule utilisable ici. +AGENTS_ARGV = ("claude", "agents", "--json") + +# Le registre des processus vivants, un fichier par pid. +REGISTRE = "~/.claude/sessions" + +# Les transcriptions persistées, un répertoire par projet. +PROJETS = "~/.claude/projects" + +# Le champ de « /proc//stat » qui porte le moment de démarrage, compté +# depuis le premier. C'est lui qui distingue un pid recyclé d'un pid vivant. +CHAMP_DEMARRAGE = 22 + +# Ce que le listage lit dans une transcription, et rien d'autre : deux champs +# de structure. Le contenu des messages n'est pas de ce côté-ci de la +# frontière que le système a posée sur le répertoire. +CHAMPS_TRANSCRIPT = ("cwd", "gitBranch") + +# Le nombre de lignes lues en tête d'une transcription pour y trouver ces deux +# champs. Les premiers enregistrements les portent tous ; en lire plus +# coûterait des mégaoctets pour la même réponse. +LIGNES_EN_TETE = 40 + +# Le délai d'un appel au listage. Il borne l'attente si l'outil est absent ou +# occupé, pour que le menu rende la main. +DELAI = 15 + + +@dataclass(frozen=True) +class Session: + """Une session, telle que le registre l'annonce. + + `live` dit qu'un processus la tient EN CE MOMENT, prouvé par son moment + de démarrage et non par son seul pid. `kind` vaut `interactive` ou + `background`, et cette distinction décide du risque : reprendre une + session tenue par un terminal n'est pas refusé par l'outil, là où une + session d'arrière-plan l'est. + """ + + session_id: str + pid: int = 0 + kind: str = "" + status: str = "" + cwd: str = "" + name: str = "" + version: str = "" + branch: str = "" + live: bool = False + + +def live(*, run=None, read_registry=None, read_stat=None) -> list[Session]: + """Les sessions qu'un processus tient en ce moment. + + `run(argv)` rend le texte du listage, `read_registry()` les entrées du + registre, `read_stat(pid)` le contenu de l'état d'un processus : les trois + coutures permettent à un test de décrire une flotte entière sans qu'aucune + session réelle ne soit lue ni dérangée. + + Rend une liste VIDE quand l'outil est absent ou muet — une machine sans + Claude Code n'est pas une panne du menu. + """ + lanceur = run or _lancer + entrees = (read_registry or _lire_registre)() + par_pid = {int(e.get("pid", 0) or 0): e for e in entrees} + trouvees = [] + for brute in _agents(lanceur): + pid = int(brute.get("pid", 0) or 0) + enrichie = par_pid.get(pid, {}) + vivante = is_live(pid, enrichie.get("procStart"), read_stat=read_stat) + trouvees.append( + Session( + session_id=str(brute.get("sessionId") or ""), + pid=pid, + kind=str(brute.get("kind") or ""), + status=str(brute.get("status") or ""), + cwd=str(brute.get("cwd") or ""), + name=str(brute.get("name") or ""), + version=str(enrichie.get("version") or ""), + live=vivante, + ) + ) + return trouvees + + +def is_live(pid, procstart, *, read_stat=None) -> bool: + """Ce pid porte-t-il TOUJOURS la session que le registre y attachait ? + + Un pid vivant ne suffit pas : les pids se recyclent, et une entrée laissée + par un arrêt brutal désignerait le processus d'un autre. Le moment de + démarrage du processus tranche — il est propre à un démarrage, donc un pid + réattribué ne le porte pas. + + Sans moment de démarrage connu, la réponse est la présence du pid : c'est + ce que le listage de l'outil affirme déjà, et le prétendre mort serait + plus faux que de le croire vivant. + """ + lecteur = read_stat or _lire_stat + contenu = lecteur(pid) + if not contenu: + return False + if procstart in (None, ""): + return True + champs = contenu.rsplit(")", 1)[-1].split() + # Le champ compté depuis le premier, et le nom du programme — qui peut + # contenir des espaces — est déjà écarté par la coupe ci-dessus. + rang = CHAMP_DEMARRAGE - 3 + if rang >= len(champs): + return True + return champs[rang] == str(procstart) + + +def resumable(*, projects_root=None, read_head=None) -> list[Session]: + """Les sessions persistées, reprenables et sans processus. + + Le répertoire de travail se LIT dans la transcription, jamais dans le nom + du répertoire qui la contient : cette transformation remplace les + séparateurs, les points et les tirets bas par des tirets, donc elle ne + s'inverse pas et confondrait deux dépôts voisins. + + Deux champs sont lus, et deux seulement — le répertoire et la branche. + Aucun titre, aucune invite, aucun message : la transcription est sous un + répertoire que le système ferme à son propriétaire, et cette frontière + n'est pas à rouvrir pour décorer une liste. + """ + racine = Path(projects_root or os.path.expanduser(PROJETS)) + lecteur = read_head or _lire_en_tete + trouvees = [] + for chemin in _transcripts(racine): + faits = _structure(lecteur(chemin)) + trouvees.append( + Session( + session_id=chemin.name[: -len(".jsonl")], + cwd=faits.get("cwd", ""), + branch=faits.get("gitBranch", ""), + live=False, + ) + ) + return sorted(trouvees, key=lambda s: s.session_id) + + +def fleet( + *, + run=None, + read_registry=None, + read_stat=None, + projects_root=None, + read_head=None, +) -> list[Session]: + """La flotte : les sessions vivantes, puis celles qui se reprennent. + + Une session persistée est aussi présente tant qu'un processus la tient : + les deux listages se recouvrent donc, et les présenter côte à côte + montrerait deux fois la même session, une fois vivante et une fois comme + reprenable. La fusion garde l'entrée VIVANTE, qui porte le pid, le genre + et l'état — c'est-à-dire tout ce qui décide du risque. + + Les vivantes ouvrent la liste : ce sont celles où écrire coûte quelque + chose. + """ + persistees = { + session.session_id: session + for session in resumable( + projects_root=projects_root, read_head=read_head + ) + } + # La branche vient de la transcription, que le listage de l'outil ne + # connaît pas : sans cette reprise, elle paraîtrait pour les sessions + # dormantes et manquerait pour les vivantes, ce qui se lit comme un + # défaut alors que l'information est là. + vivantes = [ + replace( + session, + branch=getattr(persistees.get(session.session_id), "branch", ""), + ) + for session in live( + run=run, read_registry=read_registry, read_stat=read_stat + ) + ] + connues = {session.session_id for session in vivantes} + dormantes = [ + session + for identifiant, session in persistees.items() + if identifiant not in connues + ] + return vivantes + dormantes + + +def displayable(session) -> dict: + """Ce qu'une session a le droit de montrer à l'écran. + + Le répertoire est réduit à son dernier segment et l'identifiant à son + préfixe : les deux suffisent à reconnaître une session sans étaler le + chemin d'un compte, que le détecteur du dépôt compte d'ailleurs parmi les + données identifiantes. + """ + return { + "id": session.session_id[:8], + "pid": session.pid, + "kind": session.kind, + "status": session.status, + "dir": Path(session.cwd).name if session.cwd else "", + "name": session.name, + "version": session.version, + "branch": session.branch, + "live": session.live, + } + + +def held_by(session) -> str: + """Le pid qui tient cette session, ou "" quand personne ne la tient. + + Sert la seule question qui compte avant d'écrire dans une session : y + a-t-il quelqu'un dedans. Reprendre une session tenue par un terminal n'est + PAS refusé par l'outil, et deux écritures simultanées scindent la + transcription en silence — une branche est alors orpheline. Le menu + demande donc, et branche une copie par défaut. + """ + return str(session.pid) if session.live and session.pid else "" + + +def _agents(lanceur): + """Les entrées du listage publié par l'outil, ou une liste vide.""" + texte = lanceur(list(AGENTS_ARGV)) + if not texte: + return [] + try: + charge = json.loads(texte) + except ValueError: + return [] + return ( + [e for e in charge if isinstance(e, dict)] + if isinstance(charge, list) + else [] + ) + + +def _lancer(argv): + """La sortie standard du listage, ou "" quand l'outil manque.""" + try: + answer = subprocess.run( + argv, capture_output=True, text=True, timeout=DELAI + ) + except (OSError, subprocess.SubprocessError): + return "" + return answer.stdout if answer.returncode == 0 else "" + + +def _lire_registre(): + """Les entrées du registre des processus vivants. + + Les fichiers de jetons voisins ne sont jamais ouverts : ils portent une + autorisation de messagerie, et ce listage n'a rien à en faire. + """ + racine = Path(os.path.expanduser(REGISTRE)) + entrees = [] + try: + fichiers = sorted(racine.glob("*.json")) + except OSError: + return entrees + for chemin in fichiers: + try: + entrees.append(json.loads(chemin.read_text())) + except (OSError, ValueError): + # Un registre à moitié écrit ne doit pas cacher les autres. + continue + return [e for e in entrees if isinstance(e, dict)] + + +def _lire_stat(pid): + """Le contenu de l'état d'un processus, ou "" s'il n'existe plus.""" + try: + return Path(f"/proc/{int(pid)}/stat").read_text() + except (OSError, ValueError): + return "" + + +def _transcripts(racine): + """Les transcriptions persistées, triées, sans descendre plus bas. + + Les sous-répertoires par session portent des travaux dérivés — agents, + flux — que ce listage n'a pas à parcourir. + """ + try: + return sorted( + chemin + for projet in sorted(racine.iterdir()) + if projet.is_dir() + for chemin in sorted(projet.glob("*.jsonl")) + ) + except OSError: + return [] + + +def _lire_en_tete(chemin): + """Les premières lignes d'une transcription. + + En tête seulement : une transcription se compte en mégaoctets, et les + premiers enregistrements portent déjà les deux champs cherchés. + """ + lignes = [] + try: + with open(chemin, encoding="utf-8", errors="replace") as fichier: + for rang, ligne in enumerate(fichier): + if rang >= LIGNES_EN_TETE: + break + lignes.append(ligne) + except OSError: + return [] + return lignes + + +def _structure(lignes): + """Les deux champs de structure, pris dans les premiers enregistrements. + + Ne lit que les clés déclarées : un enregistrement porte aussi le contenu + des messages, et le parcourir pour en extraire deux champs ne donne aucun + droit sur le reste. + """ + faits = {} + for ligne in lignes or (): + try: + enregistrement = json.loads(ligne) + except ValueError: + continue + if not isinstance(enregistrement, dict): + continue + for champ in CHAMPS_TRANSCRIPT: + valeur = enregistrement.get(champ) + if champ not in faits and isinstance(valeur, str) and valeur: + faits[champ] = valeur + if len(faits) == len(CHAMPS_TRANSCRIPT): + break + return faits diff --git a/script/todo/assistant/context.py b/script/todo/assistant/context.py new file mode 100644 index 0000000..5805407 --- /dev/null +++ b/script/todo/assistant/context.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le contexte déclaré d'un gpt : ce qu'il a le droit de lire, et de dire. + +Un gpt déclare des fichiers et des commandes ; ce module les lit, les borne, +les balaie, et rend le texte assemblé avec ce qu'il y a trouvé. Il ne décide +jamais d'envoyer : c'est le menu qui pose la porte à l'utilisateur, et +`gate` lui donne le verdict à afficher. + +**La liste de refus passe avant tout, et se résout sur le chemin RÉEL.** Ni +« suivi par git » ni « ignoré par git » ne sont des portes utilisables : +`private/` est partiellement suivi, et `tasks/` n'est pas dans `.gitignore`. +Un lien symbolique est donc résolu avant d'être comparé, sinon un lien vers +`private/` traverserait la liste en la contournant par le nom. + +**Une commande est un `argv`, jamais une chaîne d'interpréteur**, et elle est +confrontée à une liste d'autorisation qui vit dans le dépôt — un gpt venu +d'ailleurs ne peut donc pas l'étendre. Le refus d'un métacaractère dans un +argument n'est PAS une protection contre l'injection : sans interpréteur, il +n'y a rien à injecter. C'est un signal que l'auteur croyait écrire une ligne +de shell, donc que son gpt ne fera pas ce qu'il voulait — le lui dire au +chargement vaut mieux qu'un résultat surprenant. + +**Les plafonds servent la lisibilité autant que le coût.** Un contexte qu'on +ne peut plus relire avant de l'envoyer n'est plus un contexte déclaré, c'est +un versement. La coupe se fait sur une frontière de ligne et porte une marque, +pour qu'une source tronquée se voie. + +**Ce que le balayage voit, et ce qu'il ne voit pas.** `identifiants()` +reconnaît les adresses, les courriels et les chemins de compte. Il ne +reconnaît PAS les noms — d'hôte, de client, de base de données — sauf si +`private/noms_interdits.txt` les énumère, et ce fichier n'existe pas +d'ordinaire. La moitié « noms » du filtre est donc inerte par défaut, et +`gate` le dit au lieu de laisser croire à un contrôle complet : une +destination tierce est REFUSÉE tant que cette liste est vide. +""" +from __future__ import annotations + +import os +import re +import subprocess +from pathlib import Path + +from script import lib_identifiant + +# Ce qu'aucun contexte ne lit, jamais, quel que soit le gpt qui le demande. +# `private/` est le seul endroit autorisé à porter une donnée de client ; +# `tasks/` porte l'enquête, que la convention y envoie pour qu'elle ne suive +# pas le dépôt ; le reste est un coffre, une clé, un cache ou un historique. +REFUS = ( + "private", + "tasks", + ".git", + ".ssh", + ".erplibre", + ".venv", + "node_modules", +) + +# Les suffixes refusés où qu'ils soient : un coffre reste un coffre. +SUFFIXES_REFUSES = (".kdbx", ".key", ".pem") + +# Les commandes qu'un gpt peut déclarer, par PRÉFIXE d'argv. La liste vit +# dans le dépôt et n'est pas extensible depuis un gpt : c'est ce qui rend +# inoffensif un fichier que personne n'a relu. +AUTORISEES = ( + ("python3", "script/analyse/check_comment_hygiene.py"), + ("python3", "script/analyse/check_manifest_gaps.py"), + ("git", "diff"), + ("git", "log"), + ("git", "status"), + ("./script/test/run_unit_test.sh",), +) + +# Les caractères qui trahissent un auteur qui croyait écrire du shell. +METACARACTERES = ";|&><`$\n" + +# Ce qu'une source peut peser, et ce que tout le contexte peut peser. Un +# contexte qu'on ne peut plus relire avant l'envoi n'est plus déclaré. +MAX_PAR_SOURCE = 8_000 +MAX_TOTAL = 24_000 + +# Ce qu'une commande a le droit de durer, et ce que toutes ont ensemble. Une +# commande qui dépasse laisse le menu rendre la main plutôt que d'attendre. +DELAI_PAR_COMMANDE = 30 +DELAI_TOTAL = 60 + +# La marque d'une source coupée. Elle est visible dans l'aperçu, donc la +# troncature ne se découvre pas dans la réponse du modèle. +MARQUE_COUPE = "… [cut]" + +# Les verdicts de la porte. +OK = "ok" +AVERTIR = "warn" +BLOQUER = "block" + +# Les clés de la porte, nommées une fois. Voir la raison dans `gpt.py`. +NOMS_INVERIFIABLES = ( + "private/noms_interdits.txt is absent: no client, database, VM or host" + " name can be recognized." +) +TROUVAILLE_BLOQUE_UN_TIERS = ( + "A finding blocks a send to a third party. No override." +) +TROUVAILLE_A_RELIRE = "finding to re-read before sending" + + +class ContextRefused(Exception): + """Une source qu'aucun gpt n'a le droit de lire, ou un argv refusé. + + Le message porte la raison en clair : il s'affiche tel quel dans les + problèmes du catalogue, à côté de la source refusée. + """ + + +def repo_root() -> Path: + """La racine du dépôt, dérivée de l'emplacement de ce module.""" + return Path(__file__).resolve().parents[3] + + +def resolve_file(path, *, root=None) -> Path: + """Le chemin réel d'une source déclarée. Lève `ContextRefused`. + + Deux refus, dans cet ordre. Le chemin est d'abord RÉSOLU — liens + symboliques compris — puis comparé : un lien vers `private/` ne doit pas + passer parce que son nom, lui, est anodin. Ensuite il doit rester SOUS la + racine du dépôt : un contexte n'a rien à lire ailleurs, et « ../ » est le + chemin le plus court vers le répertoire personnel. + """ + base = Path(root).resolve() if root else repo_root() + reel = (base / Path(path)).resolve() + try: + relatif = reel.relative_to(base) + except ValueError: + raise ContextRefused(f"hors du dépôt : {path}") from None + if reel.suffix in SUFFIXES_REFUSES: + raise ContextRefused(f"suffixe refusé : {reel.suffix}") + for partie in relatif.parts: + if partie in REFUS or partie.startswith(".venv"): + raise ContextRefused(f"chemin refusé : {partie}") + return reel + + +def check_argv(argv) -> tuple: + """L'argv d'une commande déclarée. Lève `ContextRefused`. Fonction PURE. + + Une chaîne est refusée d'emblée : elle voudrait dire qu'un interpréteur + la relira, et c'est justement ce qu'aucun contexte ne fait. + """ + if isinstance(argv, str): + raise ContextRefused("une commande est une liste, pas une chaîne") + if not isinstance(argv, (list, tuple)) or not argv: + raise ContextRefused("commande vide") + morceaux = [] + for morceau in argv: + if not isinstance(morceau, str): + raise ContextRefused(f"argument non textuel : {morceau!r}") + if any(caractere in morceau for caractere in METACARACTERES): + # Sans interpréteur il n'y a rien à injecter : ce refus dit que + # l'auteur croyait écrire du shell, donc que son gpt ne fera pas + # ce qu'il voulait. + raise ContextRefused(f"métacaractère d'interpréteur : {morceau}") + morceaux.append(morceau) + for prefixe in AUTORISEES: + if tuple(morceaux[: len(prefixe)]) == prefixe: + return tuple(morceaux) + raise ContextRefused(f"hors liste d'autorisation : {morceaux[0]}") + + +def substituer(argv, inputs=None) -> list: + """`argv` avec ses `{nom}` remplacés. Lève `ContextRefused`. + + L'ORDRE compte, et c'est tout l'enjeu : la substitution a lieu AVANT + `check_argv`, jamais après. Une valeur saisie par l'utilisateur passe donc + par le contrôle des métacaractères et de la liste d'autorisation comme le + reste de la ligne — vérifier le gabarit puis y injecter une valeur + reviendrait à vérifier ce qu'on n'exécute pas. + + Un `{nom}` sans valeur est refusé plutôt que laissé tel quel : une + commande qui recevrait « {test_file} » comme chemin échouerait plus loin, + avec une erreur qui ne dirait pas d'où elle vient. + """ + valeurs = dict(inputs or {}) + remplis = [] + for morceau in argv or (): + if not isinstance(morceau, str): + remplis.append(morceau) + continue + for nom, valeur in valeurs.items(): + morceau = morceau.replace("{" + nom + "}", str(valeur)) + manquant = re.search(r"\{([A-Za-z_][A-Za-z0-9_]*)\}", morceau) + if manquant: + raise ContextRefused(f"entrée sans valeur : {manquant.group(1)}") + remplis.append(morceau) + return remplis + + +def borner(texte, maximum=MAX_PAR_SOURCE) -> str: + """`texte` ramené sous `maximum`, coupé sur une frontière de ligne. + + La coupe se voit : sans marque, une source tronquée se lit comme une + source complète, et le modèle répond sur ce qu'il n'a pas reçu. + """ + if len(texte) <= maximum: + return texte + coupe = texte[:maximum] + frontiere = coupe.rfind("\n") + if frontiere > 0: + coupe = coupe[:frontiere] + return coupe + "\n" + MARQUE_COUPE + + +def assemble( + files=(), + commands=(), + *, + inputs=None, + read=None, + run=None, + termes=None, + root=None, +): + """Le contexte assemblé, et ce que le balayage y a trouvé. + + Rend `(texte, trouvailles)`. Chaque trouvaille est un dictionnaire + `{source, motif, extrait, position}` : le menu les surligne dans + l'aperçu, et `gate` décide de ce qu'elles autorisent. + + Les sources sont lues dans l'ordre déclaré, chacune bornée, et + l'assemblage s'arrête net au plafond total : une source qui n'entre pas + est ANNONCÉE plutôt que silencieusement absente. + + `read`, `run` et `termes` sont injectés — un test décide alors ce que la + machine contient, ce que les commandes rendent, et quels noms le filtre + connaît, sans dépendre du poste qui le lance. + """ + lecteur = read or _lire + lanceur = run or _lancer + liste = lib_identifiant.termes_interdits() if termes is None else termes + + morceaux: list[str] = [] + trouvailles: list[dict] = [] + total = 0 + reste_delai = DELAI_TOTAL + + for chemin in files or (): + libelle = str(chemin) + try: + chemin = substituer([str(chemin)], inputs)[0] + reel = resolve_file(chemin, root=root) + contenu = lecteur(reel) + except (ContextRefused, OSError) as refus: + morceaux.append(f"# {libelle} — {refus}") + continue + contenu, total, plein = _ajouter(contenu, total) + morceaux.append(f"# {libelle}\n{contenu}") + trouvailles.extend(_balayer(contenu, libelle, liste)) + if plein: + morceaux.append(f"# {MARQUE_COUPE}") + return "\n\n".join(morceaux), trouvailles + + for commande in commands or (): + libelle = _libelle(commande) + try: + argv = check_argv(substituer(_argv(commande), inputs)) + except ContextRefused as refus: + morceaux.append(f"# {libelle} — {refus}") + continue + delai = min(DELAI_PAR_COMMANDE, reste_delai) + if delai <= 0: + morceaux.append(f"# {libelle} — délai total épuisé") + continue + try: + sortie = lanceur(argv, delai) + except Exception as panne: + morceaux.append(f"# {libelle} — {panne}") + continue + reste_delai -= delai + sortie, total, plein = _ajouter(sortie or "", total) + morceaux.append(f"# {libelle}\n{sortie}") + trouvailles.extend(_balayer(sortie, libelle, liste)) + if plein: + morceaux.append(f"# {MARQUE_COUPE}") + break + + return "\n\n".join(morceaux), trouvailles + + +def gate(trouvailles, hosting, *, names_checkable=True) -> tuple: + """Ce que la porte autorise. Rend `(verdict, clé)`. Fonction PURE. + + Trois verdicts. `ok` laisse passer. `warn` demande une confirmation que + l'utilisateur peut donner. `block` REFUSE sans passe-droit. + + La règle tient à qui reçoit. Sur la boucle locale, une trouvaille est un + avertissement : rien ne quitte la machine, et l'opérateur décide chez lui. + Vers un TIERS, elle bloque — une adresse ou un chemin de compte envoyé à + quelqu'un d'autre ne se rattrape pas. + + `names_checkable` dit si la liste des noms interdits est renseignée. + Vide, la moitié « noms » du filtre est inerte : le balayage ne verrait ni + nom d'hôte, ni nom de client, ni nom de base. Un envoi vers un tiers est + alors refusé même SANS trouvaille, parce que l'absence de trouvaille ne + prouve plus rien. + """ + tiers = hosting not in ("loopback", "lan") + if tiers and not names_checkable: + return ( + BLOQUER, + NOMS_INVERIFIABLES, + ) + if not trouvailles: + return OK, "" + if tiers: + return ( + BLOQUER, + TROUVAILLE_BLOQUE_UN_TIERS, + ) + return AVERTIR, TROUVAILLE_A_RELIRE + + +def _ajouter(contenu, total): + """(contenu borné, nouveau total, plafond atteint).""" + contenu = borner(contenu, MAX_PAR_SOURCE) + place = MAX_TOTAL - total + if len(contenu) >= place: + return borner(contenu, max(place, 0)), MAX_TOTAL, True + return contenu, total + len(contenu), False + + +def _balayer(texte, source, termes): + """Les données identifiantes d'une source, nommées par leur source. + + Le filtre reconnaît les adresses, les courriels et les chemins de compte. + Les NOMS ne lui sont connus que par `termes`, d'où l'injection : une + liste vide rend un balayage muet sur toute une classe de données. + """ + return [ + { + "source": source, + "motif": motif, + "extrait": extrait, + "position": position, + } + for motif, extrait, position in lib_identifiant.identifiants( + texte, termes=tuple(termes or ()) + ) + ] + + +def _argv(commande): + """L'argv d'une commande déclarée, quelle que soit sa forme.""" + if isinstance(commande, dict): + return commande.get("argv") + return commande + + +def _libelle(commande): + """Ce qui nomme une commande dans l'aperçu. + + Le libellé de l'auteur s'il en donne un : « Trouvailles » se lit mieux + qu'une ligne d'argv, et c'est cet aperçu que l'utilisateur relit. + """ + if isinstance(commande, dict): + etiquette = commande.get("label") + if isinstance(etiquette, str) and etiquette.strip(): + return etiquette.strip() + argv = commande.get("argv") or () + else: + argv = commande or () + return " ".join(str(morceau) for morceau in argv)[:80] + + +def _lire(chemin): + """Le texte d'une source de contexte.""" + return Path(chemin).read_text(encoding="utf-8", errors="replace") + + +def _lancer(argv, delai): + """La sortie standard d'une commande déclarée, sans interpréteur. + + `cwd` est la racine du dépôt : un gpt déclare des chemins relatifs à + elle, et non au répertoire d'où le menu a été lancé. La sortie d'erreur + est jointe — une commande qui explique pourquoi elle n'a rien produit est + plus utile qu'un vide. + """ + answer = subprocess.run( + list(argv), + capture_output=True, + text=True, + timeout=delai, + cwd=str(repo_root()), + env={**os.environ, "LC_ALL": "C", "LANG": "C"}, + ) + return answer.stdout + (answer.stderr if answer.returncode else "") diff --git a/script/todo/assistant/discover.py b/script/todo/assistant/discover.py new file mode 100644 index 0000000..0db3413 --- /dev/null +++ b/script/todo/assistant/discover.py @@ -0,0 +1,674 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Quels couples (hôte, port) méritent une reconnaissance, et la frappe. + +Ce module n'identifie rien : il rend des cibles et dit lesquelles ont accepté +une connexion. Qui répond derrière un port ouvert est la question de +`fingerprint`, et le partage est ce qui rend le balayage vérifiable sans +serveur — un connecteur injecté suffit à parcourir un /24 entier sans émettre +un paquet. + +Quatre sources répondent à « où chercher ». Deux sont locales à ce module — +les réseaux que la machine porte, et la table de voisinage. Deux sont +INJECTÉES : l'énumération des VM libvirt et la résolution d'un alias SSH +existent déjà comme méthodes de la classe TODO, et ce paquet n'a pas le droit +d'importer `todo.py`. Elles arrivent donc en arguments nommés, et leur +absence rend une LISTE VIDE plutôt qu'une erreur : une source qu'on n'a pas +branchée est une source qui n'a rien à dire, pas une panne du menu. + +**Le noyau est interrogé, jamais deviné.** Le préfixe d'un réseau se lit dans +`ip`, qui le connaît, et non dans une adresse, d'où il ne se déduit pas. Un +/24 tiré d'une adresse nue est une HYPOTHÈSE, et s'annonce comme telle. Ce +module ne rétrécit donc jamais un préfixe plus large pour le rendre +balayable : il le rend tel que le noyau l'annonce, et `plan_sweep` refuse +tout ce qui dépasse un /24. Rétrécir en silence présenterait une hypothèse +comme une lecture. + +Une machine porte volontiers DEUX /24 — un sur son interface physique, un +sur un pont de virtualisation où elle est elle-même la passerelle. « Le /24 +local » n'a donc pas de sens : `local_networks` les rend TOUS, chacun marqué +`is_bridge`, et le choix appartient au menu. + +Le dimensionnement de la piscine se fait par NOMBRE DE VAGUES et jamais par +nombre de cœurs : une sonde de connexion est de l'attente réseau, et un +compte de cœurs à deux chiffres multiplierait par quinze la durée d'un /24. +Le prédicteur qui colle est `plafond(hôtes × ports / ouvriers) × délai` : +254 hôtes × 4 ports au pire cas, tout en délai d'attente, tiennent en 1,5 s +à 256 ouvriers et 0,30 s de délai, contre 2,9 s à 128. + +D'où l'ordre des passes : une passe de CONNEXION SEULE sur tout le réseau +d'abord, puis le budget coûteux des GET de reconnaissance dépensé sur la +poignée d'hôtes qui ont accepté. `sweep` fait la première, et rien d'autre. +""" +from __future__ import annotations + +import ipaddress +import os +import re +import shlex +import socket +import subprocess +import time +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError as PoolTimeout +from concurrent.futures import as_completed +from dataclasses import dataclass + +from script.todo.assistant import fingerprint + +# Ce qu'un /24 porte d'hôtes utilisables, et donc le plafond d'un balayage. +# Plus large est refusé : le temps croît linéairement, et une plage que +# personne n'a désignée décrit des machines que personne n'a désignées. +MAX_HOSTS = 254 + +# Le nombre d'ouvriers en vol. Dimensionné par VAGUES, jamais par cœurs : +# ces fils attendent le réseau, ils ne calculent pas, et le nombre de cœurs +# n'a aucun rapport avec le nombre de connexions qu'une machine peut tenir en +# attente. `sweep` plafonne à `min(len(jobs), workers)`, donc une valeur plus +# grande que le nombre de sondes ne change RIEN : un /24 sur onze ports en +# compte 2 794, et 4 096, 8 192 ou 16 384 ouvriers y donnent tous une vague et +# la même durée. +# +# La durée suit `plafond(sondes / ouvriers) × délai`. Le compromis retenu à +# 1 024 laisse trois vagues sur un /24 plutôt qu'une : le gain de la vague +# unique se compte en centièmes de seconde, et trois salves d'un tiers de +# taille pèsent moins sur un commutateur qu'une seule salve entière — donc +# moins de paquets perdus, et un paquet perdu se lit comme un port fermé. +MAX_WORKERS = 1024 + +# Le délai d'une connexion. Il NE se déduit PAS de la latence observée au +# repos : un hôte joignable en une milliseconde à vide se manque à 0,05 s de +# délai dès que mille connexions partent ensemble, parce que la file, la +# passerelle et le noyau ajoutent tous leur part sous charge. La marge sert +# donc à ça, et non à la distance. +# +# Il couvre aussi la résolution ARP d'un voisin absent du cache, qui est ce +# qui coûte devant une adresse morte. Le raccourcir est le seul réglage de ce +# module qui fabrique des FAUX NÉGATIFS, et un réseau annoncé vide sur un +# délai trop court est plus coûteux que les secondes épargnées. +CONNECT_TIMEOUT = 0.30 + +# L'intervalle du battement de cœur. Paramétrable, et c'est le point : un +# intervalle figé rend la branche du battement intestable, donc non testée. +HEARTBEAT_SEC = 2.0 + +# Le délai d'un aller-retour SSH pour lire les réseaux d'un hôte. Il +# borne l'attente d'un hôte éteint : sans lui, un menu tiendrait le +# temps que la pile TCP renonce d'elle-même. +SSH_TIMEOUT = 15 + +# Le port qu'annonce `ssh -G` quand aucun n'est déclaré, et donc la seule +# valeur de repli qui ne soit pas une invention. +SSH_PORT = 22 + +# Le délai d'un appel à `ip`. La commande lit des tables du noyau et rend la +# main tout de suite ; le délai n'existe que pour ne jamais tenir le menu. +IP_TIMEOUT = 10 + +# Une ligne de « ip -o -4 addr show » : le rang, l'interface, puis le +# préfixe. C'est le préfixe qui compte, et c'est lui que la forme sans « -o » +# rend inattachable à son interface, l'une étant sur la ligne d'en-tête et +# l'autre sur la suivante. +ADDR_LINE = re.compile(r"^\s*\d+:\s+(\S+)\s+inet\s+(\d+\.\d+\.\d+\.\d+/\d+)") + +# Le nom d'interface d'une ligne de « ip link show ». L'arobase d'une +# interface appairée sépare le nom de son pair : le nom s'arrête avant. +LINK_NAME = re.compile(r"^\s*\d+:\s+([^:@\s]+)") + +# Le jeton que « ip -d link » pose sur un pont, et lui seul. La frontière de +# mot est ce qui distingue le type « bridge » des attributs « bridge_id » et +# « bridge_slave », qui paraissent sur la même ligne ou sur celle d'un port. +BRIDGE_KIND = re.compile(r"\bbridge\b") + +# L'interface que la table de routage a élue pour sortir de la machine. Elle +# ne choisit pas le réseau à balayer — elle ouvre la liste, parce que la +# question « lequel est le mien » a déjà été tranchée par le noyau. +ROUTE_DEV = re.compile(r"\bdev\s+(\S+)") + +# Un alias de configuration SSH qui ne désigne pas une machine : un joker est +# une règle, et un motif nié retire un nom au lieu d'en déclarer un. +SSH_PATTERN_CHARS = ("*", "?") +SSH_NEGATION = "!" + + +@dataclass(frozen=True) +class Interface: + """Un réseau que la machine porte, tel que le noyau l'annonce. + + `cidr` est la forme réseau du préfixe LU sur l'interface, jamais un + préfixe supposé autour d'une adresse. `is_bridge` dit que l'interface est + un pont de virtualisation, donc que la machine y est probablement la + passerelle et que ce qui s'y trouve est à elle : l'affichage le signale, + et le choix reste à l'utilisateur. + """ + + name: str + cidr: str + is_bridge: bool + + +def _c_env(): + """L'environnement d'un sous-processus, forcé en anglais. + + `ip` et `virsh` traduisent leurs champs quand la locale le demande, et + une comparaison de jeton échoue alors sans rien lever. La même fonction + existe comme méthode statique de la classe TODO ; ce paquet n'importe pas + `todo.py`, d'où cette copie de trois lignes. + """ + env = dict(os.environ) + env["LC_ALL"] = "C" + env["LANG"] = "C" + return env + + +def run_ip(args, *, run=None) -> str: + """La sortie de « ip », ou la chaîne vide. Le transport, et rien + d'autre. + + `run(argv)` rend le texte de la sortie standard et vaut `None` par + défaut, résolu ici sur un sous-processus en locale C. Cette couture est + ce qui permet à un test de décider ce que la machine annonce, et elle + sert aussi à l'appelant qui veut la table de voisinage brute pour + `neigh_hosts`. + + Ne lève pas : une commande absente, un délai dépassé ou un code de retour + non nul rendent la chaîne vide, que chaque analyse lit comme « rien à + dire ». + """ + if run is None: + run = _run_ip + try: + return run(["ip"] + list(args)) or "" + except Exception: + # Un `run` injecté lève ce qu'il veut, et celui du système lève sur + # une commande absente : les deux disent qu'il n'y a rien à analyser. + return "" + + +def _run_ip(argv): + """La sortie standard de `argv`, en locale C, ou la chaîne vide.""" + answer = subprocess.run( + argv, + capture_output=True, + text=True, + timeout=IP_TIMEOUT, + env=_c_env(), + ) + return answer.stdout if answer.returncode == 0 else "" + + +def local_networks(*, run=None) -> list[Interface]: + """Les réseaux IPv4 que la machine porte, dans l'ordre à proposer. + + L'interface élue par la route par défaut ouvre la liste : le noyau a déjà + tranché laquelle sort de la machine, et le demander vaut mieux que + l'ordre des rangs. Le reste suit dans l'ordre où `ip` les annonce. + + La boucle locale est écartée — elle se sonde sans balayage, en quelques + millisecondes — et chaque réseau ne paraît qu'une fois, là où une lecture + des adresses ET des routes rend le même préfixe deux fois. + + Le préfixe est celui que porte l'interface, sans retouche. Un réseau plus + large qu'un /24 est rendu TEL QUEL, et c'est `plan_sweep` qui le refuse : + le rétrécir ici présenterait un /24 supposé comme un /24 lu. Ni + `route_to` ni `interface_addresses` de `script/vpn/drivers/base.py` ne + servent ici — le premier ne prend aucun exécuteur injecté, donc un test + parlerait à la vraie machine, et le second jette la longueur du préfixe, + qui est justement ce qu'on vient chercher. `host_networks` de + `script/qemu/deploy_qemu.py` la garde, mais perd le NOM de l'interface, + que l'affichage et le marquage des ponts exigent. + """ + addresses = run_ip(["-o", "-4", "addr", "show"], run=run) + bridges = _bridge_names(run_ip(["-d", "-o", "link", "show"], run=run)) + first = _default_dev( + run_ip(["-o", "-4", "route", "show", "default"], run=run) + ) + found: list[Interface] = [] + seen: set[tuple[str, str]] = set() + for line in addresses.splitlines(): + parsed = ADDR_LINE.match(line) + if not parsed: + continue + name, address = parsed.group(1), parsed.group(2) + network = _network(address) + if network is None or network.is_loopback: + continue + key = (name, str(network)) + if key in seen: + continue + seen.add(key) + found.append(Interface(name, str(network), name in bridges)) + found.sort(key=lambda item: 0 if item.name == first else 1) + return found + + +def ssh_runner(alias, *, timeout=SSH_TIMEOUT): + """Un exécuteur qui lance `ip` SUR `alias`, pour `local_networks`. + + `local_networks` reçoit son exécuteur en argument, donc lui en passer un + qui traverse SSH suffit à énumérer les réseaux d'une AUTRE machine : la + lecture des adresses, la détection des ponts et l'ordre par route par + défaut se réutilisent tels quels, sans une ligne d'analyse en double. + + `BatchMode=yes` refuse toute invite : un hôte qui demanderait un mot de + passe rend une chaîne vide plutôt que de bloquer le menu sur une question + que personne ne voit venir. + + Les arguments sont cités un par un : la commande distante est une chaîne + interprétée par un interpréteur là-bas, et un argument non cité s'y + ferait relire. + """ + + def executer(argv): + distant = " ".join(shlex.quote(morceau) for morceau in argv) + answer = subprocess.run( + [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + f"ConnectTimeout={int(timeout)}", + alias, + distant, + ], + capture_output=True, + text=True, + timeout=timeout, + env=_c_env(), + ) + return answer.stdout if answer.returncode == 0 else "" + + return executer + + +def remote_networks(alias, *, run=None) -> list[Interface]: + """Les réseaux IPv4 que porte `alias`, lus chez lui. + + Sert le cas que `local_networks` ne peut pas voir : quand le CLI tourne + dans une machine virtuelle, les réseaux qu'il porte sont ceux de + l'hyperviseur, et le parc réel est hors-lien. La machine du dessus, elle, + porte les bons préfixes et sait les dire. + + Les réseaux sont LUS là-bas et balayés D'ICI : c'est la table de routage + locale qui décide si l'on y accède, et une route par défaut suffit + d'ordinaire. Rien n'est balayé depuis l'hôte distant, qui n'a donc besoin + que d'un accès en lecture. + + Rend une liste vide quand l'hôte est injoignable, refuse une clé ou n'a + pas `ip` : aucun de ces cas n'est une panne du menu. + """ + return local_networks(run=run or ssh_runner(alias)) + + +def qemu_hosts( + *, list_domains=None, vm_ip=None +) -> list[tuple[str, str | None]]: + """Les VM libvirt de la machine : (nom, adresse ou None). + + `list_domains()` rend les noms de domaine et `vm_ip(nom)` l'adresse d'un + seul. Les deux sont INJECTÉS parce qu'ils existent comme méthodes de la + classe TODO, que ce paquet n'importe pas. Sans `list_domains`, il n'y a + rien à énumérer et la source rend une liste VIDE, sans erreur : le menu + branche les deux, un test n'en branche aucun, et aucun des deux cas n'est + une panne. Sans `vm_ip`, les domaines sortent avec une adresse inconnue. + + Le résolveur attendu est `_qemu_vm_ip_now`, qui lit le bail UNE FOIS et + ne patiente pas. Les deux résolveurs patients du même fichier attendent + jusqu'à dix minutes PAR VM : afficher une liste de trois VM éteintes + gèlerait le menu une demi-heure, et une découverte ne doit jamais + attendre une machine qui n'est pas allumée. + + Une VM sans adresse est LISTÉE avec `None`, jamais écartée : elle est + définie, l'utilisateur peut vouloir la démarrer, et la faire disparaître + de la liste ne le lui dirait pas. + """ + if list_domains is None: + return [] + try: + names = list_domains() or [] + except Exception: + # L'énumérateur injecté lève ce qu'il veut ; celui de la classe TODO + # rend déjà [] sur un `virsh` absent. Les deux valent « aucune VM ». + return [] + found: list[tuple[str, str | None]] = [] + for name in names: + if not isinstance(name, str) or not name.strip(): + continue + found.append((name, _vm_address(name, vm_ip))) + return found + + +def ssh_hosts( + *, list_aliases=None, resolve=None +) -> list[tuple[str, str, int]]: + """Les machines de la configuration SSH : (alias, hôte, port). + + `list_aliases()` rend les noms déclarés et `resolve(alias)` la + configuration résolue par `ssh -G`, en clés minuscules. Les deux sont + INJECTÉS — ce sont des méthodes de la classe TODO, que ce paquet + n'importe pas — et leur absence rend une liste VIDE sans erreur. Les deux + sont exigées : sans résolveur, l'hôte et le port ne se sauraient pas, et + les inventer serait une devinette là où `ssh -G` a la réponse. + + Le résolveur est celui qui délègue à `ssh` au lieu de relire le fichier, + parce que lui seul connaît les `Include`, les `Match`, l'héritage des + jokers et ses propres défauts, pour quelques millisecondes l'appel — d'où + une résolution séquentielle de toute la configuration, qui ne mérite + aucune parallélisation. + + Une entrée SANS `HostName` n'est jamais écartée : `ssh -G` remplit alors + l'hôte avec l'ALIAS lui-même, et l'alias est une cible sondable que le + DNS ou le fichier des hôtes résout très bien. + + Deux filtres tiennent de ce côté-ci. Un joker et un motif NIÉ sont des + règles et non des machines ; l'énumérateur du dépôt écarte le premier et + laisse passer le second, donc les deux sont refusés ici. Et aucun lecteur + de fichier du dépôt ne suit `Include` : un alias déclaré dans un fichier + inclus reste invisible à l'énumération, alors même que `ssh -G` le + résoudrait — la source est donc incomplète sans être fausse. + """ + if list_aliases is None or resolve is None: + return [] + try: + aliases = list_aliases() or [] + except Exception: + # Un fichier absent rend déjà [] chez l'énumérateur du dépôt ; un + # énumérateur injecté lève ce qu'il veut, et c'est la même chose. + return [] + found: list[tuple[str, str, int]] = [] + seen: set[str] = set() + for alias in aliases: + if not _is_machine(alias) or alias in seen: + continue + seen.add(alias) + config = _ssh_config(alias, resolve) + host = _text(config.get("hostname")) or alias + found.append((alias, host, _ssh_port(config.get("port")))) + return found + + +def neigh_hosts(text: str) -> list[str]: + """Les adresses qui ont PARLÉ, lues dans « ip neigh ». Fonction PURE. + + Une entrée du voisinage ne porte une adresse matérielle que si la machine + a répondu ; une entrée `FAILED` ou `INCOMPLETE` n'en porte pas, et dit + précisément qu'on a demandé sans obtenir. Exiger l'adresse matérielle est + donc ce qui sépare « a parlé » de « a été sollicitée ». + + C'est le préfiltre par défaut d'un réseau trop large pour s'énumérer : il + ne coûte rien, ne demande aucun droit, et ne touche que des machines déjà + entrées dans le cache du noyau. Il est incomplet par nature — une machine + silencieuse en est absente — donc il précède un balayage, il ne le + remplace pas. + + Rend les adresses dans l'ordre de lecture, sans doublon. + """ + found: list[str] = [] + for line in (text or "").splitlines(): + words = line.split() + if len(words) < 2 or "lladdr" not in words: + continue + address = _address(words[0]) + if address is None or address in found: + continue + found.append(address) + return found + + +def plan_sweep(cidr: str, ports=None, *, skip=()) -> list[tuple[str, int]]: + """Les couples (adresse, port) à frapper sur `cidr`. Fonction PURE. + + `ports` vaut les onze ports de `fingerprint` en son absence. `skip` + retire des adresses, et sert d'abord aux adresses de la machine + elle-même : sans ce retrait, un balayage se reconnaît lui-même et la + passerelle d'un pont est offerte comme un serveur découvert. + + Rend une liste VIDE, sans lever, dans les trois cas où il n'y a rien à + planifier : un CIDR illisible, un réseau plus large qu'un /24, et un + ensemble de ports vide. Le refus du plus large qu'un /24 est vérifié + AVANT toute énumération, parce qu'énumérer un /8 pour découvrir qu'il est + trop grand coûterait seize millions d'adresses en mémoire. + + Un /24 tiré d'une adresse nue est une HYPOTHÈSE et se dit comme telle à + l'utilisateur : le préfixe réel ne se déduit pas d'une adresse, et ce + module ne le devine jamais à sa place. + + L'ordre est par hôte, tous ses ports ensemble : c'est celui que + l'affichage compte (« k/254 hôtes ») et celui qui groupe les résultats + d'une même machine. + """ + network = _network(cidr) + if network is None: + return [] + if _usable_count(network) > MAX_HOSTS: + return [] + wanted = tuple(fingerprint.PORTS if ports is None else ports) + if not wanted: + return [] + excluded = {_address(one) or str(one) for one in skip or ()} + return [ + (str(host), port) + for host in network.hosts() + if str(host) not in excluded + for port in wanted + ] + + +def sweep( + jobs, + *, + connect=None, + workers=MAX_WORKERS, + timeout=CONNECT_TIMEOUT, + heartbeat_sec=HEARTBEAT_SEC, + now=None, + on_event=None, +) -> list[tuple[str, int]]: + """Frappe les couples de `jobs` et rend ceux qui ont accepté. + + `connect(hôte, port, délai)` rend un booléen et vaut `None` par défaut, + résolu sur une connexion TCP nue. La sonde est une connexion et RIEN + d'autre : un `ping` en sous-processus coûterait un `fork+exec` par + adresse, soit un millier pour un /24, là où une socket n'en coûte aucun. + + La piscine est dimensionnée par le nombre de vagues, + `min(len(jobs), workers)`, et jamais par le nombre de cœurs : ces fils + attendent le réseau, ils ne calculent pas. + + `on_event` reçoit de petits tuples, et c'est par lui que le menu imprime + sans que ce module connaisse l'affichage : `("hit", hôte, port)` dès + qu'un port accepte, `("heartbeat", faits, total, secondes)` quand un + intervalle passe sans qu'une réponse arrive, `("done", trouvés, total, + secondes)` à la fin. Les trouvailles sont AUSSI rendues, dans l'ordre où + les événements les ont annoncées, pour qu'un appelant — un test le + premier — puisse affirmer sur des données plutôt que sur du texte capté. + + `now()` rend un compteur de secondes et vaut `None` par défaut, résolu + sur l'horloge monotone. Injecté, il rend les durées des événements + prévisibles. + + Un hôte qui met le délai entier à répondre n'arrête pas les autres, et un + connecteur qui lève sur une adresse compte pour un port fermé : l'échec + d'une cible ne fait pas perdre le balayage. + """ + jobs = list(jobs or ()) + clock = time.monotonic if now is None else now + started = clock() + if not jobs: + _emit(on_event, ("done", 0, 0, clock() - started)) + return [] + if connect is None: + connect = _connect + size = min(len(jobs), workers) or 1 + hits: list[tuple[str, int]] = [] + done = 0 + with ThreadPoolExecutor(max_workers=size) as pool: + futures = { + pool.submit(connect, host, port, timeout): (host, port) + for host, port in jobs + } + pending = set(futures) + while pending: + try: + for future in as_completed( + list(pending), timeout=heartbeat_sec + ): + pending.discard(future) + done += 1 + host, port = futures[future] + if _accepted(future): + hits.append((host, port)) + _emit(on_event, ("hit", host, port)) + except PoolTimeout: + # Le battement : l'intervalle est passé sans qu'une réponse + # arrive. Il dit que le balayage avance, là où un silence + # prolongé se lit comme un blocage. + _emit( + on_event, + ("heartbeat", done, len(jobs), clock() - started), + ) + _emit(on_event, ("done", len(hits), len(jobs), clock() - started)) + return hits + + +def _emit(on_event, event): + """Passe un événement à l'appelant, s'il en veut.""" + if on_event is not None: + on_event(event) + + +def _accepted(future): + """Le port a-t-il accepté ? Une levée compte pour un port fermé.""" + try: + return bool(future.result()) + except Exception: + # Un connecteur peut lever sur une adresse mal formée ou une pile + # réseau à bout de descripteurs : c'est un port qui n'a pas répondu, + # et le balayage continue sur les autres. + return False + + +def _connect(host: str, port: int, timeout: float) -> bool: + """Vrai si `port` accepte une connexion sur `host`. + + Une connexion et rien d'autre : aucun octet n'est envoyé, aucun verbe + n'est prononcé, donc la passe ne peut ni charger un modèle ni dépenser un + jeton. Le port ouvert coûte une fraction de milliseconde ; le délai + n'existe que pour borner un écouteur qui accepte sans répondre. + """ + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + +def _bridge_names(text) -> set[str]: + """Les interfaces qui sont des ponts, d'après « ip -d link show ».""" + names = set() + for line in (text or "").splitlines(): + parsed = LINK_NAME.match(line) + if parsed and BRIDGE_KIND.search(line): + names.add(parsed.group(1)) + return names + + +def _default_dev(text) -> str: + """L'interface de la route par défaut, ou la chaîne vide.""" + parsed = ROUTE_DEV.search(text or "") + return parsed.group(1) if parsed else "" + + +def _network(cidr): + """Le réseau d'un CIDR, ou None quand la valeur n'en est pas un. + + Accepte l'adresse d'un hôte avec son préfixe — c'est la forme que `ip` + rend — et la ramène à son réseau, ce qui est l'ensemble à balayer. + """ + if not isinstance(cidr, str) or not cidr.strip(): + return None + try: + return ipaddress.ip_network(cidr.strip(), strict=False) + except ValueError: + return None + + +def _usable_count(network) -> int: + """Le nombre d'hôtes utilisables d'un réseau, sans l'énumérer. + + Compté et non énuméré : le plafond se vérifie devant un /8 comme devant + un /24, et matérialiser le premier pour le mesurer coûterait seize + millions d'adresses. Un réseau de deux adresses ou moins les compte + toutes, comme le fait l'énumération elle-même — il n'y a alors ni adresse + de réseau ni adresse de diffusion à retirer. + """ + total = network.num_addresses + return total - 2 if total > 2 else total + + +def _address(text): + """L'adresse en forme canonique, ou None si le texte n'en est pas une.""" + if not isinstance(text, str): + return None + try: + return str(ipaddress.ip_address(text.strip())) + except ValueError: + return None + + +def _vm_address(name, vm_ip): + """L'adresse d'une VM, ou None quand elle n'en annonce aucune.""" + if vm_ip is None: + return None + try: + return _text(vm_ip(name)) or None + except Exception: + # Une VM dont la résolution échoue reste listée : une seule source + # abîmée ne fait pas disparaître les autres machines. + return None + + +def _ssh_config(alias, resolve) -> dict: + """La configuration résolue d'un alias, ou un dictionnaire vide.""" + try: + config = resolve(alias) + except Exception: + # Le résolveur du dépôt rend déjà {} sur un `ssh` absent ou un code + # de retour non nul ; un résolveur injecté lève ce qu'il veut. + return {} + return config if isinstance(config, dict) else {} + + +def _is_machine(alias) -> bool: + """Cet alias désigne-t-il une machine, plutôt qu'une règle ?""" + if not isinstance(alias, str) or not alias.strip(): + return False + if alias.startswith(SSH_NEGATION): + return False + return not any(char in alias for char in SSH_PATTERN_CHARS) + + +def _ssh_port(value) -> int: + """Le port d'une configuration résolue, sinon le défaut de `ssh`. + + `ssh -G` rend toujours un port, et le défaut n'est atteint que si la + résolution a échoué entièrement : il vaut alors ce que `ssh` lui-même + aurait annoncé, ce qui n'invente rien. + """ + text = _text(value) + if text.isdigit(): + port = int(text) + if 1 <= port <= 65535: + return port + return SSH_PORT + + +def _text(value) -> str: + """La valeur quand c'est une chaîne, sans ses espaces de bord ; sinon "". + + Une valeur d'un autre type est jetée plutôt que passée par `str()` : la + représentation d'un dictionnaire entrerait dans un nom d'hôte et de là + dans une cible de connexion. + """ + return value.strip() if isinstance(value, str) else "" diff --git a/script/todo/assistant/fingerprint.py b/script/todo/assistant/fingerprint.py new file mode 100644 index 0000000..fa179de --- /dev/null +++ b/script/todo/assistant/fingerprint.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Qui répond sur un port : l'échelle de reconnaissance, et son transport. + +Un port dit OÙ frapper, jamais QUI répond : 8080 héberge llama.cpp, LocalAI et +Open WebUI, 5000 héberge text-generation-webui et TabbyAPI, et `/v1/models` +est servi par onze serveurs sur douze. L'identité se lit donc dans le CORPS +d'une réponse, dans un ordre fixe, et le premier accord arrête l'échelle. + +Cet ordre porte tout le raisonnement, et son premier étage en est la raison : +LocalAI réémet l'API native d'Ollama EN ENTIER — `/api/tags`, `/api/show`, +`/api/ps`, `/api/version` — et rend jusqu'à la chaîne « Ollama is running » +sur « / ». Les points de terminaison propres à Ollama n'identifient donc pas +Ollama. LocalAI s'écarte le PREMIER, par `GET /readyz`, qu'Ollama ne possède +pas et où il rend 404 ; l'étage Ollama n'est atteignable que parce que cet +écart a déjà eu lieu. + +Le module tient deux moitiés qui ne se mélangent pas. `identify` est PUR : il +ne reçoit que des octets déjà lus, donc l'ordre de l'échelle se vérifie sans +ouvrir une socket. `collect` ne fait que le transport, donc le plafond de +lecture et les délais se vérifient contre un serveur qui se conduit mal. + +La découverte n'émet que des GET, sans corps et sans en-tête `Authorization` : +un balayage ne doit pouvoir ni charger un modèle, ni dépenser un jeton. Le +`POST /api/show` d'Ollama appartient à l'interrogation des capacités, lancée +après que l'utilisateur a choisi un serveur. + +Trois réponses que le transport rend comme des RÉSULTATS et non des échecs : +un 503 « starting » ou « Loading model » est vivant et identifié, un 401 est +un accord de reconnaissance et jamais une invitation à saisir une clé, et un +corps tronqué vaut ce qui en est arrivé. +""" +from __future__ import annotations + +import functools +import http.client +import json +import re +import socket +import time +import urllib.parse +from dataclasses import dataclass +from typing import Callable + +# Les ports à frapper, dans cet ordre. Le port ne nomme rien : il ne fait +# qu'ouvrir la question que l'échelle tranche. +PORTS: tuple[int, ...] = ( + 11434, + 1234, + 5001, + 1337, + 4891, + 8080, + 5000, + 8000, + 3000, + 8081, + 5002, +) + +# Le plafond de lecture par réponse. Une page d'administration de routeur ou +# un catalogue de plusieurs milliers de modèles répond volontiers à ces +# chemins ; la reconnaissance se joue dans les premiers octets. +BODY_CAP = 8192 + +# GPT4All n'expose aucun point de terminaison qui lui soit propre : son étage +# ne s'atteint que par élimination, et seulement sur ce port. +GPT4ALL_PORT = 4891 + +# OpenAI distant se tranche par le nom d'hôte. Un scan ne le touche jamais : +# il coûte un jeton et n'est pas sur le réseau qu'on balaie. +OPENAI_HOST = "api.openai.com" + +OLLAMA_ROOT = b"Ollama is running" +JAN_TITLE = "Jan API Server Endpoints" + +# Un numéro de version plausible. L'étage Ollama s'en sert pour confirmer que +# `/api/version` répond bien ce qu'Ollama y répond, et non le JSON d'autre +# chose monté au même endroit. +SEMVER = re.compile(r"^\d+\.\d+") + +# Ce qui prouve que rien n'écoute : les chemins suivants seraient refusés de +# la même façon, donc la collecte s'arrête au lieu de recommencer quinze fois +# par hôte mort. +DEAD = (ConnectionRefusedError, socket.gaierror) + + +@dataclass(frozen=True) +class Fingerprint: + """Ce qu'une réponse a prouvé, et ce qu'elle n'a pas dit. + + `software` est la chaîne vide quand aucun étage n'a reconnu quoi que ce + soit : un point de terminaison génériquement compatible OpenAI en est un + cas normal, pas une panne. `unknown` nomme les champs que les corps ne + portaient pas, parmi « software », « version » et « models » — de quoi + afficher « ? » sur ceux-là plutôt que de deviner. + """ + + software: str = "" + version: str = "" + models: tuple[str, ...] = () + unknown: frozenset[str] = frozenset() + + +@dataclass(frozen=True) +class Probe: + """Un étage : le logiciel qu'il nomme, ce qu'il lit, ce qui l'accorde. + + `decide(bodies, port, host)` rend la version lue, la chaîne vide quand le + corps ne la porte pas, et `None` quand l'étage ne reconnaît rien. + Distinguer « accord sans version » de « pas d'accord » est ce qui permet + de nommer un serveur dont le numéro reste inconnu. + """ + + software: str + paths: tuple[str, ...] + decide: Callable[[dict[str, tuple[int, bytes]], int, str], str | None] + + +def _answer( + bodies: dict[str, tuple[int, bytes]], path: str +) -> tuple[int, bytes] | None: + """Le couple (statut, octets) d'un chemin, ou `None`. + + Un chemin absent de la table n'a pas été sondé ou n'a rien rendu : les + deux se lisent pareil, et aucun étage ne doit distinguer les deux. + """ + answer = bodies.get(path) + if not isinstance(answer, tuple) or len(answer) != 2: + return None + return answer + + +def _json( + bodies: dict[str, tuple[int, bytes]], + path: str, + *, + statuses: tuple[int, ...] = (200,), +) -> object: + """Le corps d'un chemin analysé en JSON, ou `None`. + + Rend `None` sur un statut non voulu, sur du HTML, sur un corps vide et + sur un corps coupé en plein milieu : chaque analyse est enveloppée parce + qu'un portail captif répond 200 en HTML à n'importe quel chemin. + """ + answer = _answer(bodies, path) + if answer is None or answer[0] not in statuses: + return None + try: + return json.loads(answer[1]) + except Exception: + return None + + +def _entries(data: object) -> list[dict]: + """Les entrées d'une liste OpenAI `{"data": [...]}`, sinon une liste vide. + + Ne garde que les éléments qui sont des mappings : une liste d'identifiants + nus ne porte aucun champ à lire. + """ + if not isinstance(data, dict): + return [] + entries = data.get("data") + if not isinstance(entries, list): + return [] + return [entry for entry in entries if isinstance(entry, dict)] + + +def _text(data: object, key: str) -> str: + """La valeur textuelle d'une clé d'un mapping, sinon la chaîne vide.""" + if isinstance(data, dict): + value = data.get(key) + if isinstance(value, str): + return value + return "" + + +# Étage 1 — LocalAI. `/readyz` est le seul point que LocalAI possède et +# qu'Ollama ignore : Ollama y rend 404. La version ne se lit PAS ici, et pas +# ailleurs non plus : LocalAI annonce un littéral figé sur `/api/version`, +# indépendant de sa propre version, et ce numéro-là existe aussi comme +# version réelle d'Ollama — il ne sépare donc rien à lui seul. +def _localai(bodies, port, host): + answer = _answer(bodies, "/readyz") + if answer is None: + return None + status, body = answer + if status == 200 and not body.strip(): + return "" + if status == 503 and _text( + _json(bodies, "/readyz", statuses=(503,)), "status" + ): + # Le préchargement d'un modèle : le serveur est identifié et vivant, + # il n'est pas encore prêt à répondre. + return "" + return None + + +# Étage 2 — KoboldCpp se nomme lui-même dans le champ `result`. +def _koboldcpp(bodies, port, host): + data = _json(bodies, "/api/extra/version") + if _text(data, "result") == "KoboldCpp": + return _text(data, "version") + return None + + +# Étage 3 — Jan se nomme dans le titre de son schéma OpenAPI. +def _jan(bodies, port, host): + data = _json(bodies, "/openapi.json") + if not isinstance(data, dict): + return None + info = data.get("info") + if _text(info, "title") == JAN_TITLE: + return _text(info, "version") + return None + + +# Étage 4 — Open WebUI est la seule interface à publier `deployment_id`. +def _open_webui(bodies, port, host): + data = _json(bodies, "/api/config") + if isinstance(data, dict) and "deployment_id" in data: + return _text(data, "version") + return None + + +# Étage 5 — llama.cpp. Les deux clés sont exigées ENSEMBLE : `build_info` seul +# se retrouve sur des empaquetages qui recopient le champ, et +# `chat_template_caps` est ce que le serveur amont sert vraiment sur `/props`. +def _llamacpp(bodies, port, host): + data = _json(bodies, "/props") + if not isinstance(data, dict): + return None + if "build_info" in data and "chat_template_caps" in data: + return _text(data, "build_info") + return None + + +# Étage 6 — vLLM. Le chemin est `/version`, PAS `/api/version` : ce dernier +# appartient à Ollama et à LocalAI, et les confondre nomme vLLM sur toutes +# les machines Ollama. +def _vllm(bodies, port, host): + data = _json(bodies, "/version") + if isinstance(data, dict) and "version" in data: + return _text(data, "version") + return None + + +# Étage 7 — LM Studio. Son catalogue porte des champs que la forme OpenAI +# n'a pas ; l'ancien chemin `/api/v0/models` et le nouveau se lisent pareil. +def _lmstudio(bodies, port, host): + for path in ("/api/v0/models", "/api/v1/models"): + for entry in _entries(_json(bodies, path)): + if "compatibility_type" in entry or "max_context_length" in entry: + return "" + return None + + +# Étage 8 — Ollama, atteignable seulement parce que l'étage 1 a écarté +# LocalAI. Le catalogue et la racine sont exigés ENSEMBLE, et `/api/version` +# confirme sans jamais être exigé : une confirmation absente laisse l'accord +# debout, seul un champ `version` qui ne ressemble pas à un numéro le retire — +# c'est alors que du JSON étranger est monté sous ce chemin. +def _ollama(bodies, port, host): + tags = _json(bodies, "/api/tags") + if not isinstance(tags, dict) or not isinstance(tags.get("models"), list): + return None + root = _answer(bodies, "/") + if root is None or root[0] != 200 or OLLAMA_ROOT not in root[1]: + return None + data = _json(bodies, "/api/version") + if not isinstance(data, dict) or "version" not in data: + return "" + version = _text(data, "version") + return version if SEMVER.match(version) else None + + +# Étage 9 — text-generation-webui, par un chemin interne qu'il est seul à +# monter sous `/v1`. +def _textgen_webui(bodies, port, host): + data = _json(bodies, "/v1/internal/model/info") + if isinstance(data, dict) and data: + return "" + return None + + +# Étage 10 — TabbyAPI. `/v1/model` au singulier n'existe que chez lui, et il +# exige une clé par défaut : un 401 est donc un ACCORD de reconnaissance. Le +# 200 couvre la configuration qui a désactivé l'authentification. +def _tabbyapi(bodies, port, host): + for path in ("/v1/model", "/v1/template/list"): + answer = _answer(bodies, path) + if answer is not None and answer[0] == 401: + return "" + if isinstance(_json(bodies, "/v1/model"), dict): + return "" + return None + + +# Étage 11 — llama.cpp derrière un mandataire inverse, qui ne publie souvent +# que `/v1`. Le champ `owned_by` survit au masquage de `/props`. +def _llamacpp_proxy(bodies, port, host): + for entry in _entries(_json(bodies, "/v1/models")): + if entry.get("owned_by") == "llamacpp": + return "" + return None + + +# Étage 12 — GPT4All, par élimination : rien au-dessus n'a reconnu, le port +# est le sien, et une liste OpenAI est bien là. Le port seul ne suffit pas — +# une page d'administration écoute aussi sur des ports d'application. +def _gpt4all(bodies, port, host): + if port != GPT4ALL_PORT: + return None + data = _json(bodies, "/v1/models") + if isinstance(data, dict) and isinstance(data.get("data"), list): + return "" + return None + + +# Étage 13 — OpenAI distant, tranché par le nom d'hôte. Aucun balayage ne +# l'atteint : c'est la configuration qui le nomme. +def _openai(bodies, port, host): + if host.strip().lower().rstrip(".") == OPENAI_HOST: + return "" + return None + + +# L'échelle, dans l'ordre où elle est lue, arrêt au premier accord. LocalAI +# EN PREMIER : déplacer cet étage plus bas nomme « ollama » toutes les +# machines LocalAI, puisque LocalAI sert l'API native d'Ollama en entier. +LADDER: tuple[Probe, ...] = ( + Probe("localai", ("/readyz",), _localai), + Probe("koboldcpp", ("/api/extra/version",), _koboldcpp), + Probe("jan", ("/openapi.json",), _jan), + Probe("open_webui", ("/api/config",), _open_webui), + Probe("llamacpp", ("/props",), _llamacpp), + Probe("vllm", ("/version",), _vllm), + Probe("lmstudio", ("/api/v0/models", "/api/v1/models"), _lmstudio), + Probe("ollama", ("/api/tags", "/", "/api/version"), _ollama), + Probe("textgen_webui", ("/v1/internal/model/info",), _textgen_webui), + Probe("tabbyapi", ("/v1/model", "/v1/template/list"), _tabbyapi), + Probe("llamacpp", ("/v1/models",), _llamacpp_proxy), + Probe("gpt4all", ("/v1/models",), _gpt4all), + Probe("openai", (), _openai), +) + + +def probe_plan() -> list[tuple[str, str]]: + """Les requêtes de la découverte, dans l'ordre de l'échelle, sans doublon. + + Rend des couples (méthode, chemin). La méthode est toujours GET, et elle + figure dans le plan pour que l'exiger reste une contrainte lisible : un + étage qui aurait besoin d'un autre verbe devrait aussi toucher le + transport, qui ne sait faire que GET. + """ + plan: list[tuple[str, str]] = [] + seen: set[str] = set() + for probe in LADDER: + for path in probe.paths: + if path not in seen: + seen.add(path) + plan.append(("GET", path)) + return plan + + +def _models(bodies: dict[str, tuple[int, bytes]]) -> tuple[str, ...]: + """Les noms de modèles annoncés, dédoublonnés, dans l'ordre de lecture. + + Se lit même quand aucun étage n'a reconnu le serveur : une liste de + modèles est utile devant un point de terminaison anonyme. + """ + names: list[str] = [] + for path in ("/v1/models", "/api/v0/models", "/api/v1/models"): + for entry in _entries(_json(bodies, path)): + name = _text(entry, "id") + if name and name not in names: + names.append(name) + tags = _json(bodies, "/api/tags") + if isinstance(tags, dict) and isinstance(tags.get("models"), list): + for entry in tags["models"]: + name = _text(entry, "name") + if name and name not in names: + names.append(name) + return tuple(names) + + +def identify( + bodies: dict[str, tuple[int, bytes]], *, port: int = 0, host: str = "" +) -> Fingerprint: + """Qui répond, lu dans les corps déjà collectés. Fonction PURE. + + `bodies` associe un chemin à (statut, octets bruts) ; un chemin absent + n'a pas été sondé ou n'a rien rendu. `port` ne sert qu'à l'étage + d'élimination et `host` qu'à l'étage nommé par configuration : aucun des + deux ne peut nommer un logiciel que le corps n'a pas prouvé. + + Ne lève jamais. Un corps vide, tronqué, HTML ou hostile rend une + empreinte sans logiciel, ce qui est un résultat. + """ + models = _models(bodies) + for probe in LADDER: + version = probe.decide(bodies, port, host) + if version is None: + continue + unknown = set() + if not version: + unknown.add("version") + if not models: + unknown.add("models") + return Fingerprint(probe.software, version, models, frozenset(unknown)) + unknown = {"software", "version"} + if not models: + unknown.add("models") + return Fingerprint("", "", models, frozenset(unknown)) + + +def _http_get( + url: str, timeout: float, *, max_bytes: int = BODY_CAP +) -> tuple[int, bytes]: + """Un GET de la bibliothèque standard, dont le corps est PLAFONNÉ. + + Rend (statut, octets). Ne monte ni `Authorization`, ni corps, ni verbe + autre que GET. Le plafond exige de lire la réponse par morceaux, ce que + `requests` ne donne pas simplement : un serveur qui annonce huit + mégaoctets ne doit pas en faire tenir huit en mémoire du menu. + """ + parts = urllib.parse.urlsplit(url) + conn = http.client.HTTPConnection( + parts.hostname or "", parts.port or 80, timeout=timeout + ) + try: + conn.request("GET", parts.path or "/") + response = conn.getresponse() + return response.status, response.read(max_bytes) + finally: + conn.close() + + +def collect( + host: str, + port: int, + *, + http_get: Callable[[str, float], tuple[int, bytes]] | None = None, + budget: float = 1.0, + max_bytes: int = BODY_CAP, +) -> dict[str, tuple[int, bytes]]: + """Frappe le plan et rend les corps arrivés. Le transport, et rien d'autre. + + `http_get(url, timeout)` rend (statut, octets) et se remplace en test ; + la réalisation par défaut passe par la bibliothèque standard pour pouvoir + plafonner la lecture. `budget` est le TOTAL de la collecte : le délai de + chaque requête est ce qu'il en reste, donc un écouteur bloqué coûte le + budget une fois et non une fois par chemin. + + Ne lève pas pour un port mort, une page HTML, un 401, un 503 ou un corps + coupé : ce sont des résultats, et l'appelant les lit par `identify`. Un + chemin absent du dictionnaire n'a rien rendu. + """ + if http_get is None: + http_get = functools.partial(_http_get, max_bytes=max_bytes) + bodies: dict[str, tuple[int, bytes]] = {} + deadline = time.monotonic() + budget + for _method, path in probe_plan(): + left = deadline - time.monotonic() + if left <= 0: + break + try: + status, body = http_get(f"http://{host}:{port}{path}", left) + except DEAD: + break + except Exception: + # Un délai dépassé, une réponse illisible, une coupure : le + # chemin reste absent et les suivants gardent leur chance. + continue + # Le plafond est celui du collecteur, pas celui du transport : un + # `http_get` injecté qui l'ignorerait ne remplit pas la mémoire. + bodies[path] = (status, bytes(body[:max_bytes])) + return bodies diff --git a/script/todo/assistant/gpt.py b/script/todo/assistant/gpt.py new file mode 100644 index 0000000..7f5999b --- /dev/null +++ b/script/todo/assistant/gpt.py @@ -0,0 +1,473 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le catalogue d'outils gpt : un fichier Markdown par outil. + +Un gpt porte un en-tête YAML — identité, exigences, paramètres du modèle, +entrées, contexte déclaré — puis un corps coupé par des marqueurs en deux +parties : l'invite système et le gabarit de question. RIEN dans un gpt ne +s'exécute au nom du modèle : le contexte déclaré est LU, jamais évalué, et +c'est `context.py` qui décide ce qu'il a le droit de lire. + +Le corps se coupe par des marqueurs `` plutôt que par des +scalaires YAML : le dépôt lit déjà cette forme dans ses `.base.md`, et une +longue prose dans un bloc YAML est là où vivent les fautes d'indentation. + +**`yaml.safe_load` n'est pas un validateur**, et c'est la contrainte qui +gouverne ce module. Un en-tête qui est une LISTE rend une `list`, un scalaire +nu rend une `str`, un fichier vide rend `None`, et une clé RÉPÉTÉE est résolue +en silence sur la dernière. Aucun de ces quatre cas ne lève. +Le dernier est le pire : deux blocs `requires` font passer un gpt de +`loopback` à `any` sans un mot, donc changent sa classe de sûreté. D'où un +contrôle de TYPE, et une relecture du texte BRUT à la recherche des clés +répétées, avant toute lecture de champ. + +**Un fichier abîmé ne casse jamais le menu.** Chaque gpt est analysé dans son +propre rattrapage, les échecs s'accumulent dans une liste de problèmes, et le +catalogue s'affiche avec ce qui reste. Jamais fatal, jamais silencieux non +plus : un outil cassé qui se tait se confond avec un outil absent. + +Les problèmes portent une CLÉ d'internationalisation et non une phrase : +l'affichage appartient au menu, comme pour la raison que rend +`capabilities.match`. + +Deux racines, la seconde l'emportant par nom de fichier. Celle du dépôt est +publique et ne porte rien d'identifiant ; celle de l'utilisateur vit sous son +répertoire personnel, n'est jamais versionnée, et n'a passé aucune relecture — +d'où deux restrictions sur elle : `hosting` y est forcé à `loopback`, et un +gpt qui y déclarerait une COMMANDE est refusé. Un fichier qu'on n'a pas relu +n'est pas une donnée, c'est de la configuration exécutable. +""" +from __future__ import annotations + +import os +import re +from dataclasses import dataclass, field +from pathlib import Path + +# La version du schéma que ce module sait lire. Un fichier qui en annonce une +# plus récente est GRISÉ avec sa raison, jamais deviné : deviner un champ dont +# on ne connaît pas le sens est la façon la plus sûre de trahir un gpt. +SCHEMA = 1 + +# Les marqueurs qui coupent le corps. Le gabarit de question est OBLIGATOIRE — +# sans lui, un gpt n'a rien à demander au modèle et n'est qu'une invite. +MARQUEUR_SYSTEME = "" +MARQUEUR_QUESTION = "" + +# `make doc_markdown` ne balaie que les `*.base.md` : un gpt ainsi nommé +# serait réécrit par la chaîne de documentation, qui y verrait une source +# bilingue. Le suffixe est donc refusé au chargement plutôt que découvert au +# prochain `make doc_markdown`. +SUFFIXE_INTERDIT = ".base.md" + +# L'extension d'un gpt, et la seule. +SUFFIXE = ".md" + +# Le répertoire des gpts de l'utilisateur, sous son répertoire personnel. +RACINE_UTILISATEUR = "~/.erplibre/gpt" + +# Les clés attendues à la racine de l'en-tête. Une clé inconnue n'est pas une +# erreur — le schéma peut grandir — mais elle est SIGNALÉE, parce qu'une faute +# de frappe sur « requires » retirerait toutes les exigences en silence. +CLES_CONNUES = frozenset( + { + "gpt", + "name", + "name_fr", + "description", + "requires", + "params", + "inputs", + "context", + } +) + +# Une clé de premier niveau dans le texte BRUT de l'en-tête : en début de +# ligne, sans indentation. C'est ce qui permet de voir une répétition que +# `yaml.safe_load` a déjà écrasée. +CLE_BRUTE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*:", re.MULTILINE) + +# Le séparateur d'en-tête, en tête de fichier puis en fermeture. +BORNE = "---" + +# Les problèmes que le chargement peut rendre. Nommés une fois ici : le +# module les rend, le test les lit, et une reformulation ne peut pas faire +# diverger les deux. Ce sont des clés d'internationalisation, donc la +# chaîne anglaise elle-même — `t()` rend une clé absente inchangée, ce qui +# dégrade en anglais correct plutôt qu'en jargon. +SANS_ENTETE = "No front-matter: a gpt opens with ---" +ENTETE_NON_FERMEE = "Front-matter is not closed" +ENTETE_PAS_UN_DICTIONNAIRE = "Front-matter is not a mapping" +ENTETE_ILLISIBLE = "Front-matter is unreadable" +CLE_REPETEE = "Repeated key in front-matter:" +SCHEMA_ABSENT = "No schema version: gpt is required" +SCHEMA_TROP_RECENT = "Schema too recent for this version of TODO" +NOM_ABSENT = "No name" +DESCRIPTION_ABSENTE = "No description" +MARQUEUR_QUESTION_ABSENT = "Missing marker " +CLE_INCONNUE = "Unknown key in front-matter:" +NAME_FR_HORS_PLACE = "name_fr belongs in the translations file" +HORS_DEPOT_SANS_COMMANDE = ( + "A gpt from outside the repository may not declare any command." +) +ECRASE = "Overrides the one from" +FICHIER_ILLISIBLE = "Unreadable file:" +NOM_BASE_MD_REFUSE = "A gpt may not be named *.base.md" +PYYAML_ABSENT = ( + "PyYAML is missing: the gpt catalogue stays closed, the free question" + " works." +) + + +@dataclass(frozen=True) +class Probleme: + """Ce qui empêche un gpt d'être utilisable, ou mérite d'être relu. + + `key` est une clé d'internationalisation et `detail` le fragment concret + à montrer à côté — un nom de clé répétée, une valeur refusée. Le menu + traduit la première et imprime la seconde telle quelle. + + `fatal` distingue un gpt qui ne se charge pas d'un gpt utilisable dont + quelque chose est à relire : le premier disparaît du catalogue, le second + y reste avec sa remarque. + """ + + stem: str + key: str + detail: str = "" + fatal: bool = True + + +@dataclass +class Gpt: + """Un outil du catalogue, tel que son fichier le déclare. + + `stem` est le nom de fichier sans extension, et c'est l'IDENTITÉ : il n'y + a pas de champ `id`, parce que deux sources de vérité pour un nom finissent + par diverger. Renommer le fichier renomme le gpt. + + `name` et `description` portent la chaîne ANGLAISE, qui EST la clé + d'internationalisation — `t()` rend une clé inconnue inchangée, donc un + gpt non traduit s'affiche en anglais au lieu de rien. + """ + + stem: str + name: str + description: str + system: str = "" + question: str = "" + requires: dict = field(default_factory=dict) + params: dict = field(default_factory=dict) + inputs: tuple = () + context: dict = field(default_factory=dict) + name_fr: str = "" + root: str = "" + from_repo: bool = True + + +def repo_root() -> Path: + """La racine des gpts livrés avec le dépôt. + + Dérivée de l'emplacement de ce module plutôt que d'un chemin écrit : + le paquet reste déplaçable, et un checkout ailleurs fonctionne sans + réglage. + """ + return Path(__file__).resolve().parent / "gpt" + + +def default_roots(*, home=None) -> list[Path]: + """Les racines à charger, dans l'ordre où elles se recouvrent. + + Celle du dépôt d'abord, celle de l'utilisateur ensuite : à nom de fichier + égal, la seconde l'emporte, ce qui permet d'adapter un gpt livré sans + modifier un fichier versionné. + + Chaque racine est rendue avec sa CONFIANCE : le dépôt est relu, le + répertoire de l'utilisateur non. La confiance est une donnée et non une + comparaison de chemins — déduite, elle serait indéductible en test, et un + chargeur qu'on ne peut pas exercer sur ses deux niveaux de confiance + n'exerce en pratique que le permissif. + + `home` remplace le répertoire personnel, pour qu'un test n'aille pas lire + celui de la machine qui le lance. + """ + base = Path(home) if home else Path(os.path.expanduser("~")) + utilisateur = base / RACINE_UTILISATEUR.removeprefix("~/") + return [(repo_root(), True), (utilisateur, False)] + + +def parse(text, *, stem, from_repo=True): + """Un gpt et ses problèmes, lus dans `text`. Fonction PURE. + + Rend `(gpt, problemes)`. `gpt` vaut `None` quand un problème fatal + empêche de le construire ; les problèmes non fatals accompagnent un gpt + utilisable. + """ + problemes: list[Probleme] = [] + + def refus(key, detail=""): + problemes.append(Probleme(stem, key, detail)) + return None, problemes + + entete_brute, corps, erreur = _decouper(text or "") + if erreur: + return refus(erreur) + + repetee = _cle_repetee(entete_brute) + if repetee: + # Écrasée en silence par l'analyseur : la valeur retenue est la + # DERNIÈRE, donc un second bloc `requires` change la classe de sûreté + # du gpt sans que rien ne le dise. + return refus(CLE_REPETEE, repetee) + + entete, erreur = _charger_yaml(entete_brute) + if erreur: + return refus(erreur) + if not isinstance(entete, dict): + return refus(ENTETE_PAS_UN_DICTIONNAIRE, type(entete).__name__) + + version = entete.get("gpt") + if not isinstance(version, int) or isinstance(version, bool): + return refus(SCHEMA_ABSENT) + if version > SCHEMA: + return refus(SCHEMA_TROP_RECENT, str(version)) + + name = _texte(entete.get("name")) + if not name: + return refus(NOM_ABSENT) + description = _texte(entete.get("description")) + if not description: + return refus(DESCRIPTION_ABSENTE) + + if MARQUEUR_QUESTION not in corps: + return refus(MARQUEUR_QUESTION_ABSENT) + system, question = _corps(corps) + + for clef in sorted(set(entete) - CLES_CONNUES): + # Pas une erreur — le schéma peut grandir — mais une faute de frappe + # sur « requires » retirerait toutes les exigences sans un mot. + problemes.append(Probleme(stem, CLE_INCONNUE, clef, fatal=False)) + + requires = _mapping(entete.get("requires")) + params = _mapping(entete.get("params")) + context = _mapping(entete.get("context")) + name_fr = _texte(entete.get("name_fr")) + + if from_repo and name_fr: + # Dans le dépôt, la traduction vit dans le fichier des traductions, + # qui est la source unique. Un `name_fr` ici en créerait une seconde. + problemes.append(Probleme(stem, NAME_FR_HORS_PLACE, "", False)) + if not from_repo: + if context.get("commands"): + # Un fichier hors du dépôt n'a passé aucune relecture : lui + # laisser déclarer une commande en ferait de la configuration + # exécutable, et la liste d'autorisation vit dans le dépôt. + return refus(HORS_DEPOT_SANS_COMMANDE) + requires = dict(requires) + requires["hosting"] = "loopback" + + gpt = Gpt( + stem=stem, + name=name, + description=description, + system=system, + question=question, + requires=requires, + params=params, + inputs=_entrees(entete.get("inputs")), + context=context, + name_fr=name_fr, + from_repo=from_repo, + ) + return gpt, problemes + + +def load_all(*, roots=None, read=None, home=None): + """Le catalogue et ses problèmes, lus dans les racines. + + Rend `(gpts, problemes)`, les gpts triés par nom de fichier. Une racine + absente n'est pas une erreur : le répertoire de l'utilisateur n'existe + d'ordinaire pas. + + `read(chemin)` rend le texte d'un fichier, et `roots` remplace la liste + des racines par des couples (chemin, relu) : les deux coutures permettent + à un test de décrire un catalogue entier, aux deux niveaux de confiance, + sans toucher au disque de la machine. + + Sans PyYAML, le catalogue rend une liste VIDE et un seul problème qui le + dit. Le catalogue est un supplément ; la question libre, elle, marche sans + lui. + """ + problemes: list[Probleme] = [] + if not _yaml_disponible(): + return [], [ + Probleme( + "", + PYYAML_ABSENT, + "", + True, + ) + ] + + lecteur = read or _lire + trouves: dict[str, Gpt] = {} + sources = roots if roots is not None else default_roots(home=home) + for racine, depot in sources: + racine = Path(racine) + for chemin in _fichiers(racine): + stem = chemin.name[: -len(SUFFIXE)] + if chemin.name.endswith(SUFFIXE_INTERDIT): + problemes.append( + Probleme(stem, NOM_BASE_MD_REFUSE, chemin.name) + ) + continue + try: + texte = lecteur(chemin) + except OSError as panne: + problemes.append(Probleme(stem, FICHIER_ILLISIBLE, str(panne))) + continue + try: + gpt, soucis = parse(texte, stem=stem, from_repo=depot) + except Exception as panne: # pragma: no cover - filet + # Le filet de sécurité : une forme d'en-tête imprévue ne doit + # pas emporter le catalogue entier avec elle. + problemes.append(Probleme(stem, FICHIER_ILLISIBLE, str(panne))) + continue + problemes.extend(soucis) + if gpt is None: + continue + gpt.root = str(racine) + if stem in trouves: + problemes.append( + Probleme( + stem, + ECRASE, + trouves[stem].root, + fatal=False, + ) + ) + trouves[stem] = gpt + return [trouves[cle] for cle in sorted(trouves)], problemes + + +def _fichiers(racine): + """Les fichiers `.md` d'une racine, triés. Vide si elle n'existe pas. + + Sans récursion : un catalogue plat se lit d'un coup d'œil, et un + sous-répertoire y cacherait un gpt. + """ + try: + return sorted( + chemin + for chemin in racine.iterdir() + if chemin.is_file() and chemin.name.endswith(SUFFIXE) + ) + except OSError: + return [] + + +def _lire(chemin): + """Le texte d'un fichier de gpt.""" + return Path(chemin).read_text(encoding="utf-8") + + +def _yaml_disponible(): + """PyYAML est-il là ? Son absence ferme le catalogue, pas le menu.""" + try: + import yaml # noqa: F401 + except ImportError: + return False + return True + + +def _decouper(text): + """(en-tête brut, corps, clé d'erreur) — la coupe du fichier. + + L'en-tête ouvre le fichier et se ferme sur une ligne de bornes. Un + fichier sans en-tête est refusé plutôt que traité comme un corps nu : un + gpt sans exigences ni identité n'est pas un gpt. + """ + lignes = text.splitlines() + if not lignes or lignes[0].strip() != BORNE: + return "", "", SANS_ENTETE + for rang in range(1, len(lignes)): + if lignes[rang].strip() == BORNE: + return ( + "\n".join(lignes[1:rang]), + "\n".join(lignes[rang + 1 :]), + "", + ) + return "", "", ENTETE_NON_FERMEE + + +def _cle_repetee(entete_brute): + """La première clé de premier niveau qui paraît deux fois, ou "". + + Lue dans le texte BRUT : l'analyseur YAML a déjà écrasé la première + occurrence quand on lui pose la question, donc lui demander ne sert à + rien. + """ + vues = set() + for nom in CLE_BRUTE.findall(entete_brute): + if nom in vues: + return nom + vues.add(nom) + return "" + + +def _charger_yaml(entete_brute): + """(objet, clé d'erreur) — l'en-tête analysé, ou la raison du refus.""" + import yaml + + try: + return yaml.safe_load(entete_brute), "" + except yaml.YAMLError: + return None, ENTETE_ILLISIBLE + + +def _corps(corps): + """(invite système, gabarit de question) — le corps coupé. + + L'invite système est ce qui précède le marqueur de question, son propre + marqueur retiré s'il est présent. Le marqueur de question a déjà été + exigé par l'appelant. + """ + avant, _, apres = corps.partition(MARQUEUR_QUESTION) + avant = avant.replace(MARQUEUR_SYSTEME, "") + return avant.strip(), apres.strip() + + +def _mapping(valeur): + """Le dictionnaire quand c'en est un, sinon un dictionnaire vide. + + Une valeur d'un autre type est JETÉE plutôt que devinée : une liste sous + `requires` viendrait d'une indentation fautive, et en tirer des exigences + inventerait une classe de sûreté. + """ + return dict(valeur) if isinstance(valeur, dict) else {} + + +def _entrees(valeur): + """Les entrées déclarées, chacune réduite à un dictionnaire nommé. + + Une entrée sans nom est écartée : elle ne pourrait ni se demander ni se + substituer dans le gabarit. + """ + if not isinstance(valeur, list): + return () + gardees = [] + for entree in valeur: + if isinstance(entree, dict) and _texte(entree.get("name")): + gardees.append(dict(entree)) + return tuple(gardees) + + +def _texte(valeur): + """La valeur quand c'est une chaîne non vide, sans ses bords ; sinon "". + + Une valeur d'un autre type n'est pas passée par `str()` : la + représentation d'un dictionnaire entrerait dans un libellé de menu, et de + là dans une clé de traduction. + """ + return valeur.strip() if isinstance(valeur, str) else "" diff --git a/script/todo/assistant/gpt/base-md-bilingual.md b/script/todo/assistant/gpt/base-md-bilingual.md new file mode 100644 index 0000000..ed399b0 --- /dev/null +++ b/script/todo/assistant/gpt/base-md-bilingual.md @@ -0,0 +1,39 @@ +--- +gpt: 1 +name: Bilingual doc - write or repair a .base.md +description: Produce or fix a .base.md, its header and its language blocks +requires: + hosting: lan + context_window: 16000 + parameters: 30 +params: + temperature: 0.2 + max_tokens: 1200 +inputs: + - name: path + type: repo_path + required: true + - name: direction + type: choice + required: true +context: + files: + - .claude/rules/07-documentation.md + - script/todo/README.base.md +--- + + +Tu écris ou répares un fichier source de documentation bilingue. + +La forme est exacte : un en-tête de quatre lignes, puis des blocs par +langue, plus un bloc commun. Un bloc de code va dans le commun et ne se +traduit jamais. + +La traduction se corrige à la SOURCE : les deux fichiers dérivés sont +regénérés, donc une correction qui y serait faite est perdue au prochain +passage de l'outil. + +Rends le fichier source complet, et rien d'autre. + + +Écris ou répare {path}, dans le sens {direction}. diff --git a/script/todo/assistant/gpt/cloned-module-review.md b/script/todo/assistant/gpt/cloned-module-review.md new file mode 100644 index 0000000..0b3511f --- /dev/null +++ b/script/todo/assistant/gpt/cloned-module-review.md @@ -0,0 +1,45 @@ +--- +gpt: 1 +name: Cloned module - find what it inherited +description: Name what a cloned module inherited rather than what was written +requires: + # `lan` : le travail de ce gpt est de regarder des noms suspects, donc + # il ne doit atteindre aucun tiers — mais le réseau de l'opérateur + # n'est pas un tiers. + hosting: lan + context_window: 32000 + parameters: 7 +params: + temperature: 0.1 + max_tokens: 600 +inputs: + - name: module_path + type: repo_path + required: true +context: + files: + - .claude/rules/04-code-conventions.md + commands: + - label: Staged diff + argv: ["git", "diff", "--cached", "--", "{module_path}"] + - label: Comment findings + argv: + - python3 + - script/analyse/check_comment_hygiene.py + - --json + - "{module_path}" +--- + + +Un module engendré depuis un module existant hérite de ses commentaires et +de ses docstrings. Ton travail est de repérer ce qui a été HÉRITÉ plutôt +qu'écrit pour ce module-ci : une phrase qui parle d'un autre sujet, un nom +qui n'a rien à faire là, un exemple pris ailleurs. + +Tu ne réécris rien. Tu désignes. + +Rends une ligne par trouvaille, au plus huit : +: — + + +Qu'est-ce que {module_path} a hérité sans qu'on le veuille ? diff --git a/script/todo/assistant/gpt/comment-hygiene.md b/script/todo/assistant/gpt/comment-hygiene.md new file mode 100644 index 0000000..50c0a24 --- /dev/null +++ b/script/todo/assistant/gpt/comment-hygiene.md @@ -0,0 +1,45 @@ +--- +gpt: 1 +name: Comment hygiene - rewrite narrative as mechanism +description: Rewrite each flagged sentence so the code is the subject, present tense +requires: + # `lan` et non `loopback` : une trouvaille identifiante ne doit pas + # atteindre un TIERS, et une machine que l'opérateur fait tourner sur + # son propre réseau n'en est pas un. Seul `any` est refusé. + hosting: lan + context_window: 8000 + parameters: 30 +params: + temperature: 0.2 + max_tokens: 700 +inputs: + - name: path + type: repo_path + required: true +context: + files: + - .claude/rules/04-code-conventions.md + commands: + - label: Findings + argv: + - python3 + - script/analyse/check_comment_hygiene.py + - --json + - "{path}" +--- + + +Tu réécris les phrases signalées « récit » pour que le CODE en soit le sujet, +au présent. Le mode de défaillance que le code empêche RESTE ; l'incident où +on l'a observé PART. + +Le vérificateur donne le fichier, la ligne et le motif — pas la phrase. Lis +les lignes autour avant de réécrire. + +Rends, par trouvaille : +NARRATIF: +DURABLE: +APRÈS: + + +Réécris les phrases signalées dans {path}. diff --git a/script/todo/assistant/gpt/commit-message.md b/script/todo/assistant/gpt/commit-message.md new file mode 100644 index 0000000..a055d6a --- /dev/null +++ b/script/todo/assistant/gpt/commit-message.md @@ -0,0 +1,36 @@ +--- +gpt: 1 +name: Commit message - subject, bilingual body, Assisted-by +description: Turn the staged diff into a tagged subject and a bilingual body +requires: + hosting: lan + context_window: 16000 + parameters: 30 +params: + temperature: 0.2 + max_tokens: 900 +context: + files: + - .claude/rules/04-code-conventions.md + commands: + - label: Staged files + argv: ["git", "diff", "--cached", "--stat"] + - label: Recent subjects, for style + argv: ["git", "log", "--oneline", "-10"] + - label: Staged diff + argv: ["git", "diff", "--cached"] +--- + + +Tu écris un message de commit pour le diff indexé, selon la règle du dépôt. + +Trois contraintes se vérifient mécaniquement, donc tu les respectes sans +exception : le sujet porte un tag et tient en 72 caractères ; le corps fait +au plus dix lignes PAR LANGUE ; le corps est bilingue, séparé par un +marqueur qui nomme la langue de ce qui SUIT. + +Le corps dit pourquoi c'était nécessaire, puis s'arrête. Rien de ce que le +diff montre déjà. Aucune donnée identifiante. + + +Écris le message pour ce qui est indexé. diff --git a/script/todo/assistant/gpt/manifest-gap.md b/script/todo/assistant/gpt/manifest-gap.md new file mode 100644 index 0000000..09c3605 --- /dev/null +++ b/script/todo/assistant/gpt/manifest-gap.md @@ -0,0 +1,35 @@ +--- +gpt: 1 +name: Manifest gaps - which tier loses which modules +description: Read the reported holes and name the tier and the modules each loses +requires: + hosting: lan + context_window: 16000 + parameters: 7 +params: + temperature: 0.1 + max_tokens: 600 +context: + files: + - .claude/rules/01-versions.md + commands: + - label: Manifest holes + argv: ["python3", "script/analyse/check_manifest_gaps.py", "--json"] +--- + + +Tu lis des trous de manifeste et tu dis, pour chacun, quel palier de version +perd quels modules. + +Deux faits de l'outil bornent ce que tu peux affirmer. Un trou n'est un +DÉFAUT que si la branche existe en amont, et la vérification amont est +désactivée par défaut parce qu'elle coûte une interrogation par dépôt : un +trou non confirmé reste donc un trou à vérifier, jamais un défaut. Et la +majorité des trous rapportés se révèlent sans conséquence, donc les +énumérer tous n'apprend rien. + +Rends au plus cinq lignes, la plus conséquente d'abord : +PALIER : — + + +Quels paliers perdent quoi, et lesquels valent qu'on regarde d'abord ? diff --git a/script/todo/assistant/gpt/unit-test-failure.md b/script/todo/assistant/gpt/unit-test-failure.md new file mode 100644 index 0000000..f1e0fd6 --- /dev/null +++ b/script/todo/assistant/gpt/unit-test-failure.md @@ -0,0 +1,36 @@ +--- +gpt: 1 +name: Unit test failure - the cause, and what to read next +description: Read one failing test and name the cause, without proposing a patch +requires: + hosting: lan + context_window: 8000 + parameters: 7 +params: + temperature: 0.1 + max_tokens: 500 +inputs: + - name: test_file + type: repo_path + required: true +context: + commands: + - label: Test output + argv: ["./script/test/run_unit_test.sh", "{test_file}"] +--- + + +Tu tries une sortie de test unitaire Python. Ton travail est de NOMMER la +cause et de dire quel fichier ouvrir. Tu ne proposes AUCUN correctif. + +Ce que la suite garantit, et qui écarte des causes d'emblée : elle ne +demande ni base de données, ni Odoo, ni machine virtuelle. Un test qui se +déclare ignoré n'est donc pas un test en échec. + +Rends EXACTEMENT ces trois lignes, rien d'autre : +CAUSE: +OUVRIR: +ÉCARTÉ: + + +Voici la sortie de la suite pour {test_file}. Quelle est la cause ? diff --git a/script/todo/assistant/servers.py b/script/todo/assistant/servers.py new file mode 100644 index 0000000..fffd42f --- /dev/null +++ b/script/todo/assistant/servers.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Les serveurs LLM retenus : une poignée opaque, une adresse qui ne sort pas. + +Chaque serveur porte une POIGNÉE — « server-1 » — attribuée par son RANG au +chargement, et c'est la seule forme qui a le droit de circuler : `redacted` +est ce qui peut atteindre une invite, un argument de commande ou un fichier +que le dépôt suit. L'hôte, le port et le libellé servent à l'affichage du +menu et à la configuration privée, et s'arrêtent là. + +La retenue est structurelle parce qu'elle ne peut pas être un filtre : le +détecteur du dépôt reconnaît les adresses, les courriels et les chemins de +compte, et rend une liste vide devant un nom d'hôte, un alias SSH ou un nom +de VM. Ce qu'aucun garde-fou ne voit passer ne doit pas être en position de +passer. + +L'écriture passe par `set_config_value`, et par lui seul. Des trois fichiers +que la lecture fusionne, c'est le seul qui soit gitignored ; les deux autres +suivent le dépôt en amont, et ce qui vit sous `private/` devient public avec +lui sur un fork rendu public. Une seule section est écrite, sous le chemin de +clés « assistant › servers ». + +N'est enregistré que ce que l'utilisateur a choisi de garder : ni date de +dernier contact, ni rapport de balayage, ni résultat négatif. La liste de qui +a répondu parmi les 254 adresses d'un /24 décrit des machines que personne +n'a désignées, là où un serveur retenu en désigne une seule, volontairement. +""" +from __future__ import annotations + +from dataclasses import dataclass, replace + +# Le chemin de clés de la section, dans les fichiers de configuration. +CONFIG_KEYS = ("assistant", "servers") + +HANDLE_PREFIX = "server-" + +# La poignée d'un serveur qui n'a pas encore reçu de rang. Elle garde +# l'adresse dehors là où un repli sur le libellé l'y ferait entrer. +UNASSIGNED_HANDLE = f"{HANDLE_PREFIX}?" + +# L'échelle d'hébergement, du plus contenu au plus exposé. +HOSTINGS = ("loopback", "lan", "global") + +# La classe d'une valeur stockée qu'on ne reconnaît pas. Lire au plus +# prudent impose la confirmation la plus stricte au lieu de la lever. +UNKNOWN_HOSTING = "global" + +# La famille sans racine `/v1`, réduite à ses lettres et ses chiffres. +OPEN_WEBUI = "openwebui" +OPEN_WEBUI_ROOT = "/api" +DEFAULT_ROOT = "/v1" + +HTTPS_PORT = 443 +MAX_PORT = 65535 + + +@dataclass +class Server: + """Un serveur retenu. + + `handle` vaut « server-N » et se rattribue à chaque chargement ; + `label` est ce que l'utilisateur a tapé pour le nommer, et ne sert qu'à + l'affichage. `secret_ref` est vide, ou « kdbx: » : la + clé elle-même reste dans le coffre, jamais ici. + """ + + handle: str + label: str + host: str + port: int + software: str + model: str + hosting: str + secret_ref: str + + +def assign_handles(servers) -> list[Server]: + """Les mêmes serveurs, chacun portant « server-N » selon son rang. + + Numérote à partir de 1, dans l'ordre de la liste reçue. Pure : rend de + nouveaux objets et laisse intacts ceux qu'on lui donne. + + La poignée dérive de la seule position et jamais d'une valeur stockée : + un fichier édité à la main ne peut donc produire ni deux « server-1 », + ni une poignée qui porterait un nom de machine. + """ + return [ + replace(server, handle=f"{HANDLE_PREFIX}{rank}") + for rank, server in enumerate(servers, 1) + ] + + +def load(*, get_config=None) -> list[Server]: + """Les serveurs enregistrés, poignées attribuées. Jamais None. + + `get_config` prend un chemin de clés et rend la valeur fusionnée des + trois fichiers de configuration ; il vaut `ConfigFile().get_config_value` + quand rien n'est injecté, résolu ici pour qu'un test n'ait aucun fichier + réel à toucher. + + Rend une liste vide plutôt que de lever, dans les quatre cas où la + configuration ne porte pas de section utilisable : l'accesseur lève + `TypeError` quand la section est absente, `ValueError` sur un JSON + abîmé, `OSError` sur un fichier illisible, et rend n'importe quel type + sur un fichier édité à la main. Aucun n'est une raison d'empêcher le + menu de s'ouvrir. + + Une entrée qui ne décrit pas un serveur est écartée seule : une ligne + abîmée ne fait pas disparaître les suivantes. + """ + if get_config is None: + from script.config.config_file import ConfigFile + + get_config = ConfigFile().get_config_value + try: + raw = get_config(list(CONFIG_KEYS)) + except (OSError, ValueError, TypeError, KeyError, AttributeError): + return [] + if not isinstance(raw, list): + return [] + kept = [_from_dict(entry) for entry in raw] + return assign_handles([server for server in kept if server is not None]) + + +def save(servers, *, set_config=None) -> None: + """Écrit la liste sous « assistant › servers », et rien d'autre. + + `set_config` prend un chemin de clés et une valeur ; il vaut + `ConfigFile().set_config_value` quand rien n'est injecté. C'est le seul + écrivain autorisé : il vise le seul des trois fichiers fusionnés qui + soit gitignored, il fusionne au lieu d'écraser, et il écrit + atomiquement par un temporaire en 0600 suivi d'un `os.replace`. + + La poignée n'est pas écrite : elle se rattribue au chargement, et un + rang figé sur le disque survivrait à la suppression d'un voisin. + """ + if set_config is None: + from script.config.config_file import ConfigFile + + set_config = ConfigFile().set_config_value + set_config(list(CONFIG_KEYS), [_as_dict(server) for server in servers]) + + +def base_url(server) -> str: + """La racine d'API à laquelle parler à ce serveur. + + Toutes les familles servent leur complétion sous `/v1`, sauf Open WebUI + qui n'a PAS de racine `/v1` : la sienne vit sous `/api`, la complétion à + `/api/chat/completions`, et elle exige un jeton Bearer. Une racine + `/v1` pointée sur lui rend 404 à chaque envoi. + + Le schéma se déduit du port : 443 est du TLS, tout le reste du HTTP en + clair, qui est ce qu'un serveur de modèle sert par défaut. + """ + scheme = "https" if server.port == HTTPS_PORT else "http" + root = ( + OPEN_WEBUI_ROOT + if _family(server.software) == OPEN_WEBUI + else DEFAULT_ROOT + ) + return f"{scheme}://{server.host}:{server.port}{root}" + + +def redacted(server) -> str: + """Ce que ce serveur a le droit de devenir dans une invite. + + Rend « server-1 (ollama) », ou la seule poignée quand le logiciel n'a + pas été reconnu. La poignée et le nom du logiciel ne désignent personne. + + L'hôte, le port et le libellé n'en sortent jamais : le libellé est ce + que l'opérateur a tapé, donc un alias ou un nom de machine aussi souvent + qu'autre chose. Un serveur sans rang rend `UNASSIGNED_HANDLE` plutôt que + de combler le trou avec son adresse. + """ + handle = server.handle or UNASSIGNED_HANDLE + software = _text(server.software) + return f"{handle} ({software})" if software else handle + + +def _family(software) -> str: + """Le nom d'un logiciel réduit à ses lettres et ses chiffres, en bas de + casse. + + L'échelle de reconnaissance nomme « Open WebUI » ; l'espace, le tiret et + la casse varient d'une source à l'autre sans changer la famille, et + comparer les chaînes brutes ferait dépendre la racine d'API d'un tiret. + """ + return "".join(c for c in (software or "").lower() if c.isalnum()) + + +def _from_dict(entry): + """Un serveur lu depuis la configuration, ou None si l'entrée n'en + décrit pas un. + + Exige un hôte et un port utilisables — sans eux il n'y a rien à + joindre — et se contente du reste tel qu'il vient. La poignée stockée + est IGNORÉE : elle se rattribue par le rang. + + Un libellé absent retombe sur l'hôte, comme la saisie du menu le fait + déjà : le libellé ne sert qu'à l'affichage, où l'adresse a le droit de + paraître. + """ + if not isinstance(entry, dict): + return None + host = _text(entry.get("host")) + port = _port(entry.get("port")) + if not host or port is None: + return None + return Server( + handle="", + label=_text(entry.get("label")) or host, + host=host, + port=port, + software=_text(entry.get("software")), + model=_text(entry.get("model")), + hosting=_hosting(entry.get("hosting")), + secret_ref=_text(entry.get("secret_ref")), + ) + + +def _as_dict(server) -> dict: + """L'entrée écrite pour un serveur : ce qui a été choisi, rien de plus. + + Sept champs, tous fournis par l'utilisateur ou par la reconnaissance du + serveur qu'il a désigné. Aucun horodatage, aucune trace de contact : ce + fichier dit ce qu'on garde, pas ce qu'on a vu. + """ + return { + "label": server.label, + "host": server.host, + "port": server.port, + "software": server.software, + "model": server.model, + "hosting": server.hosting, + "secret_ref": server.secret_ref, + } + + +def _text(value) -> str: + """La valeur quand c'est une chaîne, sans ses espaces de bord ; sinon "". + + Une valeur d'un autre type est jetée plutôt que passée par `str()` : + la représentation d'un dictionnaire ou d'une liste entrerait dans un + libellé et de là dans l'affichage. + """ + return value.strip() if isinstance(value, str) else "" + + +def _port(value): + """Le numéro de port, ou None quand la valeur n'en est pas un. + + `bool` est un `int` pour Python : sans le refus explicite, `true` + deviendrait le port 1. Une chaîne de chiffres est acceptée parce qu'un + fichier de configuration édité à la main en porte volontiers une. + """ + if isinstance(value, bool): + return None + if isinstance(value, str): + value = value.strip() + if not value.isdigit(): + return None + value = int(value) + if not isinstance(value, int): + return None + return value if 1 <= value <= MAX_PORT else None + + +def _hosting(value) -> str: + """La classe d'hébergement stockée, « global » si elle n'est pas connue. + + La lecture est pessimiste : une valeur absente ou abîmée vaut tiers, ce + qui impose au premier envoi la confirmation la plus stricte au lieu de + la lever. + """ + text = _text(value) + return text if text in HOSTINGS else UNKNOWN_HOSTING diff --git a/script/todo/assistant_menu.py b/script/todo/assistant_menu.py new file mode 100644 index 0000000..1d45b23 --- /dev/null +++ b/script/todo/assistant_menu.py @@ -0,0 +1,1568 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le menu de l'assistant LLM : quel serveur, et la conversation. + +La frontière avec `script/todo/assistant/` est nette : ici on DEMANDE (quel +serveur, quelle adresse, quelle question) et on affiche ; là-bas on reconnaît, +on classe et on parle. Ce fichier ne connaît ni l'ordre des sondes, ni la forme +d'une réponse d'API. + +Mixin de la classe TODO : ses méthodes vivent sur la même instance que celles +des autres fichiers, elles s'appellent donc par « self. » sans rien importer. +Le fil d'Ariane l'exige — `_menu_header` dérive les miettes de la pile +d'appels en retenant les cadres dont la variable locale `self` EST l'instance +TODO. Une fonction de module n'en laisse aucune, et c'est pourquoi le +sous-menu VPN n'apparaît pas dans son propre fil. + +Trois contraintes d'affichage sont mesurées, pas supposées. + +`click.prompt` réimprime TOUTE sa chaîne d'invite à chaque entrée vide, et une +question collée sur plusieurs lignes lui devient autant de tours — une ligne +collée valant « 0 » déclenche alors une entrée de menu. La conversation lit +donc par `input()`, sur une invite d'une seule ligne, et toute commande porte +une barre oblique initiale. + +`click.prompt` lève `Abort` sur Ctrl+C comme sur Ctrl+D, et le seul rattrapage +vit dans `__main__` : sans capture locale, une interruption pendant une +réponse termine le CLI entier. Chaque boucle d'ici l'attrape et rend la main +au menu. + +Le dépôt n'a ni pager, ni progression sur place : la sortie s'ajoute ligne à +ligne. Une réponse longue se ferme sur une ligne de pied, jamais sur un +défilement piloté. +""" +from __future__ import annotations + +import os +import time + +import click + +from script.todo.assistant import capabilities as llm_caps +from script.todo.assistant import fingerprint as llm_fp +from script.todo.assistant import servers as llm_servers +from script.todo.todo_i18n import t + +# Les commandes que cette boucle sert. `chat.COMMANDS` en porte une de plus, +# « /gpt », qui suppose un catalogue d'outils : l'annoncer dans « /? » avant +# qu'il existe promettrait une entrée qui n'aboutit pas. +COMMANDES_PHASE_1 = ( + "/?", + "/q", + "/new", + "/gpt", + "/srv", + "/ctx", + "/m", + "/save", +) + +# Le catalogue se choisit par LETTRE. Le menu qui précède numérote ses +# entrées ; une seconde liste numérotée juste après invite à retaper un +# numéro de menu, et c'est une régression que le menu VPN a déjà payée. Un +# chiffre reste accepté comme rang, parce que le doigt vient d'en taper un. +LETTRES = "abcdefghijklmnopqrstuvwxyz" + +# Les marques de compatibilité. Seule une exigence CONTREDITE par un champ +# réellement lu sur le serveur grise ; l'inconnu et l'estimé restent +# exécutables, sans quoi le catalogue se viderait devant un serveur qui +# n'annonce rien — c'est-à-dire devant la plupart. +MARQUE = {"ok": "✅", "unknown": "⚠️", "no": "⛔"} + +# Ce qu'une description a le droit d'occuper. Le dépôt n'interroge jamais la +# largeur du terminal ; soixante-dix caractères tiennent partout, indentation +# comprise. +LARGEUR = 70 + +# Le serveur distant que le coffre sait déjà servir. Il porte une poignée comme +# les autres : c'est elle, et non son adresse, qui a le droit de circuler. +REPLI_OPENAI = llm_servers.Server( + handle="server-0", + label="OpenAI", + host="api.openai.com", + port=443, + software="openai", + model="gpt-4o", + hosting="global", + secret_ref="kdbx", +) + + +class AssistantMenuMixin: + """Le menu de l'assistant LLM : quel serveur, et la conversation.""" + + def _llm_state(self): + """L'état de la session : serveur choisi, sonde locale, destinations + déjà confirmées. + + Vit sur l'instance et meurt avec le CLI. Rien n'en descend sur le + disque : une table de qui a répondu décrit des machines que personne + n'a désignées. + """ + if getattr(self, "_llm_session", None) is None: + self._llm_session = { + "serveur": None, + "sonde": None, + "confirmes": set(), + "contextes": set(), + "gpt": None, + "gpts": None, + } + return self._llm_session + + def _llm_probe_loopback(self): + """Ce qui écoute sur la boucle locale, sondé une fois par session. + + Les onze ports se testent en quelques millisecondes : la sonde est + donc gratuite et ne mérite aucune question. Son résultat nourrit les + étiquettes du menu, pour qu'une première utilisation n'ait pas à + choisir entre configurer et abandonner. + """ + state = self._llm_state() + if state["sonde"] is not None: + return state["sonde"] + trouves = [] + for port in llm_fp.PORTS: + corps = llm_fp.collect("127.0.0.1", port, budget=0.4) + if not corps: + continue + empreinte = llm_fp.identify(corps, port=port, host="127.0.0.1") + if empreinte.software: + trouves.append((port, empreinte)) + state["sonde"] = trouves + return trouves + + def _llm_current(self): + """Le serveur en usage : celui qu'on a choisi, le premier connu, ou + celui que la boucle locale vient d'offrir. `None` quand il n'y en a + aucun, et le repli distant prend alors la question.""" + state = self._llm_state() + if state["serveur"]: + return state["serveur"] + connus = llm_servers.load(get_config=self._llm_get_config) + if connus: + state["serveur"] = connus[0] + return connus[0] + for port, empreinte in self._llm_probe_loopback(): + serveur = llm_servers.Server( + handle="server-1", + label=empreinte.software, + host="127.0.0.1", + port=port, + software=empreinte.software, + model=empreinte.models[0] if empreinte.models else "", + hosting="loopback", + secret_ref="", + ) + state["serveur"] = serveur + return serveur + return None + + def _llm_get_config(self, keys): + return self.config_file.get_config_value(keys) + + def _llm_set_config(self, keys, value): + self.config_file.set_config_value(keys, value) + + @staticmethod + def _llm_count(nombre, singulier, pluriel): + """« 1 hôte » ou « 254 hôtes » : le nombre, et le nom qui s'accorde. + + Le français comme l'anglais accordent le nom sur le nombre. Une ligne + de résumé qui annonce « 1 hôtes » se lit comme un défaut de l'outil, + et c'est la seule chose qu'on retienne de la ligne. + """ + return f"{nombre} {t(singulier if abs(nombre) == 1 else pluriel)}" + + @staticmethod + def _llm_label(serveur): + """« ollama · petit-modele:7b » — ce qui tient dans une étiquette. + + Le logiciel et le modèle suffisent à situer une destination ; l'adresse + n'y figure pas, elle appartient à la fiche du serveur. + """ + if serveur is None: + return t("no server yet") + if serveur.model: + return f"{serveur.software} · {serveur.model}" + return serveur.software + + def prompt_assistant_llm(self): + """Le sous-menu : parler à un serveur, ou décider auquel.""" + print(f"🤖 {t('A server, a gpt tool, a conversation.')}") + while True: + serveur = self._llm_current() + if serveur is None: + repli = t("no local server — via api.openai.com") + parler = f"{t('Free question')} ({repli})" + else: + parler = f"{t('Free question')} ({self._llm_label(serveur)})" + connus = llm_servers.load(get_config=self._llm_get_config) + compte = f"{len(connus)}" if connus else t("no server yet") + choices = [ + {"section": t("Talk")}, + {"prompt_description": parler}, + {"prompt_description": self._llm_gpt_label()}, + {"section": t("Server")}, + {"prompt_description": (f"{t('Known servers')} ({compte})")}, + {"prompt_description": t("Search for a server…")}, + { + "prompt_description": ( + f"{t('Server card')} ({t('what it says it can do')})" + ) + }, + ] + try: + status = click.prompt(self.fill_help_info(choices)) + except (KeyboardInterrupt, click.exceptions.Abort): + print() + return + print() + if status == "0": + return + elif status == "1": + self._llm_conversation() + elif status == "2": + self._llm_gpt_catalogue() + elif status == "3": + self._llm_servers() + elif status == "4": + self._llm_search() + elif status == "5": + self._llm_server_card() + else: + print(t("Command not found !")) + + # ------------------------------------------------------------------ + # Les serveurs + + def _llm_servers(self): + """Lister, choisir, ajouter à la main, supprimer. + + Une liste vide tombe dans la recherche plutôt que d'imprimer une + erreur : l'entrée n'a jamais de raison d'être une impasse. + """ + connus = llm_servers.load(get_config=self._llm_get_config) + if not connus: + print(t("no server yet")) + self._llm_search() + return + while True: + state = self._llm_state() + choices = [] + for serveur in connus: + marque = ( + f" ({t('in use')})" + if state["serveur"] + and state["serveur"].handle == serveur.handle + else "" + ) + choices.append( + { + "prompt_description": ( + f"{serveur.label} — {self._llm_label(serveur)}" + f"{marque}" + ) + } + ) + choices.append({"section": t("Server")}) + choices.append({"prompt_description": t("Add a server by hand")}) + choices.append({"prompt_description": t("Delete a server")}) + try: + status = click.prompt(self.fill_help_info(choices)) + except (KeyboardInterrupt, click.exceptions.Abort): + print() + return + print() + if status == "0": + return + try: + rang = int(status) + except ValueError: + print(t("Command not found !")) + continue + if 1 <= rang <= len(connus): + self._llm_state()["serveur"] = connus[rang - 1] + print(f"✅ {self._llm_label(connus[rang - 1])}") + elif rang == len(connus) + 1: + self._llm_add_server() + connus = llm_servers.load(get_config=self._llm_get_config) + elif rang == len(connus) + 2: + self._llm_delete_server(connus) + connus = llm_servers.load(get_config=self._llm_get_config) + else: + print(t("Command not found !")) + + def _llm_add_server(self): + """Saisir un hôte et un port, puis reconnaître ce qui répond. + + La reconnaissance passe par le corps de la réponse : un port ne dit + jamais quel logiciel écoute derrière lui. + """ + try: + host = click.prompt(t("Host or IP")).strip() + port = int(click.prompt(t("Port")).strip()) + except (KeyboardInterrupt, click.exceptions.Abort): + print() + return + except ValueError: + print(t("Command not found !")) + return + corps = llm_fp.collect(host, port, budget=2.0) + empreinte = llm_fp.identify(corps, port=port, host=host) + if not empreinte.software: + print(f"⚠ {t('Answered, not identified')}") + label = click.prompt(t("Name for this server")).strip() or host + connus = llm_servers.load(get_config=self._llm_get_config) + connus.append( + llm_servers.Server( + handle="", + label=label, + host=host, + port=port, + software=empreinte.software or "", + model=empreinte.models[0] if empreinte.models else "", + hosting=llm_caps.classify_hosting(host), + secret_ref="", + ) + ) + llm_servers.save( + llm_servers.assign_handles(connus), + set_config=self._llm_set_config, + ) + print(f"✅ {label}") + + def _llm_delete_server(self, connus): + """Supprimer un serveur, son nom retapé en entier. + + Une frappe sur « o » se donne par réflexe ; recopier un nom oblige à + regarder ce qu'on retire. + """ + noms = [s.label for s in connus] + for rang, nom in enumerate(noms, 1): + print(f"[{rang}] {nom}") + try: + choisis = self._parse_index_selection( + click.prompt(t("Delete a server")), noms + ) + if not choisis: + return + frappe = click.prompt( + t("Type the server name in full to delete it:") + ).strip() + except (KeyboardInterrupt, click.exceptions.Abort): + print() + return + if frappe != choisis[0]: + print(t("Destination not retyped — nothing was sent.")) + return + restants = [s for s in connus if s.label != choisis[0]] + llm_servers.save( + llm_servers.assign_handles(restants), + set_config=self._llm_set_config, + ) + state = self._llm_state() + if state["serveur"] and state["serveur"].label == choisis[0]: + state["serveur"] = None + + def _llm_search(self): + """Où chercher un serveur. + + La question se pose ICI, au moment où l'on cherche, et non à l'entrée + du menu : une première utilisation doit pouvoir répondre sans avoir + rien à configurer. + + Les réseaux détectés sont LISTÉS, jamais devinés. Une machine porte + souvent deux /24 — celui qui sort et celui d'un pont de + virtualisation — et « le réseau local » ne désigne alors rien de + précis ; le pont est signalé comme tel et le choix reste entier. + """ + from script.todo.assistant import discover as llm_disc + + while True: + reseaux = llm_disc.local_networks() + choices = [ + { + "prompt_description": ( + f"{t('Here (127.0.0.1)')}" + f" ({t('11 ports, instant')})" + ) + }, + { + "prompt_description": t( + "The QEMU VMs of this machine (virsh)" + ) + }, + {"prompt_description": t("The hosts of ~/.ssh/config")}, + ] + for interface in reseaux: + pont = ( + f" ({t('libvirt bridge')})" if interface.is_bridge else "" + ) + choices.append( + { + "prompt_description": ( + f"{t('local network of this machine')}" + f" {interface.cidr}" + f" · {interface.name}{pont}" + ) + } + ) + choices.append({"prompt_description": t("An address I type")}) + choices.append( + {"prompt_description": t("A network I type (CIDR)")} + ) + choices.append( + {"prompt_description": t("The networks of a machine over SSH")} + ) + print(t("Where should I look for a server?")) + try: + status = click.prompt(self.fill_help_info(choices)) + except (KeyboardInterrupt, click.exceptions.Abort): + print() + return + print() + if status == "0": + return + try: + rang = int(status) + except ValueError: + print(t("Command not found !")) + continue + if rang == 1: + self._llm_state()["sonde"] = None + self._llm_probe_and_keep(["127.0.0.1"]) + elif rang == 2: + self._llm_search_qemu() + elif rang == 3: + self._llm_search_ssh() + elif 4 <= rang <= 3 + len(reseaux): + self._llm_search_network(reseaux[rang - 4]) + elif rang == 4 + len(reseaux): + self._llm_add_server() + elif rang == 5 + len(reseaux): + self._llm_search_cidr() + elif rang == 6 + len(reseaux): + self._llm_search_remote() + else: + print(t("Command not found !")) + + def _llm_search_qemu(self): + """Les VM libvirt de la machine comme cibles. + + Le résolveur d'adresse est celui qui ne PATIENTE pas : celui qui + attend tient le menu jusqu'à dix minutes par VM, et une VM éteinte + suffit à le déclencher. + """ + from script.todo.assistant import discover as llm_disc + + vms = llm_disc.qemu_hosts( + list_domains=self._qemu_list_domains, + vm_ip=self._qemu_vm_ip_now, + ) + if not vms: + print(t("libvirt answers, no VM defined")) + return + adresses = [] + for nom, adresse in vms: + if adresse: + adresses.append(adresse) + else: + print( + f" {nom} —" + f" {t('Host without an address — listed as unknown')}" + ) + if adresses: + self._llm_probe_and_keep(adresses) + + def _llm_search_ssh(self): + """Les machines déjà connues de la configuration SSH comme cibles. + + Une entrée sans `HostName` n'est jamais écartée : `ssh -G` rend alors + l'alias comme nom d'hôte, et le DNS ou /etc/hosts le résout souvent. + """ + from script.todo.assistant import discover as llm_disc + + hotes = llm_disc.ssh_hosts( + list_aliases=self._ssh_config_hosts, + resolve=self._ssh_resolve, + ) + if not hotes: + print(t("~/.ssh/config absent — nothing to probe")) + return + self._llm_probe_and_keep([hote for _, hote, _ in hotes]) + + def _llm_search_cidr(self): + """Balayer un réseau que la machine ne porte pas. + + Les réseaux proposés sont ceux que les interfaces portent. Or un + serveur vit souvent AILLEURS, derrière la passerelle : quand le CLI + tourne dans une VM, le « réseau local » qu'il voit est celui de + l'hyperviseur, et le vrai parc est hors-lien. Rien d'autre dans ce + menu n'atteint ce cas — la saisie d'une adresse ne prend qu'un hôte, + et la table de voisinage est link-local, donc elle ne connaîtra + jamais un hôte routé. + """ + try: + cidr = click.prompt(t("Network in CIDR form")).strip() + except (KeyboardInterrupt, click.exceptions.Abort): + print() + return + self._llm_sweep_cidr(cidr) + + def _llm_search_remote(self): + """Lire les réseaux d'une machine joignable en SSH, et en balayer un. + + La machine du dessus porte les bons préfixes quand celle-ci n'en voit + que ceux de son hyperviseur. Les réseaux se LISENT là-bas et se + balayent D'ICI : la table de routage locale décide de l'accès, et une + route par défaut suffit d'ordinaire. L'hôte distant n'a besoin que + d'un accès en lecture, et rien n'est balayé depuis lui. + """ + from script.todo.assistant import discover as llm_disc + + try: + alias = click.prompt(t("Host reachable over SSH")).strip() + except (KeyboardInterrupt, click.exceptions.Abort): + print() + return + if not alias: + return + print(f" {t('Reading the networks it carries…')}", flush=True) + reseaux = llm_disc.remote_networks(alias) + if not reseaux: + print(f"⚠ {t('That host did not answer, or carries no network.')}") + return + choices = [] + for interface in reseaux: + pont = f" ({t('libvirt bridge')})" if interface.is_bridge else "" + choices.append( + { + "prompt_description": ( + f"{interface.cidr} · {interface.name}{pont}" + ) + } + ) + print(t("read on %s, swept from here") % alias) + try: + status = click.prompt(self.fill_help_info(choices)) + except (KeyboardInterrupt, click.exceptions.Abort): + print() + return + print() + if status == "0": + return + try: + rang = int(status) + except ValueError: + print(t("Command not found !")) + return + if 1 <= rang <= len(reseaux): + self._llm_sweep_cidr(reseaux[rang - 1].cidr) + else: + print(t("Command not found !")) + + def _llm_search_network(self, interface): + """Balayer un réseau porté par une interface.""" + self._llm_sweep_cidr(interface.cidr) + + def _llm_sweep_cidr(self, cidr): + """Confirmer, puis balayer le réseau nommé. + + C'est la seule action de ce menu qui atteigne des machines que + personne n'a désignées, d'où une confirmation qui nomme le réseau et + les comptes exacts. + + Le défaut est le réseau ENTIER, et non les hôtes que la table de + voisinage dit avoir déjà parlé. Rétrécir par défaut paraissait plus + prudent, et se retourne : la table ne porte souvent que la + passerelle, le balayage se réduit alors à une adresse, et le résumé + annonce un /24 vide là où une seule adresse a été vue. Un faux + négatif présenté comme un fait coûte plus cher que les connexions + épargnées. La confirmation nomme le compte, donc le consentement est + informé dans les deux sens. + + La lettre « v » restreint aux hôtes déjà vus, pour un réseau chargé + où l'on cherche vite. Une lettre plutôt qu'un troisième numéro : un + numéro juste après un menu numéroté invite à retaper une entrée de + menu. + """ + from script.todo.assistant import discover as llm_disc + + jobs = llm_disc.plan_sweep(cidr, skip=self._qemu_host_addresses()) + if not jobs: + print(f"⚠ {t('Wider than a /24 is refused.')}") + print( + t( + "The /24 is an assumption: a prefix does not follow from" + " an address." + ) + ) + return + toutes = sorted({ip for ip, _ in jobs}) + voisins = set(llm_disc.neigh_hosts(llm_disc.run_ip(["neigh"]))) + deja_vus = [adresse for adresse in toutes if adresse in voisins] + print( + f"⚠ {t('Sweeping the network reaches machines you did not name.')}" + ) + question = t("Sweep %s addresses × %s ports on %s?") % ( + len(toutes), + len(llm_fp.PORTS), + cidr, + ) + rappel = ( + f", « v » = {self._llm_count(len(deja_vus), 'host', 'hosts')}" + if deja_vus + else "" + ) + try: + reponse = click.prompt(f"{question} (o/N{rappel})") + except (KeyboardInterrupt, click.exceptions.Abort): + print() + return + cibles = toutes + restreint = False + if reponse.strip().lower() == "v" and deja_vus: + cibles = deja_vus + restreint = True + elif not self._is_yes(reponse): + return + self._llm_probe_and_keep(cibles, cible=cidr, restreint=restreint) + + @staticmethod + def _llm_sweep_tuning(): + """Les réglages du balayage, lus dans les préférences. + + Le menu les lit et les passe ; le module de découverte ne connaît pas + les préférences, ce qui le laisse testable sans le disque. Une valeur + illisible ou hors bornes retombe sur le défaut du module plutôt que de + propager un réglage qui fabriquerait des faux négatifs. + """ + from script.todo import todo_prefs + + reglages = {} + try: + ouvriers = int(todo_prefs.get("assistant_sweep_workers")) + if ouvriers > 0: + reglages["workers"] = ouvriers + except (TypeError, ValueError): + pass + try: + delai = float(todo_prefs.get("assistant_sweep_timeout")) + if delai > 0: + reglages["timeout"] = delai + except (TypeError, ValueError): + pass + return reglages + + def _llm_sweep_printer(self): + """L'imprimeur d'événements du balayage. + + Les touches seulement, plus un battement : une ligne par hôte mort + ferait 254 lignes de rien, et le silence d'un balayage de plusieurs + secondes se lit comme un blocage. + """ + + def imprimer(event): + genre = event[0] + if genre == "hit": + print(f" ✓ {event[1]}:{event[2]}") + elif genre == "heartbeat": + print( + f" ⏳ {event[1]}/{event[2]} {t('ports')}" + f" ({self._fmt_dur(event[3])})" + ) + + return imprimer + + def _llm_probe_and_keep(self, adresses, *, cible=None, restreint=False): + """Frapper, reconnaître, puis proposer de garder. + + Le balayage n'ouvre que des connexions ; la reconnaissance, elle, + coûte une requête HTTP par étage et ne part donc QUE vers les hôtes + qui ont accepté. C'est ce qui rend un /24 abordable. + + `restreint` dit que les adresses sont un SOUS-ENSEMBLE de ce que + `cible` nomme. L'absence de trouvaille se dit alors autrement : un + réseau dont on n'a vu qu'une adresse n'est pas un réseau vide, et + l'annoncer comme tel est un faux négatif. + """ + from script.todo.assistant import discover as llm_disc + + adresses = list(dict.fromkeys(adresses)) + jobs = [ + (adresse, port) for adresse in adresses for port in llm_fp.PORTS + ] + etiquette = cible or ", ".join(adresses[:3]) + combien = self._llm_count(len(adresses), "host", "hosts") + combien_ports = self._llm_count(len(llm_fp.PORTS), "port", "ports") + # Vidée avant que la piscine démarre : un balayage silencieux de + # plusieurs secondes se lit comme un blocage, et l'en-tête est ce qui + # dit ce qu'on attend et comment l'interrompre. + print( + f"🔎 {etiquette} · {combien} × {combien_ports}" + f" · {t('Ctrl+C interrupts')}", + flush=True, + ) + debut = time.monotonic() + try: + touches = llm_disc.sweep( + jobs, + on_event=self._llm_sweep_printer(), + **self._llm_sweep_tuning(), + ) + except KeyboardInterrupt: + print(f"\n⏹ {t('answer interrupted')}") + return + duree = time.monotonic() - debut + trouves = [] + for adresse, port in touches: + corps = llm_fp.collect(adresse, port, budget=2.0) + empreinte = llm_fp.identify(corps, port=port, host=adresse) + if not empreinte.software: + print(f" ⚠ {adresse}:{port} {t('Answered, not identified')}") + continue + print( + f" → {adresse}:{port} · {empreinte.software}" + f" {empreinte.version} ·" + f" {self._llm_count(len(empreinte.models), 'model', 'models')}" + ) + trouves.append((adresse, port, empreinte)) + if not trouves: + print( + f"⚠ " + + t("No server on %s (%s, %s, %ss).") + % ( + etiquette, + self._llm_count(len(adresses), "host", "hosts"), + self._llm_count(len(llm_fp.PORTS), "port", "ports"), + f"{duree:.0f}", + ) + ) + if restreint: + print(f" ⚠ {t('Only part of that network was swept.')}") + self._llm_nothing_found() + return + mot = ( + t("server recognized") + if len(trouves) == 1 + else t("servers recognized") + ) + print( + f" {len(trouves)} {mot}," + f" {self._llm_count(len(adresses), 'host swept', 'hosts swept')}" + f" ({self._fmt_dur(duree)})" + ) + self._llm_keep(trouves) + + def _llm_keep(self, trouves): + """Proposer de garder ce qui a été reconnu. + + Seuls les serveurs RETENUS descendent sur le disque. Aucun rapport de + balayage, aucune table de vivacité, aucun résultat négatif : la liste + de qui a répondu parmi 254 adresses décrit des machines que personne + n'a nommées, et elle est plus sensible que le serveur voulu. + """ + try: + reponse = click.prompt(f"{t('Keep them all')} (o/N)") + except (KeyboardInterrupt, click.exceptions.Abort): + print() + return + if not self._is_yes(reponse): + print(t("nothing kept")) + return + connus = llm_servers.load(get_config=self._llm_get_config) + for adresse, port, empreinte in trouves: + connus.append( + llm_servers.Server( + handle="", + label=f"{empreinte.software} ({adresse}:{port})", + host=adresse, + port=port, + software=empreinte.software, + model=empreinte.models[0] if empreinte.models else "", + hosting=llm_caps.classify_hosting(adresse), + secret_ref="", + ) + ) + llm_servers.save( + llm_servers.assign_handles(connus), + set_config=self._llm_set_config, + ) + print(f"✅ {len(trouves)} {t('kept')}") + + def _llm_nothing_found(self): + """Les suites concrètes, pour qu'un balayage vide ne soit pas une + impasse. Le repli distant en est une : il répond aujourd'hui. + + En prose, et sans crochets numérotés : la question « où chercher » + revient juste après avec sa propre numérotation, et deux séries de + numéros qui ne se correspondent pas font retaper le mauvais. + """ + print( + f" {t('Look somewhere else')} · {t('Type an address')}" + f" · {t('Carry on with the OpenAI API (key from the vault)')}" + ) + print( + f" 💡 {t('A local server: \"ollama serve\" listens on 11434.')}" + ) + + def _llm_server_card(self): + """Ce que le serveur en usage annonce savoir faire. + + Une capacité qu'il n'annonce pas s'affiche comme inconnue, jamais + comme absente : la plupart des serveurs n'annoncent rien, et confondre + les deux ferait passer un silence pour un refus. + """ + serveur = self._llm_current() + if serveur is None: + print(t("no server yet")) + return + print(f" {serveur.label} — {serveur.host}:{serveur.port}") + print(f" {t(self._llm_hosting_key(serveur.hosting))}") + empreinte = llm_fp.identify( + llm_fp.collect(serveur.host, serveur.port, budget=2.0), + port=serveur.port, + host=serveur.host, + ) + caps = llm_caps.read(empreinte, serveur.host, serveur.port) + for nom in ( + "context_window", + "parameters", + "tool_calling", + "vision", + "json_output", + ): + valeur = getattr(caps, nom) + marque = " (?)" if valeur is None else "" + print(f" {nom}: {valeur}{marque}") + + @staticmethod + def _llm_hosting_key(hosting): + """La clé i18n qui nomme une classe d'hébergement.""" + return { + "loopback": "this machine", + "lan": "local network", + "global": "third party", + }.get(hosting, "third party") + + # ------------------------------------------------------------------ + # Les sessions Claude Code de la machine + + def prompt_claude_sessions(self): + """Voir les sessions locales, en interroger une, ou la reprendre. + + Sous « GPT code » et non sous le sous-menu LLM : une session est un + processus adressé par identifiant, un serveur est un hôte adressé par + port. Les mêler dans une seule liste numérotée ferait partager cinq + numéros à deux modèles mentaux, alors que toutes les entrées Claude + vivent déjà ici. + """ + # L'emoji vit dans la valeur traduite, jamais dans le code : le + # mettre aux deux endroits en imprime deux. + print(t("Claude Code - local sessions")) + while True: + flotte = self._claude_flotte() + vivantes = sum(1 for session in flotte if session.live) + compte = ( + f"{len(flotte)} · {vivantes} {t('live')}" + if flotte + else t("No session on this machine.") + ) + choices = [ + { + "prompt_description": ( + f"{t('List local sessions')} ({compte})" + ) + }, + {"prompt_description": t("Ask a question to a session")}, + { + "prompt_description": t( + "Resume a session in a new terminal" + ) + }, + ] + try: + status = click.prompt(self.fill_help_info(choices)) + except (KeyboardInterrupt, click.exceptions.Abort): + print() + return + print() + if status == "0": + return + elif status == "1": + self._claude_lister(flotte) + elif status == "2": + self._claude_questionner(flotte) + elif status == "3": + self._claude_reprendre(flotte) + else: + print(t("Command not found !")) + + def _claude_flotte(self): + """La flotte, relue à chaque tour du menu. + + Relue et non gardée : une session démarre ou s'arrête dans un autre + terminal pendant qu'on regarde la liste, et une liste périmée + proposerait d'écrire dans un processus qui n'est plus là. + """ + from script.todo.assistant import claude_sessions as cs + + return cs.fleet() + + def _claude_lister(self, flotte): + """Afficher la flotte, sans rien lire d'une transcription. + + Ce qui paraît vient du registre, que tout compte de la machine peut + déjà lire. Le titre d'une session, lui, vit dans la transcription, et + celle-ci est sous un répertoire que le système ferme à son + propriétaire : cette frontière n'est pas à rouvrir pour décorer une + liste. + """ + from script.todo.assistant import claude_sessions as cs + + if not flotte: + print(t("No session on this machine.")) + return + for rang, session in enumerate(flotte, 1): + vue = cs.displayable(session) + if vue["live"]: + etat = ( + f"{vue['kind']} · {t(vue['status'] or 'idle')}" + f" · {t('held by pid %s') % vue['pid']}" + ) + else: + etat = t("resumable, not running") + print(f" [{rang}] {vue['id']} {etat}") + print( + f" {vue['dir']}" + f"{' ' + vue['branch'] if vue['branch'] else ''}" + f"{' ' + vue['version'] if vue['version'] else ''}" + ) + + def _claude_choisir(self, flotte): + """La session désignée par un rang, ou `None`.""" + if not flotte: + print(t("No session on this machine.")) + return None + self._claude_lister(flotte) + try: + reponse = click.prompt(t("Choice")).strip() + except (KeyboardInterrupt, click.exceptions.Abort): + print() + return None + if not reponse.isdigit(): + return None + rang = int(reponse) - 1 + return flotte[rang] if 0 <= rang < len(flotte) else None + + def _claude_questionner(self, flotte): + """Poser UNE question à une session, sans ouvrir de terminal. + + Deux garde-fous, et le second vient d'une mesure. L'invite part sur + l'entrée standard : la ligne de commande d'un processus est lisible + par tout compte de la machine, et une question porte du contexte. + + Et l'outil ne REFUSE pas de reprendre une session qu'un terminal + tient : son garde-fou écarte délibérément les détenteurs interactifs. + Deux écritures simultanées scindent alors la transcription, et une + branche est perdue de la continuation. Une copie est donc branchée par + défaut, et écrire dans la session tenue exige de retaper le pid du + détenteur — recopier un nombre oblige à regarder ce qu'on fait. + """ + import shutil + + from script.todo.assistant import backends as llm_backends + from script.todo.assistant import claude_sessions as cs + + if not shutil.which("claude"): + print(t("claude is not on the PATH.")) + return + session = self._claude_choisir(flotte) + if session is None: + return + fork = True + detenteur = cs.held_by(session) + if detenteur: + avis = t("This session is open elsewhere. A branch would be lost.") + print(f"⚠ {avis}") + print(f" [1] {t('Branch a copy (recommended)')}") + print(f" [2] {t('Write into the held session')}") + try: + choix = click.prompt(t("Choice")).strip() + except (KeyboardInterrupt, click.exceptions.Abort): + print() + return + if choix == "2": + try: + frappe = click.prompt( + t("Type the pid of the holder to write into it:") + ).strip() + except (KeyboardInterrupt, click.exceptions.Abort): + print() + return + if frappe != detenteur: + print(t("Nothing has been sent.")) + return + fork = False + elif choix != "1": + return + try: + question = click.prompt(t("Write your question ")) + except (KeyboardInterrupt, click.exceptions.Abort): + print() + return + print(f" {t('read-only: Read, Glob, Grep')}") + backend = llm_backends.ClaudeCliBackend( + session_id=session.session_id, cwd=session.cwd, fork=fork + ) + try: + texte, faits = backend.send( + [{"role": "user", "content": question}] + ) + except Exception as panne: + print(f"⚠ {panne}") + return + print(texte) + cout = faits.get("cost_usd") or faits.get("total_cost_usd") + if cout: + print(f"── {cout} USD ──") + + def _claude_reprendre(self, flotte): + """Reprendre une session dans sa propre fenêtre. + + Une session interactive est un programme plein écran : elle a besoin + d'un vrai terminal, que le tube du lanceur ordinaire ne fournit pas. + Sans fenêtre possible — ni gnome-terminal, ni son équivalent — la + commande est IMPRIMÉE plutôt que lancée sur un tube où elle ne + survivrait pas. + """ + import shlex + import shutil + + chemin = shutil.which("claude") + if not chemin: + print(t("claude is not on the PATH.")) + return + session = self._claude_choisir(flotte) + if session is None: + return + commande = ( + f"{shlex.quote(chemin)} --resume" + f" {shlex.quote(session.session_id)}" + ) + if not getattr(self.execute, "cmd_source_default", ""): + print(t("No terminal can be opened here. Paste this command:")) + print(f" cd {shlex.quote(session.cwd)} && {commande}") + return + self.execute.exec_command_live( + f"cd {shlex.quote(session.cwd)} && {commande}", + source_erplibre=False, + new_window=True, + ) + + # ------------------------------------------------------------------ + # Le catalogue d'outils gpt + + def _llm_gpts(self): + """Le catalogue, chargé une fois par session, avec ses problèmes. + + Chargé une seule fois parce que la liste des fichiers ne change pas + pendant qu'on parle, et qu'un rechargement à chaque affichage relirait + le disque pour rien. + """ + state = self._llm_state() + if state.get("gpts") is None: + from script.todo.assistant import gpt as llm_gpt + + state["gpts"], state["gpt_problemes"] = llm_gpt.load_all() + return state["gpts"], state["gpt_problemes"] + + def _llm_gpt_label(self): + """L'étiquette de l'entrée du catalogue, avec ses comptes. + + Le nombre de compatibles se dit dès le menu : ouvrir un catalogue pour + y découvrir que rien ne convient est une visite perdue. + """ + gpts, problemes = self._llm_gpts() + if not gpts: + fatals = [souci for souci in problemes if souci.fatal] + if fatals: + return f"{t('gpt tools')} ({len(fatals)} ⚠)" + return f"{t('gpt tools')} ({t('no gpt tool yet')})" + compatibles = sum( + 1 for _, verdict, _ in self._llm_apparier(gpts) if verdict == "ok" + ) + return ( + f"{t('gpt tools')} ({len(gpts)}," + f" {compatibles} {t('compatible')})" + ) + + def _llm_apparier(self, gpts): + """[(gpt, verdict, raison)] — chaque outil confronté au serveur. + + Les capacités sont lues UNE fois par session et par serveur : chaque + lecture coûte une requête au serveur, et la réponse ne change pas + entre deux affichages du même catalogue. + """ + from script.todo.assistant import capabilities as caps_mod + + serveur = self._llm_current() or REPLI_OPENAI + state = self._llm_state() + cle = (serveur.host, serveur.port, serveur.model) + if state.get("caps_cle") != cle: + from script.todo.assistant import fingerprint as fp_mod + + empreinte = fp_mod.identify( + fp_mod.collect(serveur.host, serveur.port, budget=2.0), + port=serveur.port, + host=serveur.host, + ) + state["caps"] = caps_mod.read( + empreinte, serveur.host, serveur.port + ) + state["caps_cle"] = cle + caps = state["caps"] + hosting = serveur.hosting + return [ + (outil,) + caps_mod.match(outil.requires, caps, hosting) + for outil in gpts + ] + + def _llm_gpt_catalogue(self): + """Choisir un outil, par lettre. + + Une lettre plutôt qu'un numéro : le menu qui précède numérote ses + entrées, et une seconde liste numérotée juste après fait retaper un + numéro de menu. Un chiffre reste accepté comme rang, parce que le + doigt vient d'en taper un. + + Un outil ⛔ imprime sa raison et re-demande : il n'est jamais avalé en + silence, et jamais lancé. + """ + gpts, problemes = self._llm_gpts() + fatals = [souci for souci in problemes if souci.fatal] + if not gpts: + for souci in fatals: + self._llm_dire_probleme(souci) + if not fatals: + print(t("no gpt tool yet")) + return + while True: + appariement = self._llm_apparier(gpts) + serveur = self._llm_current() or REPLI_OPENAI + print(f"{t('Which gpt tool?')} {self._llm_label(serveur)}") + for rang, (outil, verdict, raison) in enumerate(appariement): + marque = MARQUE.get(verdict, "") + # Le nom sur sa ligne, la description en dessous : un nom de + # gpt est une phrase, et les deux bout à bout dépassent la + # largeur d'un terminal — une entrée qui s'enroule se lit + # moins bien que deux lignes assumées. + print(f" [{LETTRES[rang]}] {marque} {t(outil.name)}") + print(f" {t(outil.description)[:LARGEUR]}") + print(f" [0] {t('Back')}") + if any(v != "ok" for _, v, _ in appariement): + print( + f" ⚠️ {t('a requirement could not be checked')}" + f" · ⛔ {t('a requirement is contradicted')}" + ) + if fatals: + print( + f" ⚠ {len(fatals)} {t('unreadable gpt files')}" + f" — [d] {t('details')}" + ) + try: + reponse = click.prompt(t("Choice")).strip().lower() + except (KeyboardInterrupt, click.exceptions.Abort): + print() + return + if reponse in ("0", ""): + return + if reponse == "d" and fatals: + for souci in problemes: + self._llm_dire_probleme(souci) + continue + rang = self._llm_rang(reponse, len(appariement)) + if rang is None: + print(t("Command not found !")) + continue + outil, verdict, raison = appariement[rang] + if verdict == "no": + print(f" ⛔ {self._llm_raison(outil, raison)}") + continue + self._llm_state()["gpt"] = outil + print(f"✅ {t(outil.name)}") + self._llm_conversation() + return + + @staticmethod + def _llm_rang(reponse, combien): + """Le rang désigné par une lettre ou par un chiffre, sinon `None`.""" + if len(reponse) == 1 and reponse in LETTRES: + rang = LETTRES.index(reponse) + return rang if rang < combien else None + if reponse.isdigit(): + rang = int(reponse) - 1 + return rang if 0 <= rang < combien else None + return None + + def _llm_raison(self, outil, raison): + """La raison d'un refus, ses trous remplis. + + Les clés portent des `%s` que seul l'appelant peut remplir : lui seul + tient à la fois l'exigence déclarée et ce que le serveur annonce. + """ + modele = t(raison) + if "%s" not in modele: + return modele + caps = self._llm_state().get("caps") + serveur = self._llm_current() or REPLI_OPENAI + for champ in ("context_window", "parameters"): + if champ in raison: + return modele % ( + outil.requires.get(champ), + getattr(caps, champ, None), + ) + return modele % (t(self._llm_hosting_key(serveur.hosting)),) + + @staticmethod + def _llm_dire_probleme(souci): + """Un problème de chargement, traduit, avec son détail brut.""" + nom = f"{souci.stem} : " if souci.stem else "" + detail = f" {souci.detail}" if souci.detail else "" + print(f" ⚠ {nom}{t(souci.key)}{detail}") + + # ------------------------------------------------------------------ + # La porte du contexte déclaré + + def _llm_demander_entrees(self, outil): + """Les entrées déclarées, demandées une par une. `None` si l'on sort. + + Une valeur saisie n'est PAS validée ici : elle est substituée dans la + commande, et c'est le contrôle du contexte qui la voit ensuite, avec + la liste d'autorisation et le refus des métacaractères. Valider deux + fois inviterait à valider différemment. + """ + valeurs = {} + for entree in outil.inputs: + nom = entree.get("name") + defaut = entree.get("default") or "" + invite = f"{nom}" + (f" [{defaut}]" if defaut else "") + try: + donnee = click.prompt(invite, default=defaut).strip() + except (KeyboardInterrupt, click.exceptions.Abort): + print() + return None + if not donnee and entree.get("required"): + print(t("Nothing has been sent.")) + return None + valeurs[nom] = donnee + return valeurs + + def _llm_context_gate(self, outil, serveur): + """Assembler le contexte déclaré, le montrer, et demander. + + Rend le texte à joindre, ou `None` quand rien ne doit partir. + + La confirmation vaut pour la session et pour ce couple (outil, + serveur) : la re-demander à chaque tour ferait cliquer sans lire, ce + qui est le contraire de ce qu'une porte sert à obtenir. Un tiers est + la seule exception — là, il n'y a pas de rattrapage. + """ + from script import lib_identifiant + from script.todo.assistant import context as ctx + + sources = outil.context or {} + if not sources.get("files") and not sources.get("commands"): + return "" + entrees = self._llm_demander_entrees(outil) + if entrees is None: + return None + termes = lib_identifiant.termes_interdits() + texte, trouvailles = ctx.assemble( + files=sources.get("files") or (), + commands=sources.get("commands") or (), + inputs=entrees, + termes=termes, + ) + verdict, cle = ctx.gate( + trouvailles, serveur.hosting, names_checkable=bool(termes) + ) + if verdict == ctx.BLOQUER: + print(f"⛔ {t(cle)}") + return None + state = self._llm_state() + sceau = (outil.stem, serveur.host, serveur.port) + if verdict == ctx.OK and sceau in state["contextes"]: + return texte + if not self._llm_montrer_contexte(texte, trouvailles, cle): + return None + state["contextes"].add(sceau) + return texte + + def _llm_montrer_contexte(self, texte, trouvailles, cle): + """Montrer ce qui va partir, et demander. Vrai si l'on continue. + + Ce que la porte montre est ce qui décide : la taille, la tête, la + queue, et chaque trouvaille nommée par sa source. Un aperçu qu'on ne + peut pas relire ne vaut pas mieux qu'aucun aperçu. + """ + lignes = texte.splitlines() + print(f"── {t('What is about to be sent')} ──") + print(f" {len(texte)} {t('characters')}, {len(lignes)} {t('lines')}") + for ligne in lignes[:6]: + print(f" {ligne[:100]}") + if len(lignes) > 12: + print(f" … {len(lignes) - 12} …") + for ligne in lignes[-6:] if len(lignes) > 12 else []: + print(f" {ligne[:100]}") + if trouvailles: + print(f" ⚠ {len(trouvailles)} {t(cle)}") + for trouvaille in trouvailles[:8]: + print( + f" {trouvaille['source']} · {trouvaille['motif']}" + f" · {trouvaille['extrait']}" + ) + print(f" {t('What the filter checks')}") + try: + reponse = click.prompt(f"[c] {t('continue')} · [0] {t('cancel')}") + except (KeyboardInterrupt, click.exceptions.Abort): + print() + return False + if reponse.strip().lower() != "c": + print(t("Nothing has been sent.")) + return False + return True + + # ------------------------------------------------------------------ + # La conversation + + def _llm_openai_key(self): + """La clé du coffre, ou une chaîne vide quand il n'en porte aucune. + + La clé reste en mémoire du processus : /proc expose la ligne de + commande de chaque processus à tout compte de la machine, et un + `redact_secrets` qui ne reconnaît pas « API_KEY » ne la masquerait pas + non plus dans une trace. + """ + kp = self.kdbx_manager.get_kdbx() + if not kp: + return "" + titre = self.config_file.get_config_value( + ["kdbx_config", "openai", "kdbx_key"] + ) + entree = kp.find_entries_by_title(titre, first=True) + return getattr(entree, "password", "") or "" + + def _llm_confirm_third_party(self, serveur): + """Retaper la destination avant le premier envoi vers un tiers. + + Une frappe sur « o » se donne par réflexe ; recopier l'adresse oblige + à regarder où part le texte. La confirmation vaut pour la session et + pour cette destination seulement. + """ + if serveur.hosting != "global": + return True + state = self._llm_state() + if serveur.host in state["confirmes"]: + return True + avis = t("This destination is a third party. Retype it to confirm:") + print(f"⚠ {avis}") + try: + frappe = click.prompt(serveur.host).strip() + except (KeyboardInterrupt, click.exceptions.Abort): + print() + return False + if frappe != serveur.host: + print(t("Destination not retyped — nothing was sent.")) + return False + state["confirmes"].add(serveur.host) + return True + + def _llm_conversation(self): + """La boucle de conversation. + + L'invite d'une ligne EST la ligne d'état : elle porte le serveur et le + modèle, elle est réimprimée par la lecture à chaque tour, et elle ne + peut donc pas défiler hors de l'écran. Le bloc d'en-tête, lui, ne + s'imprime qu'une fois. + """ + from script.todo.assistant import backends as llm_backends + from script.todo.assistant import chat as llm_chat + + self._llm_quiet_http() + serveur = self._llm_current() + cle = "" + if serveur is None: + serveur = REPLI_OPENAI + cle = self._llm_openai_key() + if not cle: + print( + t( + "The vault holds no OpenAI key: configure a server or" + " a key." + ) + ) + return + print(f"↑ {t('via api.openai.com (key from the vault)')}") + elif serveur.secret_ref: + cle = self._llm_openai_key() + if not self._llm_confirm_third_party(serveur): + return + print( + t( + "The history lives in memory and dies with this menu. /save" + " writes it to a file." + ) + ) + print(t("Commands start with a slash. /? lists them.")) + outil = self._llm_state().get("gpt") + systeme = "" + if outil is not None: + joint = self._llm_context_gate(outil, serveur) + if joint is None: + # La porte a refusé, ou l'utilisateur a annulé : rien ne part, + # et la conversation ne s'ouvre pas avec un contexte amputé. + return + systeme = "\n\n".join( + part for part in (outil.system, joint) if part + ) + backend = llm_backends.HttpBackend( + serveur, + serveur.model, + api_key=cle or None, + params=dict(outil.params) if outil is not None else None, + ) + conversation = llm_chat.Conversation(backend, system=systeme) + # Le RADICAL du nom de fichier, et non le nom traduit : celui-ci est + # une phrase, et l'invite d'état est réimprimée à chaque tour. Un + # radical est court, stable, et désigne le fichier sans ambiguïté. + marque_outil = f" · {outil.stem}" if outil is not None else "" + invite = ( + f"{self._llm_label(serveur)}{marque_outil}" + f" · {t(self._llm_hosting_key(serveur.hosting))} ▸ " + ) + while True: + try: + ligne = input(invite) + except (KeyboardInterrupt, click.exceptions.Abort, EOFError): + # Ctrl+C et Ctrl+D rendent la main au menu. Sans cette + # capture, `Abort` remonterait jusqu'à `__main__`, qui termine + # le CLI entier. + print() + return + commande, reste = llm_chat.parse_command(ligne) + if commande == "/q": + return + if commande == "/?": + for nom in COMMANDES_PHASE_1: + print(f" {nom} {t(llm_chat.COMMANDS[nom])}") + continue + if commande == "/new": + jetes = conversation.reset() + print(f" {jetes} {t('turns dropped')}") + continue + if commande == "/gpt": + self._llm_gpt_catalogue() + return + if commande == "/srv": + self._llm_servers() + return + if commande == "/ctx": + for message in conversation.last_sent: + print(f" [{message['role']}] {message['content']}") + continue + if commande == "/save": + self._llm_save(conversation) + continue + if commande == "/m": + reste = self._llm_multiline() + elif commande is not None: + print(t("Command not found !")) + continue + if not reste.strip(): + continue + tour = conversation.ask(reste) + if tour.role == "error": + print(f"⚠ {tour.text}") + continue + print(tour.text) + if tour.interrupted: + print(f"⏹ {t('answer interrupted')}") + # Le pied de ligne existe pour une réponse qui a défilé : il dit + # sa longueur et où l'écrire. Sous deux lignes, il n'apprend rien + # et le pluriel sonnerait faux. + lignes = len(tour.text.splitlines()) + if lignes > 1: + print( + f"── {lignes} {t('lines')} ·" + f" {t('/save to write it to a file')} ──" + ) + + @staticmethod + def _llm_quiet_http(): + """Retirer la ligne de journal que le client HTTP écrit par requête. + + `todo.py` pose un gestionnaire sur le logger RACINE à l'import, et + `httpx` journalise chaque requête en INFO : sans ceci, « HTTP Request: + POST … 200 OK » s'imprime au-dessus de CHAQUE réponse, au milieu de la + conversation. + + Brancher un niveau est le travail de L'APPLICATION, pas d'une + bibliothèque : la méthode vit donc dans le menu, et non dans + `backends.py`. `openai` porte son propre logger pour la même raison. + + La liste porte DEUX noms de transport parce que le client `openai` ne + choisit pas toujours le même : le venv installe `httpx` et `httpx2` + côte à côte, et c'est la version du client qui décide lequel émet. + Ne nommer que l'un laisse la ligne passer sans que rien ne le signale, + puisqu'une ligne de journal n'est pas une panne. + """ + import logging + + for nom in ("httpx", "httpx2", "httpcore", "openai"): + logging.getLogger(nom).setLevel(logging.WARNING) + + @staticmethod + def _llm_multiline(): + """Une question sur plusieurs lignes, terminée par une ligne « . ». + + Une question collée ligne à ligne deviendrait autant de tours, donc + autant d'appels facturés, et une ligne collée valant « 0 » aurait + déclenché une entrée de menu avant que les commandes ne portent une + barre oblique. Ce mode rend un envoi unique. + """ + lignes = [] + while True: + try: + suite = input("… ") + except (KeyboardInterrupt, EOFError): + print() + break + if suite.strip() == ".": + break + lignes.append(suite) + return "\n".join(lignes) + + def _llm_save(self, conversation): + """Écrire la conversation sous ~/.erplibre/assistant/. + + Le répertoire se crée en 0700 et le fichier en 0600 : `~/.erplibre` + est lisible par tous les comptes de la machine, et une conversation + porte ce que la session y a collé. + + Le nom de fichier est tiré du compteur de tours, jamais d'une + horloge : une date rendrait le fichier reconnaissable dans le temps + sans rien apporter à qui le relit. + """ + base = os.path.join(os.path.expanduser("~/.erplibre"), "assistant") + os.makedirs(base, mode=0o700, exist_ok=True) + os.chmod(base, 0o700) + chemin = os.path.join( + base, f"conversation-{len(conversation.turns)}.md" + ) + drapeaux = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + with os.fdopen(os.open(chemin, drapeaux, 0o600), "w") as fichier: + fichier.write(conversation.transcript()) + print(f"✅ {t('Conversation written to')} {chemin}") diff --git a/script/todo/todo.py b/script/todo/todo.py index 0bde324..8e45978 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -27,6 +27,7 @@ sys.path.append(new_path) from script.config import config_file from script.execute import execute from script.todo import dev_tools, todo_install, todo_prefs +from script.todo.assistant_menu import AssistantMenuMixin from script.todo.database_manager import DatabaseManager from script.todo.kdbx_manager import KdbxManager from script.todo.longtest_menu import LongTestMenuMixin @@ -38,10 +39,9 @@ from script.todo.qemu_manage import QemuManageMixin from script.todo.qemu_menu import QemuMenuMixin from script.todo.qemu_network import QemuNetworkMixin from script.todo.qemu_recover import QemuRecoverMixin -from script.todo.vpn_menu import VpnMenuMixin -from script.todo.kdbx_manager import KdbxManager from script.todo.todo_i18n import get_lang, lang_is_configured, set_lang, t from script.todo.version_manager import get_odoo_version +from script.todo.vpn_menu import VpnMenuMixin ERROR_LOG_PATH = ".erplibre.error.txt" VENV_ERPLIBRE = ".venv.erplibre" @@ -105,6 +105,7 @@ class TODO( ProxmoxMenuMixin, LongTestMenuMixin, VpnMenuMixin, + AssistantMenuMixin, ): def __init__(self): self.dir_path = None @@ -207,7 +208,7 @@ class TODO( while True: help_info = f"""{self._menu_header()} -[1] {t("mail_ai_question")} +[1] {t("AI question - Ask a model, local or remote")} [2] {t("mail_menu")} [0] {t("Back")}""" status = click.prompt(help_info) @@ -215,39 +216,12 @@ class TODO( if status == "0": return if status == "1": - self._assistant_question() + self.prompt_assistant_llm() elif status == "2": prompt_execute_mail(self) else: print(t("Command not found !")) - def _assistant_question(self): - while True: - help_info = f"""{self._menu_header()} -[0] {t("Back")} -{t("Write your question ")}""" - status = click.prompt(help_info) - print() - if status == "0": - return - kp = self.kdbx_manager.get_kdbx() - if not kp: - return - config_name = self.config_file.get_config_value( - ["kdbx_config", "openai", "kdbx_key"] - ) - entry = kp.find_entries_by_title(config_name, first=True) - - client = openai.OpenAI(api_key=entry.password) - prompt_update = status - completion = client.chat.completions.create( - model="gpt-4o", - messages=[{"role": "user", "content": prompt_update}], - ) - - print(completion.choices[0].message.content) - print() - def prompt_execute(self): help_info = f"""{self._menu_header()} @@ -600,6 +574,7 @@ class TODO( "run": "TODO", "prompt_execute": "Execute", "prompt_assistant": "Assistant", + "prompt_assistant_llm": "LLM", "prompt_install": "Install", "prompt_execute_function": "Automation", "prompt_execute_code": "Code", @@ -610,6 +585,7 @@ class TODO( "prompt_execute_git": "Git", "prompt_execute_git_local_server": "Git local server", "prompt_execute_gpt_code": "GPT code", + "prompt_claude_sessions": "Claude Code", "prompt_execute_process": "Process", "prompt_execute_instance": "Run", "prompt_execute_rtk": "RTK", @@ -1418,8 +1394,8 @@ class TODO( On découpe en blocs plutôt que de substituer par expression régulière : une ligne Host peut porter PLUSIEURS noms. - Deux règles, chacune corrigeant une perte de données CONSTATÉE dans - le fichier d'un utilisateur. + Deux règles, chacune corrigeant une perte de données que ce + découpage provoque sans elles. 1. Seuls « Host » et « Match » clôturent un bloc. La règle d'avant — « une ligne non indentée clôt le bloc » — prenait l'indentation @@ -1822,9 +1798,8 @@ class TODO( """Nombre de rebonds pour joindre `cible`, en suivant la chaîne. C'est la mesure de PROFONDEUR d'un hôte imbriqué, et la seule dont on - dispose de l'extérieur. Elle est exacte pour les hôtes que nous avons - déployés : c'est nous qui écrivons ces entrées, un ProxyJump par - étage. + dispose de l'extérieur. Elle est exacte pour les hôtes que cet outil + déploie : c'est lui qui écrit ces entrées, un ProxyJump par étage. `maxi` borne le parcours : une boucle dans ~/.ssh/config — A qui rebondit par B qui rebondit par A — tournerait sinon sans fin. @@ -1990,8 +1965,8 @@ class TODO( # a » — et ne consulte donc PAS ~/.ssh/config pour l'alias entier. Or c'est # todo.py qui nomme les VM découvertes « jump+domaine » (voir la marche # SSH) : ce sont les alias les plus utiles, et les seuls que sshfs échoue à - # monter tel quel. Vécu : « read: Connection reset by peer », parce que la - # seconde moitié du nom est un domaine libvirt, pas un alias SSH du rebond. + # monter tel quel : le montage échoue, la seconde moitié du nom étant un + # domaine libvirt et non un alias SSH du rebond. SSHFS_CHAIN_SEP = "+" # Options à rendre à sshfs quand on contourne l'alias : exactement celles @@ -3028,6 +3003,7 @@ class TODO( "Claude Code plugins - marketplaces and ERPLibre list" ) }, + {"prompt_description": t("Claude Code - local sessions")}, ] help_info = self.fill_help_info(choices) @@ -3046,6 +3022,8 @@ class TODO( self._show_claude_context() elif status == "5": self.prompt_execute_claude_plugins() + elif status == "6": + self.prompt_claude_sessions() else: print(t("Command not found !")) @@ -4613,11 +4591,11 @@ class TODO( def _monitoring_restore(self, zip_path): """Restaurer la sauvegarde, puis DIRE ce que la neutralisation a pris. - Mesuré sur sept bases dont le nom portait « neutralize » : - `database.is_neutralized` absent partout, jusqu'à 35 crons actifs, - et le domaine de courriel du client toujours en place. Poser la - question, recevoir oui et ne rien vérifier reproduit exactement - cette illusion — on relit donc la base. + Une base dont le nom annonce la neutralisation peut n'en porter + aucune trace : `database.is_neutralized` absent, des crons encore + actifs, un domaine de courriel toujours en place. Poser la question, + recevoir oui et ne rien vérifier reproduit exactement cette illusion + — on relit donc la base. """ from script.analyse import monitoring diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index a0fd31a..ccc1d47 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -10025,12 +10025,8 @@ TRANSLATIONS = { }, # Courriel "mail_menu": { - "fr": "Courriel - Lire et envoyer du courriel", - "en": "Mail - Read and send email", - }, - "mail_ai_question": { - "fr": "Question IA - Poser une question à un modèle", - "en": "AI question - Ask a model a question", + "fr": "📧 Courriel - Lire et envoyer du courriel", + "en": "📧 Mail - Read and send email", }, "mail_open_tui": { "fr": "Ouvrir le client courriel (TUI)", @@ -12185,6 +12181,665 @@ TRANSLATIONS = { "fr": "Aucun réseau routé pour l'instant : ce tunnel ne joindra que l'hôte distant. Monter une fois — l'adresse obtenue dira quel réseau ajouter.", "en": "No network routed yet: this tunnel will only reach the remote host. Connect once — the address you get tells you which network to add.", }, + # Assistant LLM (script/todo/assistant_menu.py, script/todo/assistant/) + "AI question - Ask a model, local or remote": { + "fr": "🤖 Question IA - Interroger un modèle, local ou distant", + "en": "🤖 AI question - Ask a model, local or remote", + }, + "A server, a gpt tool, a conversation.": { + "fr": "Un serveur, un outil gpt, une conversation.", + "en": "A server, a gpt tool, a conversation.", + }, + "Talk": { + "fr": "Parler", + "en": "Talk", + }, + "Free question": { + "fr": "💬 Question libre", + "en": "💬 Free question", + }, + "Known servers": { + "fr": "🗄 Serveurs connus", + "en": "🗄 Known servers", + }, + "Search for a server…": { + "fr": "🔍 Chercher un serveur…", + "en": "🔍 Search for a server…", + }, + "Server card": { + "fr": "📇 Fiche du serveur", + "en": "📇 Server card", + }, + "what it says it can do": { + "fr": "ce qu'il annonce savoir faire", + "en": "what it says it can do", + }, + "no local server — via api.openai.com": { + "fr": "aucun serveur local — via api.openai.com", + "en": "no local server — via api.openai.com", + }, + "no server yet": { + "fr": "aucun serveur pour l'instant", + "en": "no server yet", + }, + "reachable": { + "fr": "joignable", + "en": "reachable", + }, + "The history lives in memory and dies with this menu. /save writes it to a file.": { + "fr": "L'historique vit en mémoire et meurt avec ce menu. /save l'écrit dans un fichier.", + "en": "The history lives in memory and dies with this menu. /save writes it to a file.", + }, + "Commands start with a slash. /? lists them.": { + "fr": "Les commandes commencent par une barre oblique. /? les liste.", + "en": "Commands start with a slash. /? lists them.", + }, + "interrupted": { + "fr": "interrompue", + "en": "interrupted", + }, + "answer interrupted": { + "fr": "réponse interrompue", + "en": "answer interrupted", + }, + "/save to write it to a file": { + "fr": "/save pour l'écrire dans un fichier", + "en": "/save to write it to a file", + }, + "back to the menu": { + "fr": "retour au menu", + "en": "back to the menu", + }, + "clear the history (same server, same tool)": { + "fr": "vider l'historique (même serveur, même outil)", + "en": "clear the history (same server, same tool)", + }, + "change tool, history kept": { + "fr": "changer d'outil, historique conservé", + "en": "change tool, history kept", + }, + "change server, history CLEARED — the model is no longer the same": { + "fr": "changer de serveur, historique VIDÉ — le modèle n'est plus le même", + "en": "change server, history CLEARED — the model is no longer the same", + }, + "show again what was sent": { + "fr": "réafficher ce qui a été envoyé", + "en": "show again what was sent", + }, + 'multi-line entry, end with a single "." line': { + "fr": "saisie multiligne, terminer par une ligne « . » seule", + "en": 'multi-line entry, end with a single "." line', + }, + "write the conversation to a file": { + "fr": "écrire la conversation dans un fichier", + "en": "write the conversation to a file", + }, + "list the commands": { + "fr": "lister les commandes", + "en": "list the commands", + }, + "turns dropped": { + "fr": "tours jetés", + "en": "turns dropped", + }, + "Conversation written to": { + "fr": "Conversation écrite dans", + "en": "Conversation written to", + }, + "via api.openai.com (key from the vault)": { + "fr": "via api.openai.com (clé du coffre)", + "en": "via api.openai.com (key from the vault)", + }, + "This destination is a third party. Retype it to confirm:": { + "fr": "Cette destination est un tiers. Retape-la pour confirmer :", + "en": "This destination is a third party. Retype it to confirm:", + }, + "Destination not retyped — nothing was sent.": { + "fr": "Destination non retapée — rien n'a été envoyé.", + "en": "Destination not retyped — nothing was sent.", + }, + "third party": { + "fr": "tiers", + "en": "third party", + }, + "this machine": { + "fr": "cette machine", + "en": "this machine", + }, + "local network of this machine": { + "fr": "🌐 Réseau local de cette machine", + "en": "🌐 Local network of this machine", + }, + "local network": { + "fr": "réseau local", + "en": "local network", + }, + "No server answered on this machine.": { + "fr": "Aucun serveur n'a répondu sur cette machine.", + "en": "No server answered on this machine.", + }, + "The vault holds no OpenAI key: configure a server or a key.": { + "fr": "Le coffre ne porte aucune clé OpenAI : configurer un serveur ou une clé.", + "en": "The vault holds no OpenAI key: configure a server or a key.", + }, + "Here (127.0.0.1)": { + "fr": "🏠 Ici (127.0.0.1)", + "en": "🏠 Here (127.0.0.1)", + }, + "An address I type": { + "fr": "🎯 Une adresse que je tape", + "en": "🎯 An address I type", + }, + "Where should I look for a server?": { + "fr": "Où chercher un serveur ?", + "en": "Where should I look for a server?", + }, + "Add a server by hand": { + "fr": "➕ Ajouter un serveur à la main", + "en": "➕ Add a server by hand", + }, + "Host or IP": { + "fr": "Hôte ou IP", + "en": "Host or IP", + }, + "Port": { + "fr": "Port", + "en": "Port", + }, + "Name for this server": { + "fr": "Nom pour ce serveur", + "en": "Name for this server", + }, + "Answered, not identified": { + "fr": "A répondu, non identifié", + "en": "Answered, not identified", + }, + "Asks for a key — configure it against a server you named": { + "fr": "Demande une clé — la configurer contre un serveur que tu as nommé", + "en": "Asks for a key — configure it against a server you named", + }, + "Starting up — alive, answer not ready": { + "fr": "En démarrage — vivant, réponse pas prête", + "en": "Starting up — alive, answer not ready", + }, + "Type the server name in full to delete it:": { + "fr": "Tape le nom du serveur en entier pour le supprimer :", + "en": "Type the server name in full to delete it:", + }, + "Delete a server": { + "fr": "🗑 Supprimer un serveur", + "en": "🗑 Delete a server", + }, + "Choose the server to use": { + "fr": "Choisir le serveur à utiliser", + "en": "Choose the server to use", + }, + "in use": { + "fr": "en usage", + "en": "in use", + }, + "11 ports, instant": { + "fr": "11 ports, instantané", + "en": "11 ports, instant", + }, + "The QEMU VMs of this machine (virsh)": { + "fr": "🖥 Les VM QEMU de cette machine (virsh)", + "en": "🖥 The QEMU VMs of this machine (virsh)", + }, + "The hosts of ~/.ssh/config": { + "fr": "🔑 Les hôtes de ~/.ssh/config", + "en": "🔑 The hosts of ~/.ssh/config", + }, + "libvirt bridge": { + "fr": "pont libvirt", + "en": "libvirt bridge", + }, + "Sweep %s addresses × %s ports on %s?": { + "fr": "Balayer %s adresses × %s ports sur %s ?", + "en": "Sweep %s addresses × %s ports on %s?", + }, + "The /24 is an assumption: a prefix does not follow from an address.": { + "fr": ( + "Le /24 est une hypothèse : un préfixe ne se déduit pas" + " d'une adresse." + ), + "en": ( + "The /24 is an assumption: a prefix does not follow from an" + " address." + ), + }, + "Wider than a /24 is refused.": { + "fr": "Plus large qu'un /24 est refusé.", + "en": "Wider than a /24 is refused.", + }, + "Ctrl+C interrupts": { + "fr": "Ctrl+C interrompt", + "en": "Ctrl+C interrupts", + }, + "hosts": { + "fr": "hôtes", + "en": "hosts", + }, + "ports": { + "fr": "ports", + "en": "ports", + }, + "server recognized": { + "fr": "serveur reconnu", + "en": "server recognized", + }, + "servers recognized": { + "fr": "serveurs reconnus", + "en": "servers recognized", + }, + "hosts swept": { + "fr": "hôtes balayés", + "en": "hosts swept", + }, + "No server on %s (%s, %s, %ss).": { + "fr": "Aucun serveur sur %s (%s, %s, %ss).", + "en": "No server on %s (%s, %s, %ss).", + }, + # « host » et « port » sont déjà déclarées plus haut dans ce fichier, avec + # ce français : les redéclarer ici les écraserait en silence. L'accord des + # nombres les lit telles quelles. + "model": { + "fr": "modèle", + "en": "model", + }, + "host swept": { + "fr": "hôte balayé", + "en": "host swept", + }, + "Look somewhere else": { + "fr": "Chercher ailleurs", + "en": "Look somewhere else", + }, + "Carry on with the OpenAI API (key from the vault)": { + "fr": "Continuer avec l'API OpenAI (clé du coffre)", + "en": "Carry on with the OpenAI API (key from the vault)", + }, + 'A local server: "ollama serve" listens on 11434.': { + "fr": "Un serveur local : « ollama serve » écoute sur 11434.", + "en": 'A local server: "ollama serve" listens on 11434.', + }, + "libvirt answers, no VM defined": { + "fr": "libvirt répond, aucune VM définie", + "en": "libvirt answers, no VM defined", + }, + "~/.ssh/config absent — nothing to probe": { + "fr": "~/.ssh/config absent — rien à sonder", + "en": "~/.ssh/config absent — nothing to probe", + }, + "Host without an address — listed as unknown": { + "fr": "Hôte sans adresse — listé comme inconnu", + "en": "Host without an address — listed as unknown", + }, + "Keep this server?": { + "fr": "Garder ce serveur ?", + "en": "Keep this server?", + }, + "Keep them all": { + "fr": "Les garder tous", + "en": "Keep them all", + }, + "Sweeping the network reaches machines you did not name.": { + "fr": ( + "Balayer le réseau atteint des machines que tu n'as pas" + " nommées." + ), + "en": "Sweeping the network reaches machines you did not name.", + }, + "gpt tools": { + "fr": "🧰 Outils gpt", + "en": "🧰 gpt tools", + }, + "no gpt tool yet": { + "fr": "aucun outil gpt pour l'instant", + "en": "no gpt tool yet", + }, + "compatible": { + "fr": "compatibles", + "en": "compatible", + }, + "Which gpt tool?": { + "fr": "Quel outil gpt ?", + "en": "Which gpt tool?", + }, + "a requirement is contradicted": { + "fr": "une exigence est contredite", + "en": "a requirement is contradicted", + }, + "unreadable gpt files": { + "fr": "fichiers gpt illisibles", + "en": "unreadable gpt files", + }, + "details": { + "fr": "détails", + "en": "details", + }, + "What is about to be sent": { + "fr": "Ce qui va être envoyé", + "en": "What is about to be sent", + }, + "characters": { + "fr": "caractères", + "en": "characters", + }, + "What the filter checks": { + "fr": "Ce que le filtre contrôle : adresses, courriels, chemins de compte. Pas les noms.", + "en": "What the filter checks", + }, + "cancel": { + "fr": "annuler", + "en": "cancel", + }, + "Nothing has been sent.": { + "fr": "Rien n'a été envoyé.", + "en": "Nothing has been sent.", + }, + # Les noms et descriptions des gpts livrés (script/todo/assistant/gpt/). + # L'anglais EST la clé, comme partout : un gpt non traduit + # s'affiche en anglais au lieu de rien. L'emoji vit dans la + # valeur, jamais dans la clé. + "Unit test failure - the cause, and what to read next": { + "fr": "🧪 Échec de test unitaire - la cause, et quoi lire ensuite", + "en": "Unit test failure - the cause, and what to read next", + }, + "Read one failing test and name the cause, without proposing a patch": { + "fr": "lire un test en échec et nommer la cause, sans proposer de correctif", + "en": "Read one failing test and name the cause, without proposing a patch", + }, + "Manifest gaps - which tier loses which modules": { + "fr": "🕳️ Trous de manifeste - quel palier perd quels modules", + "en": "Manifest gaps - which tier loses which modules", + }, + "Read the reported holes and name the tier and the modules each loses": { + "fr": "lire les trous rapportés et nommer le palier et les modules perdus", + "en": "Read the reported holes and name the tier and the modules each loses", + }, + "Cloned module - find what it inherited": { + "fr": "🧬 Module cloné - repérer ce dont il a hérité", + "en": "Cloned module - find what it inherited", + }, + "Name what a cloned module inherited rather than what was written": { + "fr": "nommer ce qu'un module cloné a hérité plutôt que ce qui a été écrit", + "en": "Name what a cloned module inherited rather than what was written", + }, + "Comment hygiene - rewrite narrative as mechanism": { + "fr": "🧹 Hygiène des commentaires - réécrire le récit en fonctionnement", + "en": "Comment hygiene - rewrite narrative as mechanism", + }, + "Rewrite each flagged sentence so the code is the subject, present tense": { + "fr": "réécrire chaque phrase signalée avec le code pour sujet, au présent", + "en": "Rewrite each flagged sentence so the code is the subject, present tense", + }, + "Commit message - subject, bilingual body, Assisted-by": { + "fr": "✍️ Message de commit - sujet, corps bilingue, Assisted-by", + "en": "Commit message - subject, bilingual body, Assisted-by", + }, + "Turn the staged diff into a tagged subject and a bilingual body": { + "fr": "faire du diff indexé un sujet tagué et un corps bilingue", + "en": "Turn the staged diff into a tagged subject and a bilingual body", + }, + "Bilingual doc - write or repair a .base.md": { + "fr": "🌍 Doc bilingue - écrire ou réparer un .base.md", + "en": "Bilingual doc - write or repair a .base.md", + }, + "Produce or fix a .base.md, its header and its language blocks": { + "fr": "produire ou réparer un .base.md, son en-tête et ses blocs de langue", + "en": "Produce or fix a .base.md, its header and its language blocks", + }, + # Sessions Claude Code locales (script/todo/assistant/claude_sessions.py). + "Claude Code - local sessions": { + "fr": "🤖 Claude Code - sessions locales", + "en": "🤖 Claude Code - local sessions", + }, + "List local sessions": { + "fr": "📋 Lister les sessions locales", + "en": "📋 List local sessions", + }, + "Ask a question to a session": { + "fr": "❓ Poser une question à une session", + "en": "❓ Ask a question to a session", + }, + "Resume a session in a new terminal": { + "fr": "▶ Reprendre une session dans un nouveau terminal", + "en": "▶ Resume a session in a new terminal", + }, + "live": { + "fr": "vivantes", + "en": "live", + }, + "held by pid %s": { + "fr": "tenue par le pid %s", + "en": "held by pid %s", + }, + "resumable, not running": { + "fr": "reprenable, pas en cours", + "en": "resumable, not running", + }, + "This session is open elsewhere. A branch would be lost.": { + "fr": "Cette session est ouverte ailleurs. Une branche serait perdue.", + "en": "This session is open elsewhere. A branch would be lost.", + }, + "Branch a copy (recommended)": { + "fr": "Brancher une copie (recommandé)", + "en": "Branch a copy (recommended)", + }, + "Write into the held session": { + "fr": "Écrire dans la session tenue", + "en": "Write into the held session", + }, + "Type the pid of the holder to write into it:": { + "fr": "Tape le pid du détenteur pour y écrire :", + "en": "Type the pid of the holder to write into it:", + }, + "No session on this machine.": { + "fr": "Aucune session sur cette machine.", + "en": "No session on this machine.", + }, + "claude is not on the PATH.": { + "fr": "claude n'est pas dans le PATH.", + "en": "claude is not on the PATH.", + }, + "No terminal can be opened here. Paste this command:": { + "fr": "Aucun terminal ne peut être ouvert ici. Colle cette commande :", + "en": "No terminal can be opened here. Paste this command:", + }, + "read-only: Read, Glob, Grep": { + "fr": "lecture seule : Read, Glob, Grep", + "en": "read-only: Read, Glob, Grep", + }, + # Catalogue gpt et porte du contexte (script/todo/assistant/gpt.py, + # script/todo/assistant/context.py, capabilities.py) + "No front-matter: a gpt opens with ---": { + "fr": "Pas d'en-tête : un gpt s'ouvre par ---", + "en": "No front-matter: a gpt opens with ---", + }, + "Front-matter is not closed": { + "fr": "En-tête non fermé", + "en": "Front-matter is not closed", + }, + "Front-matter is not a mapping": { + "fr": "L'en-tête n'est pas un dictionnaire", + "en": "Front-matter is not a mapping", + }, + "Front-matter is unreadable": { + "fr": "En-tête illisible", + "en": "Front-matter is unreadable", + }, + "Repeated key in front-matter:": { + "fr": "Clé répétée dans l'en-tête :", + "en": "Repeated key in front-matter:", + }, + "No schema version: gpt is required": { + "fr": "Version de schéma absente : gpt est obligatoire", + "en": "No schema version: gpt is required", + }, + "Schema too recent for this version of TODO": { + "fr": "Schéma trop récent pour cette version de TODO", + "en": "Schema too recent for this version of TODO", + }, + "No name": { + "fr": "Nom absent", + "en": "No name", + }, + "No description": { + "fr": "Description absente", + "en": "No description", + }, + "Missing marker ": { + "fr": "Marqueur absent", + "en": "Missing marker ", + }, + "Unknown key in front-matter:": { + "fr": "Clé inconnue dans l'en-tête :", + "en": "Unknown key in front-matter:", + }, + "name_fr belongs in the translations file": { + "fr": "name_fr appartient au fichier des traductions", + "en": "name_fr belongs in the translations file", + }, + "A gpt from outside the repository may not declare any command.": { + "fr": "Un gpt hors du dépôt ne peut déclarer aucune commande.", + "en": "A gpt from outside the repository may not declare any command.", + }, + "Overrides the one from": { + "fr": "Écrase celui de", + "en": "Overrides the one from", + }, + "Unreadable file:": { + "fr": "Fichier illisible :", + "en": "Unreadable file:", + }, + "A gpt may not be named *.base.md": { + "fr": "Un gpt ne peut pas s'appeler *.base.md", + "en": "A gpt may not be named *.base.md", + }, + "PyYAML is missing: the gpt catalogue stays closed, the free question works.": { + "fr": "PyYAML manque : le catalogue gpt reste fermé, la question libre fonctionne.", + "en": "PyYAML is missing: the gpt catalogue stays closed, the free question works.", + }, + "private/noms_interdits.txt is absent: no client, database, VM or host name can be recognized.": { + "fr": "private/noms_interdits.txt est absent : aucun nom de client, de base, de VM ou d'hôte ne peut être reconnu.", + "en": "private/noms_interdits.txt is absent: no client, database, VM or host name can be recognized.", + }, + "A finding blocks a send to a third party. No override.": { + "fr": "Une trouvaille bloque un envoi vers un tiers. Aucun passe-droit.", + "en": "A finding blocks a send to a third party. No override.", + }, + "finding to re-read before sending": { + "fr": "trouvaille à relire avant d'envoyer", + "en": "finding to re-read before sending", + }, + "findings to re-read before sending": { + "fr": "trouvailles à relire avant d'envoyer", + "en": "findings to re-read before sending", + }, + "… [cut]": { + "fr": "… [coupé]", + "en": "… [cut]", + }, + "a requirement could not be checked": { + "fr": "une exigence n'a pas pu être vérifiée", + "en": "a requirement could not be checked", + }, + "asks for %s of context, this server announces %s": { + "fr": "demande %s de contexte, ce serveur en annonce %s", + "en": "asks for %s of context, this server announces %s", + }, + "asks for %s B of parameters, this server announces %s B": { + "fr": "demande %s B de paramètres, ce serveur en annonce %s B", + "en": "asks for %s B of parameters, this server announces %s B", + }, + "asks for a local server, this one is %s": { + "fr": "demande un serveur local, celui-ci est %s", + "en": "asks for a local server, this one is %s", + }, + "asks for tool calling, this server does not announce it": { + "fr": "demande l'appel d'outils, ce serveur ne l'annonce pas", + "en": "asks for tool calling, this server does not announce it", + }, + "asks for vision, this server does not announce it": { + "fr": "demande la vision, ce serveur ne l'annonce pas", + "en": "asks for vision, this server does not announce it", + }, + "asks for JSON output, this server does not announce it": { + "fr": "demande une sortie JSON, ce serveur ne l'annonce pas", + "en": "asks for JSON output, this server does not announce it", + }, + "hosting could not be checked": { + "fr": "l'hébergement n'a pas pu être vérifié", + "en": "hosting could not be checked", + }, + "context_window could not be checked on this server": { + "fr": "context_window n'a pas pu être vérifié sur ce serveur", + "en": "context_window could not be checked on this server", + }, + "parameters could not be checked on this server": { + "fr": "parameters n'a pas pu être vérifié sur ce serveur", + "en": "parameters could not be checked on this server", + }, + "tool_calling could not be checked on this server": { + "fr": "tool_calling n'a pas pu être vérifié sur ce serveur", + "en": "tool_calling could not be checked on this server", + }, + "vision could not be checked on this server": { + "fr": "vision n'a pas pu être vérifié sur ce serveur", + "en": "vision could not be checked on this server", + }, + "json_output could not be checked on this server": { + "fr": "json_output n'a pas pu être vérifié sur ce serveur", + "en": "json_output could not be checked on this server", + }, + "A network I type (CIDR)": { + "fr": "📡 Un réseau que je tape (CIDR)", + "en": "📡 A network I type (CIDR)", + }, + "Network in CIDR form": { + "fr": "Réseau en notation CIDR", + "en": "Network in CIDR form", + }, + "Only part of that network was swept.": { + "fr": "Une partie seulement de ce réseau a été balayée.", + "en": "Only part of that network was swept.", + }, + "The networks of a machine over SSH": { + "fr": "🛰 Les réseaux d'une machine en SSH", + "en": "🛰 The networks of a machine over SSH", + }, + "Host reachable over SSH": { + "fr": "Hôte joignable en SSH", + "en": "Host reachable over SSH", + }, + "Reading the networks it carries…": { + "fr": "Lecture des réseaux qu'elle porte…", + "en": "Reading the networks it carries…", + }, + "That host did not answer, or carries no network.": { + "fr": "Cet hôte n'a pas répondu, ou ne porte aucun réseau.", + "en": "That host did not answer, or carries no network.", + }, + "read on %s, swept from here": { + "fr": "lu sur %s, balayé d'ici", + "en": "read on %s, swept from here", + }, + "Only the hosts that have already spoken (ip neigh)": { + "fr": "Seulement les hôtes qui ont déjà parlé (ip neigh)", + "en": "Only the hosts that have already spoken (ip neigh)", + }, + "nothing kept": { + "fr": "rien de gardé", + "en": "nothing kept", + }, + "kept": { + "fr": "gardé", + "en": "kept", + }, + # « models », « still waiting for » et « Type an address » servent aussi + # ici et sont définies plus haut : les redéfinir écraserait la première + # sans rien lever. } diff --git a/script/todo/todo_prefs.py b/script/todo/todo_prefs.py index 14eab51..6d78a40 100644 --- a/script/todo/todo_prefs.py +++ b/script/todo/todo_prefs.py @@ -30,6 +30,17 @@ DEFAULTS = { "qemu_deploy_progress": "cli", # Interface de la migration Odoo : "ask" / "tui" / "cli". "migration_ui": "ask", + # Balayage de découverte des serveurs LLM : connexions en vol. Sert + # seulement quand il est INFÉRIEUR au nombre de sondes, le balayage + # plafonnant à celui-ci — un /24 sur onze ports en compte 2 794. Monter + # raccourcit en groupant les vagues ; descendre allège la salve sur un + # commutateur qui perd des paquets sous charge. + "assistant_sweep_workers": 1024, + # Délai d'une connexion du balayage, en secondes. Le SEUL réglage d'ici + # qui fabrique des faux négatifs : sous charge, un hôte joignable en une + # milliseconde se manque à 0,05 s. Le descendre annonce des réseaux vides + # qui ne le sont pas. + "assistant_sweep_timeout": 0.30, # Cache courriel : mode par DÉFAUT. Un compte peut le surcharger via # sa clé `cache_mode` dans accounts.json ; `null` là-bas veut dire # « hérite d'ici ». Valeurs : clear | encrypted | ephemeral. diff --git a/test/llm_fake_server.py b/test/llm_fake_server.py new file mode 100644 index 0000000..5cfbe14 --- /dev/null +++ b/test/llm_fake_server.py @@ -0,0 +1,503 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Un vrai serveur HTTP jetable, qui se fait passer pour un serveur LLM. + +Pourquoi un vrai serveur plutôt qu'un double de `requests` ou du client +`openai` : un double ne rend que ce qu'on avait imaginé en l'écrivant. Or tout +ce qui casse une reconnaissance vient du TRANSPORT, pas de l'analyse — une +page d'administration de routeur qui rend du HTML là où on attendait du JSON, +un 503 pendant le chargement d'un modèle, un défi 401, une connexion coupée en +plein corps, un corps qui ne finit pas. + +L'intérêt n'est donc pas de servir poliment : c'est de POUVOIR MAL SE +CONDUIRE. Ajouter une méchanceté doit rester une petite addition — une +sous-classe de `Fault` — jamais un second serveur. + +Le serveur se lie à 127.0.0.1 sur le port 0 : le système choisit, donc rien +n'entre en collision avec ce qui écoute déjà, et rien ne quitte la machine. + +`FIXTURES` porte les corps de référence par famille de serveur. Les tests PURS +de reconnaissance et les tests de transport lisent les MÊMES octets : sans +cela, l'analyse serait vérifiée contre une idée du protocole et le transport +contre une autre, et l'écart ne se verrait qu'en production. + +Les valeurs y sont inventées — versions, noms de modèles, empreintes de +compilation. Un relevé pris sur une machine réelle figerait dans le dépôt le +nom d'un modèle et d'un hôte que personne n'a choisi d'y mettre. +""" +from __future__ import annotations + +import http.server +import json +import threading + +HOST = "127.0.0.1" + +# Corps de référence par famille. Chemin -> (statut, octets). Un chemin absent +# de la table rend 404 : c'est ce qui distingue les familles entre elles, et +# c'est donc une donnée du test autant que les corps eux-mêmes. +# +# LocalAI porte l'API d'Ollama EN ENTIER, jusqu'à la chaîne « Ollama is +# running » sur la racine. Les deux tables se ressemblent exprès : leur seule +# différence est `/readyz`, que LocalAI sert et qu'Ollama ignore, plus la +# version figée que LocalAI rend là où Ollama rend un vrai numéro. Une échelle +# de reconnaissance qui interroge Ollama avant d'écarter LocalAI se trompe sur +# toutes les machines LocalAI, et ces deux tables sont ce qui le prouve. +FIXTURES: dict[str, dict[str, tuple[int, bytes]]] = { + "ollama": { + "/": (200, b"Ollama is running"), + "/api/version": (200, b'{"version":"0.6.2"}'), + "/api/tags": ( + 200, + json.dumps( + { + "models": [ + { + "name": "petit-modele:7b", + "details": {"parameter_size": "7.2B"}, + } + ] + } + ).encode(), + ), + "/v1/models": ( + 200, + b'{"object":"list","data":[{"id":"petit-modele:7b"}]}', + ), + }, + "localai": { + "/readyz": (200, b""), + "/healthz": (200, b""), + "/": (200, b"Ollama is running"), + # Le littéral figé : LocalAI annonce toujours cette version-là, quelle + # que soit la sienne. Un vrai Ollama rend le numéro qu'il porte. + "/api/version": (200, b'{"version":"0.9.0"}'), + "/api/tags": (200, b'{"models":[{"name":"un-modele"}]}'), + "/v1/models": (200, b'{"object":"list","data":[{"id":"un-modele"}]}'), + }, + "localai_starting": { + "/readyz": ( + 503, + b'{"status":"starting","reason":"startup preload in progress"}', + ), + "/healthz": (200, b""), + }, + "llamacpp": { + "/props": ( + 200, + json.dumps( + { + "build_info": "b9999-0000000", + "chat_template_caps": {"supports_tools": True}, + "modalities": {"vision": False}, + "total_slots": 1, + } + ).encode(), + ), + "/health": (200, b'{"status":"ok"}'), + "/v1/models": ( + 200, + json.dumps( + { + "object": "list", + "data": [ + { + "id": "un-modele.gguf", + "owned_by": "llamacpp", + "meta": { + "n_ctx_train": 32768, + "n_params": 7000000000, + }, + } + ], + } + ).encode(), + ), + }, + "llamacpp_loading": { + "/health": ( + 503, + b'{"error":{"code":503,"message":"Loading model",' + b'"type":"unavailable_error"}}', + ), + }, + "vllm": { + # Le chemin est « /version », et c'est tout le propos : + # « /api/version » appartient à Ollama et à LocalAI. + "/version": (200, b'{"version":"0.0.0"}'), + "/health": (200, b""), + "/v1/models": (200, b'{"object":"list","data":[{"id":"un-modele"}]}'), + }, + "lmstudio": { + "/api/v0/models": ( + 200, + json.dumps( + { + "data": [ + { + "id": "un-modele", + "type": "llm", + "compatibility_type": "gguf", + "quantization": "Q4_K_M", + "state": "loaded", + "max_context_length": 8192, + } + ] + } + ).encode(), + ), + "/v1/models": (200, b'{"object":"list","data":[{"id":"un-modele"}]}'), + }, + "koboldcpp": { + "/api/extra/version": (200, b'{"result":"KoboldCpp","version":"0.0"}'), + "/v1/models": (200, b'{"object":"list","data":[{"id":"un-modele"}]}'), + }, + "jan": { + "/openapi.json": ( + 200, + b'{"info":{"title":"Jan API Server Endpoints"}}', + ), + "/v1/models": (200, b'{"object":"list","data":[{"id":"un-modele"}]}'), + }, + "open_webui": { + "/api/config": ( + 200, + b'{"name":"Open WebUI","version":"0.0.0",' + b'"deployment_id":"0000"}', + ), + "/api/version": (200, b'{"version":"0.0.0"}'), + }, + "textgen_webui": { + "/v1/internal/model/info": (200, b'{"model_name":"un-modele"}'), + "/v1/models": (200, b'{"object":"list","data":[{"id":"un-modele"}]}'), + }, + "tabbyapi": { + # Un 401 est un ACCORD de reconnaissance : TabbyAPI exige une clé par + # défaut. Ce n'est jamais une invitation à en saisir une. + "/v1/model": (401, b'{"detail":"Invalid API key"}'), + "/v1/template/list": (401, b'{"detail":"Invalid API key"}'), + }, + "gpt4all": { + # Aucun point de terminaison propre : GPT4All ne s'atteint que par + # élimination, sur son port, quand rien d'autre n'a répondu. + "/v1/models": (200, b'{"object":"list","data":[{"id":"un-modele"}]}'), + }, + "routeur": { + # Une page d'administration sur 8080 répond volontiers, en HTML, à + # n'importe quel chemin. Elle ne nomme aucun serveur LLM. + "/props": (200, b"Administration"), + "/readyz": (200, b"Administration"), + "/v1/models": (200, b"Administration"), + }, +} + + +class Fault: + """Une méchanceté à servir à la place d'une réponse polie.""" + + +class Cut(Fault): + """Coupe la connexion après avoir annoncé un corps plus long. + + Le client doit rendre la main sur un corps tronqué plutôt que de lever : + un serveur qui redémarre coupe exactement ainsi. + """ + + def __init__(self, partial: bytes = b'{"build_in', announced: int = 4096): + self.partial = partial + self.announced = announced + + +class Huge(Fault): + """Annonce et sert un corps immense, pour prouver le plafond de lecture. + + La reconnaissance ne lit que quelques ko : sans plafond, un serveur mal + configuré ferait tenir tout son catalogue en mémoire du menu. + """ + + def __init__(self, size: int = 8 * 1024 * 1024): + self.size = size + + +class Silent(Fault): + """Accepte la socket et ne répond jamais, pour prouver le délai. + + C'est le cas que « le port est ouvert » ne suffit pas à écarter : un + écouteur bloqué accepte la connexion et laisse le client attendre. + """ + + def __init__(self, seconds: float = 30.0): + self.seconds = seconds + + +class _Handler(http.server.BaseHTTPRequestHandler): + # Le journal par défaut écrit sur stderr à chaque requête et noierait la + # sortie de la suite. + def log_message(self, fmt, *args): + pass + + def _serve(self, method): + server = self.server + server.seen.append((method, self.path, dict(self.headers.items()))) + route = server.routes.get(self.path) + if route is None: + self.send_response(404) + self.send_header("Content-Length", "0") + self.end_headers() + return + if isinstance(route, Silent): + # Ne rien écrire du tout : le client doit expirer de lui-même. + import time + + time.sleep(route.seconds) + return + if isinstance(route, Cut): + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(route.announced)) + self.end_headers() + self.wfile.write(route.partial) + self.close_connection = True + return + if isinstance(route, Huge): + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(route.size)) + self.end_headers() + bloc = b"x" * 65536 + reste = route.size + try: + while reste > 0: + n = min(reste, len(bloc)) + self.wfile.write(bloc[:n]) + reste -= n + except (BrokenPipeError, ConnectionResetError): + # Le client a fermé au plafond : c'est le comportement voulu, + # pas une panne du serveur. + pass + return + status, body = route + self.send_response(status) + self.send_header( + "Content-Type", + "text/html" if body[:1] == b"<" else "application/json", + ) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if body: + self.wfile.write(body) + + def do_GET(self): + self._serve("GET") + + def do_POST(self): + self._serve("POST") + + def do_HEAD(self): + self._serve("HEAD") + + +class FakeLLM: + """Un serveur d'une famille donnée, ou d'une table de chemins sur mesure. + + S'utilise comme gestionnaire de contexte ; `url` donne la racine à passer + au client. Le port est choisi par le système, donc deux serveurs peuvent + tourner en même temps sans se marcher dessus. + + `seen` porte (méthode, chemin, en-têtes) de chaque requête reçue : c'est + ce qui permet d'affirmer qu'une découverte n'émet que des GET et n'envoie + aucune autorisation. + """ + + def __init__(self, famille=None, *, routes=None, port=0): + table = dict(FIXTURES[famille]) if famille else {} + if routes: + table.update(routes) + # Le port 0 laisse le système choisir, ce qui évite toute collision + # avec ce qui écoute déjà et permet à deux serveurs de tourner en + # même temps. Un port EXPLICITE ne sert qu'à occuper celui d'un + # logiciel réel, pour qu'un balayage tombe dessus. + self._httpd = http.server.ThreadingHTTPServer((HOST, port), _Handler) + self._httpd.routes = table + self._httpd.seen = [] + self._httpd.daemon_threads = True + # `serve_forever` sonde la demande d'arrêt à cet intervalle, et + # `shutdown()` attend donc jusqu'à un intervalle entier. Le défaut de + # 0,5 s se paie une fois par serveur : un fichier qui en ouvre vingt + # passerait dix secondes à les fermer, pour une suite qui doit rester + # de l'ordre de la seconde. + self._fil = threading.Thread( + target=self._httpd.serve_forever, + kwargs={"poll_interval": 0.01}, + daemon=True, + ) + + def __enter__(self): + self._fil.start() + return self + + def __exit__(self, *exc): + self._httpd.shutdown() + self._httpd.server_close() + self._fil.join(timeout=5) + return False + + @property + def host(self) -> str: + return HOST + + @property + def port(self) -> int: + return self._httpd.server_address[1] + + @property + def url(self) -> str: + return f"http://{HOST}:{self.port}" + + @property + def seen(self) -> list: + """(méthode, chemin, en-têtes) de chaque requête reçue.""" + return self._httpd.seen + + def statut_de(self, chemin) -> str: + """Ce que ce chemin rend : un statut, une méchanceté, ou 404. + + Sert le mode à la main : voir le chemin interrogé ne dit pas ce qu'il + a répondu, et c'est la réponse qui décide de la reconnaissance. + """ + route = self._httpd.routes.get(chemin) + if route is None: + return "404" + if isinstance(route, Fault): + return type(route).__name__.lower() + return str(route[0]) + + +def port_ferme() -> int: + """Un port sur lequel personne n'écoute. + + Ouvrir puis refermer une socket rend un numéro que le système vient + d'attribuer : personne d'autre ne l'a pris entre-temps, et une connexion + y sera refusée tout de suite plutôt que d'expirer. + """ + import socket + + with socket.socket() as sock: + sock.bind((HOST, 0)) + return sock.getsockname()[1] + + +def _chat(texte): + """Une complétion OpenAI qui rend `texte`. + + Les tables `FIXTURES` ne servent que des GET de reconnaissance : la + découverte n'émet rien d'autre. Une conversation réclame en plus un + `/v1/chat/completions`, que cette fonction fabrique pour l'usage à la + main — un vrai modèle n'est pas nécessaire pour vérifier qu'un menu parle + au bon endroit. + """ + return ( + 200, + json.dumps( + { + "id": "chatcmpl-faux", + "object": "chat.completion", + "created": 0, + "model": "modele-de-facade", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": texte}, + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ).encode(), + ) + + +def _principal(arguments): + """Sert une famille en avant-plan, et JOURNALISE chaque requête reçue. + + C'est ce que le mode à la main apporte et qu'un test ne donne pas : la + liste des chemins qu'un client interroge, dans l'ordre. Une + reconnaissance qui se trompe se lit alors directement — l'ordre des + étages est visible, et le chemin qui a emporté la décision est le dernier + avant l'arrêt. + + Le port 0 laisse le système choisir ; un port explicite sert à occuper + celui qu'un logiciel réel utiliserait, pour qu'un balayage tombe dessus. + """ + import argparse + import time + + analyseur = argparse.ArgumentParser( + description=( + "Un serveur LLM de façade, pour diagnostiquer un client sans" + " modèle." + ) + ) + analyseur.add_argument( + "famille", + nargs="?", + choices=sorted(FIXTURES), + help="la famille à imiter ; sans elle, la liste s'affiche", + ) + analyseur.add_argument( + "--port", + type=int, + default=0, + help="le port d'écoute ; 0 laisse le système choisir", + ) + analyseur.add_argument( + "--chat", + metavar="TEXTE", + help="ajoute /v1/chat/completions, qui rendra TEXTE", + ) + options = analyseur.parse_args(arguments) + + if not options.famille: + print("Familles servies :") + for nom in sorted(FIXTURES): + chemins = " ".join(sorted(FIXTURES[nom])) + print(f" {nom:18} {chemins}") + return 0 + + routes = {} + if options.chat: + routes["/v1/chat/completions"] = _chat(options.chat) + with FakeLLM(options.famille, routes=routes, port=options.port) as vivant: + print(f"{options.famille} → {vivant.url}") + print("Chaque requête reçue s'affiche ici. Ctrl+C arrête.", flush=True) + vus = 0 + try: + while True: + time.sleep(0.05) + for methode, chemin, entetes in vivant.seen[vus:]: + autorisation = entetes.get("Authorization") + marque = " ⚠ Authorization" if autorisation else "" + statut = vivant.statut_de(chemin) + # Vidé à chaque ligne : la sortie d'un serveur en + # avant-plan est mise en tampon par blocs dès qu'elle est + # redirigée, et un journal qui n'arrive qu'à l'arrêt ne + # sert plus à suivre ce qui se passe. + print( + f" {methode:4} {chemin:28} → {statut}{marque}", + flush=True, + ) + vus = len(vivant.seen) + except KeyboardInterrupt: + print(f"\n{vus} requête(s) reçue(s).") + return 0 + + +if __name__ == "__main__": + import sys + + sys.exit(_principal(sys.argv[1:])) diff --git a/test/test_assistant_capabilities.py b/test/test_assistant_capabilities.py new file mode 100644 index 0000000..f0fc2fa --- /dev/null +++ b/test/test_assistant_capabilities.py @@ -0,0 +1,488 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ce qu'un serveur annonce, ce qu'un gpt exige, et qui grise l'autre. + +Ce qui casserait sans ces tests est une régression silencieuse, pas une +exception : le catalogue s'afficherait, et il serait faux. + +D'un côté, GRISER SUR L'INCONNU. Le point de terminaison le plus courant +n'annonce aucune capacité ; qui traite « pas de réponse » comme « non » +grise tout le catalogue devant lui, et l'utilisateur n'a rien à répondre à un +refus qui ne repose sur rien. De l'autre, l'échelle d'hébergement, seule +exigence qui grise avant d'avoir parlé au serveur : y inverser deux barreaux +présente un tiers comme la machine locale, et c'est un envoi de données qui +part sans que personne n'ait été prévenu. + +Aucun test n'ouvre de socket ni n'interroge un résolveur : le transport et la +résolution arrivent par argument nommé. Les noms d'hôte portent tous le +domaine réservé « .invalid », qu'aucun résolveur ne peut faire aboutir — si +l'injection cessait d'être branchée, le test échouerait au lieu d'interroger +le DNS de qui le lance. + +Les corps de réponse viennent de `llm_fake_server.FIXTURES`, la table que +lisent aussi les tests de transport : deux tables se seraient contredites sans +que rien ne le montre. `/api/show` n'y est pas et se définit ici, parce que la +découverte n'émet que des GET et qu'elle n'a donc jamais eu à le connaître. +""" +import ipaddress +import os +import sys +import unittest +from dataclasses import replace + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from llm_fake_server import FIXTURES # noqa: E402 + +from script.todo.assistant import capabilities # noqa: E402 +from script.todo.assistant.capabilities import ( # noqa: E402 + NOTHING_ANNOUNCED, + Capabilities, + classify_hosting, + match, +) +from script.todo.assistant.fingerprint import Fingerprint # noqa: E402 + +HOTE = "127.0.0.1" +PORT = 11434 + +# Des noms inventés, dans le domaine réservé aux noms qui n'existent pas. +NOM_LOCAL = "boite-a-modeles.invalid" +NOM_LAN = "modele-du-quartier.invalid" +NOM_MORT = "modele-lointain.invalid" +NOM_MIXTE = "modele-a-deux-faces.invalid" + +# Une adresse privée inventée, et une adresse publique prise dans le préfixe +# de relais 6to4, retiré du service et rendu à l'IANA : elle est globale pour +# `ipaddress` et ne désigne aucune machine. +PRIVEE = "10.42.0.7" +PUBLIQUE = "192.88.99.1" + +# Le modèle que la table partagée fait annoncer par Ollama. +MODELE = "petit-modele:7b" + +# `POST /api/show` : l'énumération de capacités et le bloc `model_info`. Le +# compte de paramètres y est un ENTIER de paramètres, que la lecture ramène en +# milliards ; la longueur de contexte porte le nom de l'architecture en +# préfixe. +SHOW = ( + 200, + b'{"capabilities":["completion","tools"],' + b'"model_info":{"general.architecture":"petit",' + b'"petit.context_length":8192,' + b'"general.parameter_count":7241732096}}', +) + +# Le même serveur, qui ne publie pas son bloc `model_info`. +SHOW_SANS_INFO = (200, b'{"capabilities":["completion","vision"]}') + + +def desservir(table, journal=None): + """Un transport injecté qui sert `table` par chemin, 404 pour le reste. + + La même fonction tient le GET et le POST : le POST reçoit une charge utile + de plus, dont une table figée n'a pas besoin pour choisir sa réponse. + `journal` reçoit chaque appel, ce qui permet d'affirmer qu'il n'y en a eu + aucun. + """ + + def repondre(host, port, path, payload=None): + if journal is not None: + journal.append(path) + return table.get(path, (404, b"")) + + return repondre + + +def resoudre(table): + """Un résolveur injecté : un nom absent de `table` ne résout pas.""" + + def resolve(host): + if host not in table: + raise OSError("nom inconnu") + return table[host] + + return resolve + + +def lire(software, table=None, *, poste=None, models=(), journal=None): + """`read` avec ses DEUX transports injectés, jamais un seul. + + N'injecter que le GET laisserait le POST d'`/api/show` partir sur une + vraie socket dès qu'un lecteur change d'avis sur ce qu'il interroge. Le + test n'a pas à y penser : l'aide branche les deux. + """ + return capabilities.read( + Fingerprint(software, models=tuple(models)), + HOTE, + PORT, + http_get=desservir(table or {}, journal), + http_post=desservir(poste or {}, journal), + ) + + +class Hebergement(unittest.TestCase): + """La classe d'hébergement : la seule exigence jamais inconnue.""" + + def test_la_boucle_locale_est_testee_avant_le_prive(self): + # `ipaddress` rapporte la boucle locale comme privée AUSSI : tester + # `is_private` d'abord classerait la machine même comme du réseau + # local, et un gpt qui exige `loopback` s'y grise. + self.assertTrue(ipaddress.ip_address(HOTE).is_private) + self.assertEqual(classify_hosting(HOTE), "loopback") + self.assertEqual(classify_hosting("::1"), "loopback") + self.assertEqual(classify_hosting("[::1]"), "loopback") + + def test_un_nom_qui_ne_resout_pas_se_lit_comme_global(self): + # Deux façons d'échouer, une seule lecture : le résolveur lève, ou il + # ne rend rien. Un nom qu'on ne sait pas placer n'est jamais local. + self.assertEqual( + classify_hosting(NOM_MORT, resolve=resoudre({})), "global" + ) + self.assertEqual( + classify_hosting(NOM_MORT, resolve=resoudre({NOM_MORT: []})), + "global", + ) + + def test_un_nom_se_classe_par_l_adresse_qu_il_resout(self): + resolve = resoudre({NOM_LOCAL: [HOTE], NOM_LAN: [PRIVEE]}) + self.assertEqual( + classify_hosting(NOM_LOCAL, resolve=resolve), "loopback" + ) + self.assertEqual(classify_hosting(NOM_LAN, resolve=resolve), "lan") + + def test_un_nom_a_plusieurs_adresses_prend_la_plus_pessimiste(self): + # Une destination n'est locale que si TOUTES ses adresses le sont : + # l'envoi partira vers celle que le système choisira, pas vers celle + # qui arrangeait le classement. + resolve = resoudre({NOM_MIXTE: [HOTE, PUBLIQUE]}) + self.assertEqual( + classify_hosting(NOM_MIXTE, resolve=resolve), "global" + ) + + def test_ce_qui_n_est_ni_local_ni_prive_est_un_tiers(self): + # La plage de transition d'opérateur n'est ni privée ni globale pour + # `ipaddress` : le rang le plus haut est celui qui ne promet rien. + for host in (PUBLIQUE, "100.64.0.1", "", " "): + with self.subTest(host=host): + self.assertEqual(classify_hosting(host), "global") + + +class Appariement(unittest.TestCase): + """Ce qui grise une entrée du catalogue, et ce qui ne la grise pas.""" + + def test_inconnu_ne_grise_jamais(self): + exigeant = { + "hosting": "any", + "context_window": 32000, + "parameters": 12, + "tool_calling": True, + "vision": True, + "json_output": True, + } + verdict, raison = match(exigeant, NOTHING_ANNOUNCED, "global") + self.assertEqual(verdict, "unknown") + self.assertNotEqual(raison, "") + for cle, valeur in exigeant.items(): + if cle == "hosting": + continue + with self.subTest(cle=cle): + seule = {"hosting": "any", cle: valeur} + verdict, _ = match(seule, NOTHING_ANNOUNCED, "global") + self.assertEqual(verdict, "unknown") + + def test_une_exigence_contredite_grise_avec_sa_raison(self): + lu = Capabilities(tool_calling=False, vision=True) + verdict, raison = match( + {"hosting": "any", "tool_calling": True}, lu, "loopback" + ) + self.assertEqual(verdict, "no") + self.assertEqual(raison, capabilities.REASON_TOOLS) + + def test_loopback_satisfait_lan_satisfait_any(self): + attendus = { + ("loopback", "loopback"): "ok", + ("loopback", "lan"): "ok", + ("loopback", "any"): "ok", + ("lan", "loopback"): "no", + ("lan", "lan"): "ok", + ("lan", "any"): "ok", + ("global", "loopback"): "no", + ("global", "lan"): "no", + ("global", "any"): "ok", + } + self.assertTrue(attendus, "aucun couple à vérifier") + for (hosting, exige), attendu in attendus.items(): + with self.subTest(hosting=hosting, exige=exige): + verdict, raison = match( + {"hosting": exige}, NOTHING_ANNOUNCED, hosting + ) + self.assertEqual(verdict, attendu) + if attendu == "no": + self.assertEqual(raison, capabilities.REASON_HOSTING) + + def test_sans_exigence_d_hebergement_le_defaut_refuse_un_tiers(self): + self.assertEqual(match({}, NOTHING_ANNOUNCED, "lan"), ("ok", "")) + self.assertEqual(match({}, NOTHING_ANNOUNCED, "loopback"), ("ok", "")) + verdict, raison = match({}, NOTHING_ANNOUNCED, "global") + self.assertEqual(verdict, "no") + self.assertEqual(raison, capabilities.REASON_HOSTING) + + def test_un_barreau_que_l_echelle_ignore_ne_grise_pas(self): + # Une faute de frappe dans l'en-tête d'un gpt n'est pas une + # contradiction : elle se signale, elle ne refuse pas. + verdict, raison = match( + {"hosting": "sur-la-lune"}, NOTHING_ANNOUNCED, "loopback" + ) + self.assertEqual(verdict, "unknown") + self.assertIn("hosting", raison) + + def test_un_contexte_plus_petit_que_demande_est_le_seul_nombre_dur(self): + lu = Capabilities( + context_window=8192, + parameters=3.0, + estimated=frozenset({"parameters"}), + ) + exige = {"hosting": "any", "context_window": 32000, "parameters": 7} + verdict, raison = match(exige, lu, "loopback") + self.assertEqual(verdict, "no") + self.assertEqual(raison, capabilities.REASON_CONTEXT) + # Le contexte satisfait, il ne reste que l'écart sur une estimation : + # elle ne grise pas, elle se répète par son nom à l'envoi. + assez = replace(lu, context_window=32768) + verdict, raison = match(exige, assez, "loopback") + self.assertEqual(verdict, "unknown") + self.assertIn("parameters", raison) + + def test_un_nombre_de_parametres_estime_ne_grise_pas(self): + devine = Capabilities( + parameters=3.0, estimated=frozenset({"parameters"}) + ) + exige = {"hosting": "any", "parameters": 7} + self.assertEqual(match(exige, devine, "loopback")[0], "unknown") + # Le même écart, mais LU sur le serveur, grise. + self.assertEqual( + match(exige, Capabilities(parameters=3.0), "loopback"), + ("no", capabilities.REASON_PARAMETERS), + ) + + def test_la_raison_nomme_la_cle_qu_on_n_a_pas_pu_verifier(self): + exigences = { + "context_window": 8000, + "parameters": 7, + "tool_calling": True, + "vision": True, + "json_output": True, + } + self.assertTrue(exigences, "aucune exigence à vérifier") + for cle, valeur in exigences.items(): + with self.subTest(cle=cle): + verdict, raison = match( + {"hosting": "any", cle: valeur}, + NOTHING_ANNOUNCED, + "loopback", + ) + self.assertEqual(verdict, "unknown") + self.assertIn(cle, raison) + + def test_une_cle_hors_de_l_ensemble_ferme_n_est_pas_avalee(self): + verdict, raison = match( + {"hosting": "any", "quantisation": "Q4"}, + NOTHING_ANNOUNCED, + "loopback", + ) + self.assertEqual(verdict, "unknown") + self.assertEqual(raison, capabilities.REASON_UNCHECKED_OTHER) + + def test_une_exigence_a_faux_est_satisfaite_par_n_importe_quoi(self): + # Un gpt qui déclare ne pas avoir besoin de la vision n'a pas à + # attendre qu'un serveur muet la lui annonce. + exige = {"hosting": "any", "vision": False, "tool_calling": False} + self.assertEqual(match(exige, NOTHING_ANNOUNCED, "global"), ("ok", "")) + + def test_une_contradiction_l_emporte_sur_une_inconnue(self): + lu = Capabilities(context_window=4096) + exige = {"hosting": "any", "context_window": 8000, "vision": True} + self.assertEqual( + match(exige, lu, "loopback"), + ("no", capabilities.REASON_CONTEXT), + ) + + +class Lecture(unittest.TestCase): + """Les trois degrés d'honnêteté : ce qui se lit, ce qui se devine, + et ce qui manque.""" + + def test_ollama_traduit_ses_capacites_depuis_l_enumeration(self): + journal = [] + caps = lire( + "ollama", + FIXTURES["ollama"], + poste={"/api/show": SHOW}, + models=[MODELE], + journal=journal, + ) + # Un seul aller-retour : `/api/show` a tout dit, et `/api/tags` est le + # repli, pas un passage obligé. + self.assertEqual(journal, ["/api/show"]) + self.assertIs(caps.tool_calling, True) + # L'énumération ne porte ni « vision » ni « image » : c'est une + # négation LUE, et elle peut donc griser. + self.assertIs(caps.vision, False) + self.assertEqual(caps.context_window, 8192) + self.assertEqual(caps.parameters, 7.24) + self.assertEqual(caps.estimated, frozenset()) + # Aucun serveur de la table n'annonce la sortie JSON. + self.assertIsNone(caps.json_output) + + def test_localai_est_lu_par_le_lecteur_d_ollama(self): + caps = lire( + "localai", + FIXTURES["ollama"], + poste={"/api/show": SHOW}, + models=[MODELE], + ) + self.assertEqual(caps.context_window, 8192) + self.assertIs(caps.tool_calling, True) + + def test_une_taille_lue_dans_les_tags_n_est_pas_estimee(self): + # Sans `model_info`, le repli lit le `parameter_size` que le serveur + # publie déjà : une lecture, donc rien dans `estimated`. + journal = [] + caps = lire( + "ollama", + FIXTURES["ollama"], + poste={"/api/show": SHOW_SANS_INFO}, + models=[MODELE], + journal=journal, + ) + self.assertEqual(journal, ["/api/show", "/api/tags"]) + self.assertEqual(caps.parameters, 7.2) + self.assertEqual(caps.estimated, frozenset()) + self.assertIsNone(caps.context_window) + self.assertIs(caps.vision, True) + + def test_un_nombre_de_parametres_absent_est_devine_dans_le_nom(self): + caps = lire("ollama", {}, models=[MODELE]) + self.assertEqual(caps.parameters, 7.0) + self.assertEqual(caps.estimated, frozenset({"parameters"})) + # Et la devinette ne grise rien, si loin du compte soit-elle. + verdict, _ = match( + {"hosting": "any", "parameters": 70}, caps, "loopback" + ) + self.assertEqual(verdict, "unknown") + + def test_llamacpp_donne_les_outils_et_la_vision_depuis_props(self): + caps = lire( + "llamacpp", FIXTURES["llamacpp"], models=["un-modele.gguf"] + ) + self.assertIs(caps.tool_calling, True) + self.assertIs(caps.vision, False) + self.assertEqual(caps.context_window, 32768) + self.assertEqual(caps.parameters, 7.0) + self.assertEqual(caps.estimated, frozenset()) + + def test_llamacpp_lit_les_deux_noms_du_drapeau_d_outils(self): + table = dict(FIXTURES["llamacpp"]) + table["/props"] = ( + 200, + b'{"build_info":"b0-0","chat_template_caps":' + b'{"supports_tool_calls":true}}', + ) + caps = lire("llamacpp", table, models=["un-modele.gguf"]) + self.assertIs(caps.tool_calling, True) + self.assertIsNone(caps.vision) + + def test_lmstudio_donne_le_contexte_et_la_vision_sans_les_outils(self): + caps = lire("lmstudio", FIXTURES["lmstudio"], models=["un-modele"]) + self.assertEqual(caps.context_window, 8192) + self.assertIs(caps.vision, False) + # Aucun drapeau d'outils n'existe chez ce serveur : le champ reste + # vide, parce qu'un `False` inventé griserait un serveur qui appelle + # des outils en vrai. + self.assertIsNone(caps.tool_calling) + + def test_lmstudio_nomme_la_vision_par_le_type_du_modele(self): + table = { + "/api/v0/models": ( + 200, + b'{"data":[{"id":"un-modele","type":"vlm",' + b'"max_context_length":4096}]}', + ) + } + caps = lire("lmstudio", table, models=["un-modele"]) + self.assertIs(caps.vision, True) + + def test_koboldcpp_annonce_ses_booleens_sans_aucun_contexte(self): + table = { + "/api/extra/version": ( + 200, + b'{"result":"KoboldCpp","version":"0.0","vision":true}', + ) + } + caps = lire("koboldcpp", table) + self.assertIs(caps.vision, True) + self.assertIsNone(caps.context_window) + self.assertIsNone(caps.tool_calling) + # Le corps partagé ne porte pas le drapeau : la vision reste vide. + self.assertIsNone(lire("koboldcpp", FIXTURES["koboldcpp"]).vision) + + def test_un_serveur_qui_n_annonce_rien_rend_tout_a_none(self): + journal = [] + self.assertTrue(capabilities.SILENT, "la famille muette est vide") + for software in sorted(capabilities.SILENT): + with self.subTest(software=software): + caps = lire( + software, + FIXTURES.get(software, {}), + models=["un-modele-7b"], + journal=journal, + ) + self.assertEqual(caps, NOTHING_ANNOUNCED) + # Le nom du modèle porte sa taille : la rendre vide prouve que le + # lecteur n'a pas tourné du tout, et le journal qu'aucune requête n'est + # partie vers un serveur qui n'a rien à en dire. + self.assertEqual(journal, []) + + def test_un_logiciel_non_reconnu_ne_fait_aucune_requete(self): + journal = [] + caps = lire("", FIXTURES["ollama"], models=[MODELE], journal=journal) + self.assertEqual(caps, NOTHING_ANNOUNCED) + self.assertEqual(journal, []) + + def test_un_corps_qui_n_est_pas_du_json_laisse_tout_a_none(self): + # Une page d'administration de routeur répond volontiers, en HTML, à + # n'importe quel chemin. + caps = lire("llamacpp", FIXTURES["routeur"], models=["un-modele.gguf"]) + self.assertEqual(caps, NOTHING_ANNOUNCED) + + def test_un_statut_qui_n_est_pas_200_n_annonce_aucune_capacite(self): + # Un démarrage et un défi d'authentification sont des serveurs + # vivants, mais ils n'annoncent aucune capacité. + for statut in (401, 503): + with self.subTest(statut=statut): + corps = FIXTURES["llamacpp"]["/props"][1] + table = {"/props": (statut, corps)} + caps = lire("llamacpp", table, models=["un-modele.gguf"]) + self.assertEqual(caps, NOTHING_ANNOUNCED) + + def test_un_drapeau_lu_comme_un_nombre_n_est_pas_une_longueur(self): + # `isinstance(True, int)` est vrai : un booléen pris pour un contexte + # de 1 serait une capacité inventée, et elle griserait. + table = { + "/v1/models": ( + 200, + b'{"data":[{"id":"un-modele.gguf",' + b'"meta":{"n_ctx_train":true,"n_params":0}}]}', + ) + } + caps = lire("llamacpp", table, models=["un-modele.gguf"]) + self.assertIsNone(caps.context_window) + self.assertIsNone(caps.parameters) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_assistant_claude_sessions.py b/test/test_assistant_claude_sessions.py new file mode 100644 index 0000000..97c9478 --- /dev/null +++ b/test/test_assistant_claude_sessions.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ce que le listage des sessions Claude Code doit voir, et ne pas lire. + +Deux frontières se jouent ici, et les deux sont vérifiables sans toucher à une +session réelle. + +**Un pid ne prouve pas qu'une session vit.** Les pids se recyclent, et une +entrée laissée par un arrêt brutal désignerait alors le processus de quelqu'un +d'autre — donc proposerait d'écrire dedans. La vivacité exige le pid ET le +moment de démarrage du processus. + +**Le contenu d'une transcription ne sort pas.** Le système ferme ce répertoire +à son propriétaire seul ; le listage n'y lit que deux champs de STRUCTURE, et +un titre ou un message ne doit atteindre aucun affichage. La fixture porte +donc un marqueur dans un message, et les tests affirment qu'il ne ressort +nulle part. + +Le répertoire de travail se lit dans la transcription et jamais dans le nom du +répertoire qui la contient : la transformation qui produit ce nom change les +séparateurs, les points et les tirets bas en tirets, donc elle ne s'inverse +pas et confondrait deux dépôts voisins. La fixture le prouve avec un nom qui +porte les trois. + +Aucun test ne lance `claude`, ne lit le registre de la machine, ni n'ouvre une +transcription réelle : le lanceur, le registre, l'état des processus et la +lecture d'en-tête sont tous injectés. +""" +from __future__ import annotations + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + +RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if RACINE not in sys.path: + sys.path.insert(0, RACINE) + +from script.todo.assistant import claude_sessions as CS # noqa: E402 + +# Des identifiants et des pids inventés. Les répertoires portent un point, un +# tiret bas et un séparateur, précisément ce que la transformation du nom de +# répertoire écrase. +VIVANTE = "aaaaaaaa-1111-4111-8111-111111111111" +DORMANTE = "bbbbbbbb-2222-4222-8222-222222222222" +PID = 4242 +DEMARRAGE = "987654" +CHEMIN = "/opt/atelier/projet.essai_v2" + +# Ce que le listage de l'outil annonce. +AGENTS = json.dumps( + [ + { + "pid": PID, + "cwd": CHEMIN, + "kind": "interactive", + "startedAt": 0, + "sessionId": VIVANTE, + "name": "atelier", + "status": "busy", + } + ] +) + +# Ce que le registre ajoute : la version, et le moment de démarrage qui +# distingue un pid recyclé d'un pid vivant. +REGISTRE = [ + { + "pid": PID, + "sessionId": VIVANTE, + "cwd": CHEMIN, + "procStart": DEMARRAGE, + "version": "9.9.999", + } +] + + +# Un extrait de « /proc//stat ». Le nom du programme est entre +# parenthèses et peut contenir des espaces : c'est ce qui interdit de compter +# les champs depuis le début de la ligne. +def stat_avec(demarrage): + # L'état est le champ 3 et le démarrage le champ 22 : entre les deux il y + # a donc DIX-HUIT champs de remplissage, et non dix-neuf. Un de trop + # déplacerait le démarrage au champ 23, et le test passerait ou + # échouerait pour une raison qui n'est pas la sienne. + champs = [str(n) for n in range(4, 22)] + return f"{PID} (claude code) S " + " ".join(champs) + f" {demarrage} 0 0" + + +# Un marqueur posé dans un MESSAGE. Il ne doit ressortir d'aucun champ : le +# listage ne lit que la structure. +# +# Le nom compte. La valeur est un TÉMOIN, inventé, et sa présence en clair +# dans la transcription d'essai est le sujet même du test : sans elle, rien ne +# prouverait que le listage ne la recopie pas. L'appeler « secret » décrivait +# donc l'inverse de ce qu'elle est, et un analyseur qui juge sur le nom +# signalait une fixture comme un dépôt de données sensibles. +MARQUEUR = "marqueur-de-message-qui-ne-doit-pas-sortir" + +TRANSCRIPT = [ + json.dumps({"type": "custom-title", "customTitle": MARQUEUR}) + "\n", + json.dumps( + { + "type": "user", + "cwd": CHEMIN, + "gitBranch": "dev/essai", + "message": {"role": "user", "content": MARQUEUR}, + } + ) + + "\n", +] + + +class LaVivacite(unittest.TestCase): + """Un pid vivant ne suffit pas : le démarrage tranche.""" + + def test_un_pid_absent_n_est_pas_vivant(self): + self.assertFalse(CS.is_live(PID, DEMARRAGE, read_stat=lambda pid: "")) + + def test_un_pid_vivant_au_bon_demarrage_est_vivant(self): + self.assertTrue( + CS.is_live( + PID, DEMARRAGE, read_stat=lambda pid: stat_avec(DEMARRAGE) + ) + ) + + def test_un_pid_recycle_n_est_pas_vivant(self): + """Le cas que le pid seul laisserait passer : le processus existe, + mais ce n'est plus celui que le registre y attachait.""" + self.assertFalse( + CS.is_live( + PID, DEMARRAGE, read_stat=lambda pid: stat_avec("111111") + ) + ) + + def test_sans_demarrage_connu_la_presence_du_pid_suffit(self): + """Le prétendre mort serait plus faux que de le croire vivant : le + listage de l'outil affirme déjà qu'il tourne.""" + self.assertTrue( + CS.is_live(PID, None, read_stat=lambda pid: stat_avec("0")) + ) + + def test_un_nom_de_programme_a_espaces_ne_decale_pas_le_champ(self): + """Le nom est entre parenthèses et peut contenir des espaces, ce qui + interdit de compter les champs depuis le début de la ligne.""" + ligne = f"{PID} (un nom avec des espaces) S " + " ".join( + str(n) for n in range(3, 22) + ) + self.assertTrue(CS.is_live(PID, "21", read_stat=lambda pid: ligne)) + + +class LeListageDesVivantes(unittest.TestCase): + """Ce que le registre et le listage de l'outil rendent ensemble.""" + + def _live(self, agents=AGENTS, registre=None, demarrage=DEMARRAGE): + return CS.live( + run=lambda argv: agents, + read_registry=lambda: (REGISTRE if registre is None else registre), + read_stat=lambda pid: stat_avec(demarrage), + ) + + def test_une_session_vivante_se_lit_dans_le_registre(self): + sessions = self._live() + self.assertEqual(len(sessions), 1) + session = sessions[0] + self.assertEqual(session.session_id, VIVANTE) + self.assertEqual(session.pid, PID) + self.assertEqual(session.kind, "interactive") + self.assertEqual(session.status, "busy") + self.assertEqual(session.version, "9.9.999") + self.assertTrue(session.live) + + def test_une_session_occupee_est_etiquetee_avant_d_etre_proposee(self): + """L'état décide du risque : une session occupée acceptera quand même + une écriture, et se mettra en concurrence avec elle-même.""" + self.assertEqual(self._live()[0].status, "busy") + + def test_un_registre_absent_ne_cache_pas_la_session(self): + """Sans entrée de registre il n'y a ni version ni démarrage, mais le + listage de l'outil affirme la session : elle reste.""" + session = self._live(registre=[])[0] + self.assertEqual(session.session_id, VIVANTE) + self.assertEqual(session.version, "") + self.assertTrue(session.live) + + def test_un_outil_absent_rend_une_liste_vide(self): + self.assertEqual(self._live(agents=""), []) + + def test_un_listage_illisible_rend_une_liste_vide(self): + self.assertEqual(self._live(agents="pas du json"), []) + + def test_un_listage_qui_n_est_pas_une_liste_rend_une_liste_vide(self): + self.assertEqual(self._live(agents='{"pid": 1}'), []) + + def test_le_pid_detenteur_ne_se_donne_que_pour_une_vivante(self): + vivante = self._live()[0] + self.assertEqual(CS.held_by(vivante), str(PID)) + dormante = CS.Session(session_id=DORMANTE, pid=PID, live=False) + self.assertEqual(CS.held_by(dormante), "") + + +class LesTranscriptions(unittest.TestCase): + """Deux champs de structure, et rien d'autre.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.racine = Path(self.tmp.name) + # Le nom de répertoire est la transformation À PERTE du chemin : le + # séparateur, le point et le tiret bas y sont tous devenus des tirets. + projet = self.racine / "-opt-atelier-projet-essai-v2" + projet.mkdir() + (projet / f"{DORMANTE}.jsonl").write_text("".join(TRANSCRIPT)) + + def _resumable(self): + return CS.resumable( + projects_root=self.racine, read_head=lambda chemin: TRANSCRIPT + ) + + def test_le_repertoire_vient_du_transcript_pas_du_slug(self): + """Le nom du répertoire a perdu le point, le tiret bas et les + séparateurs ; seul le transcript porte le chemin exact.""" + session = self._resumable()[0] + self.assertEqual(session.cwd, CHEMIN) + self.assertEqual(session.branch, "dev/essai") + + def test_aucun_contenu_de_message_ne_ressort(self): + """La frontière que le système a posée sur le répertoire : le listage + lit la structure, jamais ce qui a été dit.""" + sessions = self._resumable() + self.assertTrue(sessions, "aucune session lue") + for session in sessions: + for valeur in vars(session).values(): + self.assertNotIn(MARQUEUR, str(valeur)) + + def test_aucun_titre_de_transcript_n_est_affiche(self): + vue = CS.displayable(self._resumable()[0]) + for valeur in vue.values(): + self.assertNotIn(MARQUEUR, str(valeur)) + + def test_une_transcription_n_est_pas_chargee_en_entier(self): + """Une transcription se compte en mégaoctets : la lecture s'arrête en + tête, et le nombre de lignes lues est borné.""" + lignes = CS._lire_en_tete(next(self.racine.glob("*/*.jsonl"))) + self.assertLessEqual(len(lignes), CS.LIGNES_EN_TETE) + + def test_une_ligne_illisible_n_arrete_pas_la_lecture(self): + abimee = ["{ pas du json\n"] + TRANSCRIPT + faits = CS._structure(abimee) + self.assertEqual(faits.get("cwd"), CHEMIN) + + def test_une_racine_absente_rend_une_liste_vide(self): + self.assertEqual( + CS.resumable(projects_root=self.racine / "nulle-part"), [] + ) + + +class LaFlotte(unittest.TestCase): + """Les deux listages se recouvrent : la fusion garde la vivante.""" + + def _fleet(self): + return CS.fleet( + run=lambda argv: AGENTS, + read_registry=lambda: REGISTRE, + read_stat=lambda pid: stat_avec(DEMARRAGE), + projects_root="/nulle-part", + read_head=lambda chemin: TRANSCRIPT, + ) + + def test_une_session_ne_parait_pas_deux_fois(self): + sessions = CS.fleet( + run=lambda argv: AGENTS, + read_registry=lambda: REGISTRE, + read_stat=lambda pid: stat_avec(DEMARRAGE), + projects_root="/nulle-part", + ) + identifiants = [session.session_id for session in sessions] + self.assertEqual(len(identifiants), len(set(identifiants))) + + def test_les_vivantes_ouvrent_la_liste(self): + """Ce sont celles où écrire coûte quelque chose.""" + sessions = self._fleet() + self.assertTrue(sessions) + vivantes = [s.live for s in sessions] + self.assertEqual(vivantes, sorted(vivantes, reverse=True)) + + +class LAffichage(unittest.TestCase): + """Ce qui a le droit de paraître, et sous quelle forme.""" + + SESSION = CS.Session( + session_id=VIVANTE, + pid=PID, + kind="interactive", + status="idle", + cwd=CHEMIN, + name="atelier", + version="9.9.999", + branch="dev/essai", + live=True, + ) + + def test_l_identifiant_est_reduit_a_son_prefixe(self): + self.assertEqual(CS.displayable(self.SESSION)["id"], VIVANTE[:8]) + + def test_le_repertoire_est_reduit_a_son_dernier_segment(self): + """Un chemin complet porte un nom de compte, que le détecteur du + dépôt compte parmi les données identifiantes.""" + vue = CS.displayable(self.SESSION) + self.assertEqual(vue["dir"], "projet.essai_v2") + self.assertNotIn("/", vue["dir"]) + + def test_un_repertoire_vide_ne_devient_pas_un_point(self): + vide = CS.Session(session_id=VIVANTE, cwd="") + self.assertEqual(CS.displayable(vide)["dir"], "") + + +class LaFrontiere(unittest.TestCase): + """Le paquet doit rester importable sans le CLI.""" + + def test_le_listage_n_importe_pas_todo(self): + self.assertNotIn("script.todo.todo", sys.modules) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_assistant_context.py b/test/test_assistant_context.py new file mode 100644 index 0000000..1000848 --- /dev/null +++ b/test/test_assistant_context.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ce que le contexte déclaré d'un gpt n'a pas le droit de lire, ni de taire. + +Deux portes se contournent par le nom, et ce fichier les ferme. « Suivi par +git » et « ignoré par git » n'en sont ni l'une ni l'autre : `private/` est +partiellement suivi, `tasks/` n'est pas dans `.gitignore`. Et un lien +symbolique au nom anodin pointant dans `private/` traverserait une liste de +refus comparée sur le nom — d'où la résolution du chemin RÉEL avant toute +comparaison. + +Le filtre du dépôt reconnaît les adresses, les courriels et les chemins de +compte. Il ne reconnaît PAS les noms — d'hôte, de client, de base — sauf si +une liste les énumère, et cette liste n'existe pas d'ordinaire. Une absence +de trouvaille ne prouve donc rien sur les noms, et la porte doit refuser un +tiers dans ce cas plutôt que de laisser croire à un contrôle complet. + +Aucun test ne lit le disque de la machine ni ne lance de vraie commande : la +racine, le lecteur, le lanceur et la liste des termes sont tous injectés. Les +valeurs identifiantes des cas sont INVENTÉES — une règle qui interdit de +nommer ne se cite pas elle-même en clair, et un test fige pour toujours ce +qu'il porte. +""" +from __future__ import annotations + +import os +import sys +import tempfile +import unittest +from pathlib import Path + +RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if RACINE not in sys.path: + sys.path.insert(0, RACINE) + +from script.todo.assistant import context as C # noqa: E402 + +# Une adresse et un compte INVENTÉS, vérifiés absents du reste du dépôt. +# +# L'adresse n'est PAS prise dans un bloc documentaire : le filtre écarte +# volontairement ceux-là, donc une adresse d'exemple y serait trop sage pour +# l'exercer. Elle est dans une plage privée, ce qui la rend indiscernable +# d'une machine réelle POUR LE FILTRE — et c'est ce qu'un test de filtre doit +# lui présenter — tout en n'en désignant aucune. +ADRESSE = "10.83.4.19" +COMPTE = "/home/quelquun-invente/notes" + +# Une commande de la liste d'autorisation, et une qui n'y est pas. +PERMISE = ["git", "log", "-3"] +REFUSEE = ["curl", "http://ailleurs.invalid"] + + +class LesCheminsRefuses(unittest.TestCase): + """La liste de refus, résolue sur le chemin réel.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.base = Path(self.tmp.name).resolve() + for dossier in ("private", "tasks", ".git", ".ssh", ".venv.erplibre"): + (self.base / dossier).mkdir() + (self.base / dossier / "dedans.txt").write_text("x") + (self.base / "ordinaire.txt").write_text("du contenu") + (self.base / "coffre.kdbx").write_text("x") + + def _refus(self, chemin): + with self.assertRaises(C.ContextRefused) as pris: + C.resolve_file(chemin, root=self.base) + return str(pris.exception) + + def test_private_est_refuse_meme_s_il_est_suivi_par_git(self): + """`private/` porte des fichiers suivis : « est-ce ignoré » n'est + donc pas la question, et ne peut pas être la porte.""" + self.assertIn("private", self._refus("private/dedans.txt")) + + def test_tasks_est_refuse_meme_s_il_n_est_pas_gitignore(self): + """`tasks/` n'est pas dans `.gitignore` : il n'est pas suivi parce + qu'il n'existe pas encore, ce qui n'est pas une garantie.""" + self.assertIn("tasks", self._refus("tasks/dedans.txt")) + + def test_le_depot_git_le_coffre_ssh_et_le_venv_sont_refuses(self): + for chemin in ( + ".git/dedans.txt", + ".ssh/dedans.txt", + ".venv.erplibre/dedans.txt", + ): + self.assertTrue(self._refus(chemin)) + + def test_un_chemin_de_kdbx_est_refuse(self): + self.assertIn("suffixe", self._refus("coffre.kdbx")) + + def test_un_chemin_hors_du_depot_est_refuse(self): + """« ../ » est le chemin le plus court vers le répertoire personnel.""" + self.assertIn("hors du dépôt", self._refus("../ailleurs.txt")) + + def test_un_lien_symbolique_vers_private_est_refuse(self): + """Le cas que la comparaison sur le nom laisserait passer : le nom du + lien est anodin, sa cible ne l'est pas.""" + lien = self.base / "innocent.txt" + lien.symlink_to(self.base / "private" / "dedans.txt") + self.assertIn("private", self._refus("innocent.txt")) + + def test_un_chemin_ordinaire_passe(self): + """Sans ce cas, une liste qui refuse tout passerait les autres.""" + reel = C.resolve_file("ordinaire.txt", root=self.base) + self.assertEqual(reel.name, "ordinaire.txt") + + +class LesCommandes(unittest.TestCase): + """Un argv, jamais une chaîne, et jamais hors de la liste.""" + + def test_une_commande_est_un_argv_jamais_une_chaine_shell(self): + """Une chaîne voudrait dire qu'un interpréteur la relira.""" + with self.assertRaises(C.ContextRefused) as pris: + C.check_argv("git diff") + self.assertIn("liste", str(pris.exception)) + + def test_une_commande_vide_est_refusee(self): + with self.assertRaises(C.ContextRefused): + C.check_argv([]) + + def test_un_metacaractere_dans_un_argument_est_refuse_pas_echappe(self): + """Sans interpréteur il n'y a rien à injecter : ce refus dit que + l'auteur croyait écrire du shell, donc que son gpt ne fera pas ce + qu'il voulait.""" + with self.assertRaises(C.ContextRefused) as pris: + C.check_argv(["git", "diff", "--cached;whoami"]) + self.assertIn("métacaractère", str(pris.exception)) + + def test_une_commande_hors_liste_est_refusee(self): + with self.assertRaises(C.ContextRefused) as pris: + C.check_argv(REFUSEE) + self.assertIn("autorisation", str(pris.exception)) + + def test_un_argument_non_textuel_est_refuse(self): + with self.assertRaises(C.ContextRefused): + C.check_argv(["git", "log", 3]) + + def test_une_commande_permise_est_rendue_telle_quelle(self): + self.assertEqual(C.check_argv(PERMISE), tuple(PERMISE)) + + def test_la_liste_d_autorisation_n_est_pas_vide(self): + """Une liste vide refuserait tout, et les tests de refus passeraient + pour la mauvaise raison.""" + self.assertTrue(C.AUTORISEES) + + +class LesPlafonds(unittest.TestCase): + """Un contexte qu'on ne peut plus relire n'est plus déclaré.""" + + def test_la_coupe_se_fait_sur_une_frontiere_de_ligne_avec_une_marque( + self, + ): + texte = "\n".join(f"ligne {i}" for i in range(500)) + borne = C.borner(texte, 100) + self.assertLess(len(borne), 140) + self.assertIn(C.MARQUE_COUPE, borne) + corps = borne.replace("\n" + C.MARQUE_COUPE, "") + self.assertTrue(corps) + for ligne in corps.splitlines(): + self.assertTrue(ligne.startswith("ligne "), ligne) + + def test_un_texte_sous_le_plafond_n_est_pas_touche(self): + self.assertEqual(C.borner("court", 100), "court") + + def test_le_plafond_total_arrete_l_assemblage_et_le_dit(self): + gros = "x" * (C.MAX_PAR_SOURCE + 500) + with tempfile.TemporaryDirectory() as dossier: + base = Path(dossier).resolve() + noms = [] + for rang in range(6): + nom = f"source{rang}.txt" + (base / nom).write_text(gros) + noms.append(nom) + texte, _ = C.assemble(files=noms, termes=(), root=base) + self.assertLessEqual(len(texte), C.MAX_TOTAL + 400) + self.assertIn(C.MARQUE_COUPE, texte) + + +class LeBalayage(unittest.TestCase): + """Ce que le filtre voit, et ce qu'il ne voit pas.""" + + def _assembler(self, contenu, termes=()): + with tempfile.TemporaryDirectory() as dossier: + base = Path(dossier).resolve() + (base / "source.txt").write_text(contenu) + return C.assemble(files=["source.txt"], termes=termes, root=base) + + def test_une_adresse_et_un_chemin_de_compte_sont_trouves(self): + texte, trouvailles = self._assembler( + f"la machine {ADRESSE} et {COMPTE}/x" + ) + self.assertTrue(trouvailles, "le filtre n'a rien vu") + motifs = {t["motif"] for t in trouvailles} + self.assertIn("adresse", motifs) + self.assertIn("compte", motifs) + for trouvaille in trouvailles: + self.assertEqual(trouvaille["source"], "source.txt") + + def test_un_nom_d_hote_n_est_pas_vu_sans_liste_de_termes(self): + """La limite à connaître : le filtre ne reconnaît pas les noms. Sans + liste, une absence de trouvaille ne prouve rien à leur sujet.""" + _, trouvailles = self._assembler("serveur-invente-01", termes=()) + self.assertEqual(trouvailles, []) + + def test_un_nom_d_hote_est_vu_quand_la_liste_le_nomme(self): + _, trouvailles = self._assembler( + "serveur-invente-01", termes=("serveur-invente-01",) + ) + self.assertTrue(trouvailles) + + def test_un_fichier_refuse_est_annonce_pas_tu(self): + """Un contexte amputé en silence fait répondre le modèle sur ce + qu'il n'a pas reçu.""" + with tempfile.TemporaryDirectory() as dossier: + base = Path(dossier).resolve() + (base / "private").mkdir() + (base / "private" / "x.txt").write_text("secret") + texte, _ = C.assemble( + files=["private/x.txt"], termes=(), root=base + ) + self.assertIn("private", texte) + self.assertNotIn("secret", texte) + + def test_un_fichier_absent_est_annonce_pas_tu(self): + with tempfile.TemporaryDirectory() as dossier: + texte, _ = C.assemble( + files=["nulle-part.txt"], termes=(), root=Path(dossier) + ) + self.assertIn("nulle-part.txt", texte) + + +class LesCommandesAssemblees(unittest.TestCase): + """Le lanceur est injecté : aucun sous-processus ne part d'un test.""" + + def test_la_sortie_d_une_commande_entre_dans_le_contexte(self): + appels = [] + + def lanceur(argv, delai): + appels.append((tuple(argv), delai)) + return "trois lignes\nde sortie\nici" + + texte, _ = C.assemble( + commands=[{"label": "Journal", "argv": PERMISE}], + run=lanceur, + termes=(), + ) + self.assertEqual(len(appels), 1) + self.assertEqual(appels[0][0], tuple(PERMISE)) + self.assertIn("Journal", texte) + self.assertIn("de sortie", texte) + + def test_le_libelle_de_l_auteur_nomme_la_commande(self): + """C'est cet aperçu que l'utilisateur relit avant d'envoyer.""" + texte, _ = C.assemble( + commands=[{"label": "Trouvailles", "argv": PERMISE}], + run=lambda argv, delai: "", + termes=(), + ) + self.assertIn("Trouvailles", texte) + + def test_une_commande_refusee_est_annoncee_et_non_lancee(self): + appels = [] + texte, _ = C.assemble( + commands=[{"argv": REFUSEE}], + run=lambda argv, delai: appels.append(argv) or "", + termes=(), + ) + self.assertEqual(appels, []) + self.assertIn("autorisation", texte) + + def test_une_commande_qui_leve_est_annoncee_pas_fatale(self): + def lanceur(argv, delai): + raise OSError("commande absente") + + texte, _ = C.assemble( + commands=[{"argv": PERMISE}], run=lanceur, termes=() + ) + self.assertIn("commande absente", texte) + + def test_le_delai_total_borne_les_commandes(self): + """Le budget est TOTAL : trois commandes ne peuvent pas prendre trois + fois le délai d'une seule.""" + delais = [] + C.assemble( + commands=[{"argv": PERMISE} for _ in range(4)], + run=lambda argv, delai: delais.append(delai) or "", + termes=(), + ) + self.assertTrue(delais) + self.assertLessEqual(sum(delais), C.DELAI_TOTAL) + + +class LaPorte(unittest.TestCase): + """Qui reçoit décide de ce qu'une trouvaille autorise.""" + + TROUVE = [ + { + "source": "s", + "motif": "adresse", + "extrait": ADRESSE, + "position": 0, + } + ] + + def test_rien_a_signaler_passe(self): + self.assertEqual(C.gate([], "loopback")[0], C.OK) + self.assertEqual(C.gate([], "lan")[0], C.OK) + + def test_une_trouvaille_avertit_seulement_en_local(self): + """Rien ne quitte la machine : l'opérateur décide chez lui.""" + for hote in ("loopback", "lan"): + verdict, cle = C.gate(self.TROUVE, hote) + self.assertEqual(verdict, C.AVERTIR, hote) + self.assertTrue(cle) + + def test_une_trouvaille_bloque_un_tiers_sans_passe_droit(self): + verdict, cle = C.gate(self.TROUVE, "global") + self.assertEqual(verdict, C.BLOQUER) + self.assertEqual(cle, C.TROUVAILLE_BLOQUE_UN_TIERS) + + def test_une_liste_de_noms_vide_refuse_un_tiers_meme_sans_trouvaille( + self, + ): + """La moitié « noms » du filtre est alors inerte : l'absence de + trouvaille ne prouve plus rien, donc elle n'autorise plus rien.""" + verdict, cle = C.gate([], "global", names_checkable=False) + self.assertEqual(verdict, C.BLOQUER) + self.assertEqual( + cle, + C.NOMS_INVERIFIABLES, + ) + + def test_une_liste_vide_n_empeche_pas_un_envoi_local(self): + """Le refus vise la divulgation, pas la lecture : en local il n'y a + personne à qui divulguer.""" + self.assertEqual( + C.gate([], "loopback", names_checkable=False)[0], C.OK + ) + + +class LaFrontiere(unittest.TestCase): + """Le paquet doit rester importable sans le CLI.""" + + def test_le_contexte_n_importe_pas_todo(self): + self.assertNotIn("script.todo.todo", sys.modules) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_assistant_conversation.py b/test/test_assistant_conversation.py new file mode 100644 index 0000000..1d599d5 --- /dev/null +++ b/test/test_assistant_conversation.py @@ -0,0 +1,637 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""L'historique tient-il, et une panne reste-t-elle lisible ? + +Le transport est un VRAI serveur de boucle locale, questionné par le VRAI +client `openai`. Un double du client ne rendrait que ce qu'on aurait imaginé en +l'écrivant, or ce qui casse une conversation vient du transport : une +connexion refusée, un corps d'erreur, un flux coupé. + +Ce que ces tests défendent, et ce qui casserait sans eux : + +- Le second tour porte le premier échange. Un backend sans mémoire qui + n'enverrait que la question courante ferait perdre le fil à chaque tour. +- L'inverse pour une session qui garde son histoire : lui rejouer la nôtre + doublerait chaque échange et ferait payer deux fois les mêmes jetons. +- Une commande porte une barre oblique, et aucun nombre nu n'en est une. Une + question collée sur plusieurs lignes dont l'une vaut « 0 » déclencherait + sinon une action de menu. +- L'invite ne part JAMAIS sur l'argv de `claude` : un argv se lit par + n'importe quel compte local dès que `/proc` est monté sans `hidepid`. +- La lecture seule tient par des drapeaux, pas par une phrase d'invite. + +Rien ici n'ouvre de socket sortante, ne lance de vrai `claude`, ni ne lit la +configuration de la machine : le serveur est lié à la boucle locale sur un +port choisi par le système, et le lanceur de sous-processus est injecté. +""" +import json +import os +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +import openai # noqa: E402 +from llm_fake_server import FakeLLM, port_ferme # noqa: E402 + +from script.todo.assistant.backends import ( # noqa: E402 + ClaudeCliBackend, + HttpBackend, + Interrupted, + claude_argv, +) +from script.todo.assistant.chat import ( # noqa: E402 + COMMANDS, + Conversation, + parse_command, +) + +MODELE = "petit-modele:7b" + + +def completion(texte): + """Le corps d'une réponse `/v1/chat/completions`, valeurs inventées.""" + return json.dumps( + { + "id": "chatcmpl-0", + "object": "chat.completion", + "created": 0, + "model": MODELE, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": texte}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 3, + "completion_tokens": 2, + "total_tokens": 5, + }, + } + ).encode() + + +def fragment(texte, fin=None): + """Un événement de flux, à la forme des serveurs compatibles OpenAI.""" + return ( + b"data: " + + json.dumps( + { + "id": "chatcmpl-0", + "object": "chat.completion.chunk", + "created": 0, + "model": MODELE, + "choices": [ + { + "index": 0, + "delta": {} if fin else {"content": texte}, + "finish_reason": fin, + } + ], + } + ).encode() + + b"\n\n" + ) + + +# Le corps d'erreur cite un modèle absent : c'est la panne la plus courante +# d'un serveur local, et son message est la seule chose qui dise quoi faire. +CORPS_ERREUR = ( + b'{"error":{"message":"model \'absent\' not found, pull it first",' + b'"type":"api_error"}}' +) + +FLUX = fragment("bon") + fragment("jour") + fragment("", "stop") +FLUX += b"data: [DONE]\n\n" + +# Un seul serveur pour tout le fichier, trois préfixes de chemin : le client +# `openai` ajoute « /chat/completions » à la racine qu'on lui donne, donc la +# racine choisie décide de la réponse servie. Un serveur de plus coûterait un +# demi-seconde d'arrêt. +ROUTES = { + "/v1/chat/completions": (200, completion("bonjour")), + "/panne/chat/completions": (500, CORPS_ERREUR), + "/flux/chat/completions": (200, FLUX), + "/routeur/chat/completions": ( + 200, + b"Administration", + ), + "/vide/chat/completions": (200, b'{"choices":[]}'), + "/muet/chat/completions": ( + 200, + b'{"choices":[{"index":0,"finish_reason":"length",' + b'"message":{"role":"assistant","content":""}}]}', + ), +} + + +def longueur(entete): + """La taille du corps annoncée, quelle que soit la casse de l'en-tête.""" + for cle, valeur in entete.items(): + if cle.lower() == "content-length": + return int(valeur) + return 0 + + +class Enregistreur: + """Un backend qui garde ce qu'on lui envoie, sans mémoire propre. + + Il implémente le protocole plutôt que de le simuler : ce qui est vérifié + ici est la FORME des messages, et un serveur ne la rend pas visible. + """ + + keeps_history = False + + def __init__(self, reponse="bonjour"): + self.recus = [] + self.reponse = reponse + + def send(self, messages, *, on_chunk=None): + self.recus.append([dict(message) for message in messages]) + if on_chunk is not None: + on_chunk(self.reponse) + return self.reponse, {"model": "modele-de-test"} + + +class SessionQuiGarde(Enregistreur): + """Un backend qui tient son histoire de son côté, comme `claude`.""" + + keeps_history = True + + +class Coupure: + """Un backend coupé en pleine réponse, qui rend ce qui était arrivé.""" + + keeps_history = False + + def __init__(self, partiel): + self.partiel = partiel + + def send(self, messages, *, on_chunk=None): + if on_chunk is not None and self.partiel: + on_chunk(self.partiel) + raise Interrupted(self.partiel, {"model": MODELE}) + + +class MainLevee: + """Un backend interrompu avant le premier octet.""" + + keeps_history = False + + def send(self, messages, *, on_chunk=None): + raise KeyboardInterrupt + + +class AllerRetour(unittest.TestCase): + """Le vrai client contre un vrai serveur : la réponse ET les pannes. + + Les deux sont des faits de transport, et le serveur est monté une fois + pour le fichier — son arrêt coûte une demi-seconde. + """ + + @classmethod + def setUpClass(cls): + cls.faux = FakeLLM(routes=ROUTES) + cls.faux.__enter__() + cls.client = openai.OpenAI( + base_url=f"{cls.faux.url}/v1", api_key="cle-de-test", timeout=5 + ) + cls.client_panne = openai.OpenAI( + base_url=f"{cls.faux.url}/panne", + api_key="cle-de-test", + timeout=5, + max_retries=0, + ) + cls.client_flux = openai.OpenAI( + base_url=f"{cls.faux.url}/flux", api_key="cle-de-test", timeout=5 + ) + cls.client_routeur = openai.OpenAI( + base_url=f"{cls.faux.url}/routeur", + api_key="cle-de-test", + timeout=5, + ) + cls.client_muet = openai.OpenAI( + base_url=f"{cls.faux.url}/muet", api_key="cle-de-test", timeout=5 + ) + cls.client_vide = openai.OpenAI( + base_url=f"{cls.faux.url}/vide", api_key="cle-de-test", timeout=5 + ) + + @classmethod + def tearDownClass(cls): + cls.faux.__exit__(None, None, None) + + def setUp(self): + self.depart = len(self.faux.seen) + + def vues(self): + """Les requêtes reçues depuis le début de ce test.""" + return self.faux.seen[self.depart :] + + def test_une_reponse_traverse_le_vrai_client(self): + conversation = Conversation( + HttpBackend(None, MODELE, client=self.client) + ) + tour = conversation.ask("salut") + self.assertEqual(tour.role, "assistant") + self.assertEqual(tour.text, "bonjour") + self.assertEqual(conversation.last_meta["model"], MODELE) + self.assertEqual([v[0] for v in self.vues()], ["POST"]) + + def test_le_second_tour_porte_le_premier_echange(self): + conversation = Conversation( + HttpBackend(None, MODELE, client=self.client) + ) + conversation.ask("salut") + conversation.ask("encore") + self.assertEqual( + [(m["role"], m["content"]) for m in conversation.last_sent], + [ + ("user", "salut"), + ("assistant", "bonjour"), + ("user", "encore"), + ], + ) + vues = self.vues() + self.assertEqual(len(vues), 2, "deux envois attendus") + self.assertGreater( + longueur(vues[1][2]), + longueur(vues[0][2]), + "le second corps doit porter l'échange précédent", + ) + + def test_les_fragments_forment_le_texte_rendu(self): + recus = [] + conversation = Conversation( + HttpBackend(None, MODELE, client=self.client_flux) + ) + tour = conversation.ask("salut", on_chunk=recus.append) + self.assertTrue(recus, "aucun fragment reçu") + self.assertEqual("".join(recus), tour.text) + self.assertEqual(tour.text, "bonjour") + self.assertEqual(conversation.last_meta["finish_reason"], "stop") + + def test_une_connexion_refusee_est_un_message_pas_une_trace(self): + mort = openai.OpenAI( + base_url=f"http://127.0.0.1:{port_ferme()}/v1", + api_key="cle-de-test", + timeout=2, + max_retries=0, + ) + conversation = Conversation(HttpBackend(None, MODELE, client=mort)) + tour = conversation.ask("salut") + self.assertEqual(tour.role, "error") + self.assertIn("refused", tour.text.lower()) + self.assertNotIn("Traceback", tour.text) + self.assertNotIn("\n", tour.text) + self.assertEqual(conversation.turns, []) + + def test_un_corps_d_erreur_est_montre_pas_avale(self): + conversation = Conversation( + HttpBackend(None, MODELE, client=self.client_panne) + ) + tour = conversation.ask("salut") + self.assertEqual(tour.role, "error") + self.assertIn("500", tour.text) + self.assertIn("pull it first", tour.text) + self.assertEqual(conversation.turns, []) + + def test_une_page_html_est_un_message_pas_une_trace(self): + conversation = Conversation( + HttpBackend(None, MODELE, client=self.client_routeur) + ) + tour = conversation.ask("salut") + self.assertEqual(tour.role, "error") + self.assertIn("Administration", tour.text) + self.assertEqual(conversation.turns, []) + + def test_une_reponse_sans_choix_est_un_message(self): + conversation = Conversation( + HttpBackend(None, MODELE, client=self.client_vide) + ) + tour = conversation.ask("salut") + self.assertEqual(tour.role, "error") + self.assertIn("no choice", tour.text) + + def test_une_reponse_vide_est_un_message_pas_un_blanc(self): + """Un serveur dont le moteur de modèle s'arrête rend un 200 avec un + contenu VIDE. L'afficher tel quel se confond avec un modèle qui n'a + rien à dire, et une panne de ressources sur l'hôte se lit alors comme + un défaut du menu. La cause vit dans `finish_reason`, donc elle est + nommée.""" + conversation = Conversation( + HttpBackend(None, MODELE, client=self.client_muet) + ) + tour = conversation.ask("salut") + self.assertEqual(tour.role, "error") + self.assertIn("empty answer", tour.text) + self.assertIn("length", tour.text) + self.assertEqual(conversation.turns, []) + + def test_un_parametre_inconnu_est_nomme_pas_une_trace(self): + conversation = Conversation( + HttpBackend( + None, MODELE, client=self.client, params={"num_ctx": 4096} + ) + ) + tour = conversation.ask("salut") + self.assertEqual(tour.role, "error") + self.assertIn("num_ctx", tour.text) + self.assertEqual(self.vues(), [], "rien ne doit partir sur le réseau") + + +class Historique(unittest.TestCase): + """Qui garde les tours, qui les reçoit, et ce qu'il en reste.""" + + def test_l_historique_ne_vit_qu_en_memoire(self): + backend = Enregistreur() + conversation = Conversation(backend) + + def refuse(*args, **kwargs): + raise AssertionError(f"écriture interdite : {args!r}") + + with patch("builtins.open", side_effect=refuse): + conversation.ask("salut") + conversation.ask("encore") + texte = conversation.transcript() + self.assertIn("salut", texte) + self.assertIn("encore", texte) + self.assertEqual(len(conversation.turns), 4) + self.assertEqual( + Conversation(backend).turns, + [], + "une conversation neuve part vide : rien n'a été relu", + ) + + def test_un_backend_qui_garde_son_histoire_ne_recoit_pas_la_notre(self): + backend = SessionQuiGarde() + conversation = Conversation(backend, system="ne pas rejouer") + conversation.ask("salut") + conversation.ask("encore") + self.assertEqual(len(backend.recus), 2, "deux envois attendus") + for envoi in backend.recus: + self.assertEqual(len(envoi), 1, envoi) + self.assertEqual(envoi[0]["role"], "user") + self.assertEqual(backend.recus[1][0]["content"], "encore") + self.assertEqual(len(conversation.turns), 4) + + def test_l_invite_systeme_ouvre_l_envoi_d_un_backend_sans_memoire(self): + backend = Enregistreur() + Conversation(backend, system="tu es bref").ask("salut") + self.assertEqual(len(backend.recus), 1, "un envoi attendu") + self.assertEqual( + backend.recus[0][0], {"role": "system", "content": "tu es bref"} + ) + + def test_l_invite_systeme_du_gpt_sert_de_defaut(self): + class GptDeTest: + name = "Outil de test" + system = "tu réécris" + + backend = Enregistreur() + conversation = Conversation(backend, gpt=GptDeTest()) + conversation.ask("salut") + self.assertEqual(backend.recus[0][0]["content"], "tu réécris") + self.assertIn("Outil de test", conversation.transcript()) + + def test_vider_l_historique_rend_le_nombre_de_tours_jetes(self): + conversation = Conversation(Enregistreur()) + conversation.ask("salut") + conversation.ask("encore") + self.assertEqual(conversation.reset(), 4) + self.assertEqual(conversation.turns, []) + self.assertEqual(conversation.reset(), 0) + + def test_une_reponse_coupee_garde_ce_qui_est_arrive(self): + conversation = Conversation(Coupure("bonj")) + tour = conversation.ask("salut") + self.assertTrue(tour.interrupted) + self.assertEqual(tour.text, "bonj") + self.assertEqual(len(conversation.turns), 2) + self.assertIn("(interrupted)", conversation.transcript()) + + def test_une_coupure_sans_texte_ne_laisse_aucun_tour(self): + conversation = Conversation(MainLevee()) + tour = conversation.ask("salut") + self.assertTrue(tour.interrupted) + self.assertEqual(tour.text, "") + self.assertEqual(conversation.turns, []) + + def test_ce_qui_est_parti_reste_visible_apres_une_panne(self): + class Panne: + keeps_history = False + + def send(self, messages, *, on_chunk=None): + from script.todo.assistant.backends import BackendError + + raise BackendError("500 - server said no") + + conversation = Conversation(Panne(), system="tu es bref") + tour = conversation.ask("salut") + self.assertEqual(tour.role, "error") + self.assertEqual( + [m["role"] for m in conversation.last_sent], ["system", "user"] + ) + + +class Commandes(unittest.TestCase): + """Ce qui est une commande, et ce qui reste une question.""" + + def test_une_commande_exige_une_barre_oblique(self): + self.assertEqual(parse_command("/new"), ("/new", "")) + self.assertEqual(parse_command("new"), (None, "new")) + self.assertEqual(parse_command("q"), (None, "q")) + + def test_une_ligne_collee_valant_zero_n_est_pas_une_commande(self): + for ligne in ("0", "1", " 3 ", "0 "): + self.assertEqual(parse_command(ligne), (None, ligne), ligne) + + def test_une_question_qui_commence_par_un_chemin_reste_une_question(self): + ligne = "/v1/models rend quoi ?" + self.assertEqual(parse_command(ligne), (None, ligne)) + + def test_une_commande_porte_son_argument(self): + self.assertEqual( + parse_command("/gpt hygiene des commentaires"), + ("/gpt", "hygiene des commentaires"), + ) + + def test_chaque_commande_documentee_se_reconnait(self): + self.assertTrue(COMMANDS, "aucune commande déclarée") + for commande in COMMANDS: + self.assertEqual(parse_command(commande), (commande, "")) + self.assertTrue(COMMANDS[commande], commande) + + def test_une_ligne_vide_est_du_texte_pas_une_commande(self): + self.assertEqual(parse_command(""), (None, "")) + self.assertEqual(parse_command(" "), (None, " ")) + + +class ArgvClaude(unittest.TestCase): + """L'argv de `claude -p` : ce qu'il porte, et ce qu'il ne porte jamais.""" + + QUESTION = "quelle est la cause de cet échec" + + def test_l_argv_claude_ne_porte_jamais_l_invite(self): + argv = claude_argv(session_id="s-1", cwd="/depot", fork=True) + self.assertNotIn(self.QUESTION, argv) + self.assertNotIn(self.QUESTION, " ".join(argv)) + self.assertIn("-p", argv) + self.assertEqual( + argv[argv.index("--output-format") + 1], + "json", + "l'enveloppe JSON est la seule forme lisible par un programme", + ) + + def test_l_argv_lecture_seule_porte_tools_et_permission_mode(self): + argv = claude_argv(session_id=None, cwd="/depot", fork=False) + self.assertEqual(argv[argv.index("--tools") + 1], "Read,Glob,Grep") + self.assertEqual(argv[argv.index("--permission-mode") + 1], "dontAsk") + self.assertEqual(argv[argv.index("--add-dir") + 1], "/depot") + + def test_sans_lecture_seule_aucun_drapeau_d_outil(self): + argv = claude_argv( + session_id=None, cwd="/depot", fork=False, read_only=False + ) + for drapeau in ("--tools", "--permission-mode", "--add-dir"): + self.assertNotIn(drapeau, argv) + + def test_l_argv_par_defaut_branche_une_copie(self): + argv = claude_argv(session_id="s-1", cwd=None, fork=True) + self.assertEqual(argv[argv.index("--resume") + 1], "s-1") + self.assertIn("--fork-session", argv) + + def test_sans_fork_l_argv_ecrit_dans_la_session_nommee(self): + argv = claude_argv(session_id="s-1", cwd=None, fork=False) + self.assertIn("--resume", argv) + self.assertNotIn("--fork-session", argv) + + def test_sans_session_il_n_y_a_rien_a_brancher(self): + argv = claude_argv(session_id=None, cwd=None, fork=True) + self.assertNotIn("--fork-session", argv) + self.assertNotIn("--resume", argv) + + +def enveloppe(resultat, session="s-2", erreur=False): + """Une enveloppe `claude -p --output-format json`, valeurs inventées.""" + return json.dumps( + { + "session_id": session, + "result": resultat, + "is_error": erreur, + "num_turns": 1, + "total_cost_usd": 0.01, + "modelUsage": { + "modele-de-test": { + "contextWindow": 200000, + "maxOutputTokens": 8192, + } + }, + } + ) + + +class SessionClaude(unittest.TestCase): + """Le backend `claude` : par où part l'invite, et quelle session répond.""" + + def lanceur(self, sorties): + """Un lanceur injecté qui garde (argv, stdin) de chaque appel.""" + self.appels = [] + + def run(argv, stdin_text): + self.appels.append((list(argv), stdin_text)) + return sorties.pop(0) + + return run + + def test_l_invite_part_sur_l_entree_standard(self): + backend = ClaudeCliBackend( + session_id="s-1", + cwd="/depot", + run=self.lanceur([(0, enveloppe("la cause est le cache"), "")]), + ) + texte, faits = backend.send( + [{"role": "user", "content": "quelle cause"}] + ) + self.assertEqual(texte, "la cause est le cache") + argv, entree = self.appels[0] + self.assertEqual(entree, "quelle cause") + self.assertNotIn("quelle cause", argv) + self.assertNotIn("quelle cause", " ".join(argv)) + self.assertEqual( + faits["modelUsage"]["modele-de-test"]["contextWindow"], 200000 + ) + + def test_un_second_envoi_reprend_la_copie_sans_la_refourcher(self): + backend = ClaudeCliBackend( + session_id="s-1", + cwd=None, + run=self.lanceur( + [ + (0, enveloppe("un"), ""), + (0, enveloppe("deux"), ""), + ] + ), + ) + conversation = Conversation(backend) + conversation.ask("premier") + self.assertEqual(backend.session_id, "s-2") + self.assertFalse(backend.fork) + conversation.ask("second") + second = self.appels[1][0] + self.assertEqual(second[second.index("--resume") + 1], "s-2") + self.assertNotIn( + "--fork-session", + second, + "brancher à chaque tour perdrait le tour d'avant", + ) + + def test_le_fragment_unique_est_le_texte_rendu(self): + backend = ClaudeCliBackend( + run=self.lanceur([(0, enveloppe("une réponse"), "")]) + ) + recus = [] + texte, _ = backend.send( + [{"role": "user", "content": "salut"}], on_chunk=recus.append + ) + self.assertEqual(recus, ["une réponse"]) + self.assertEqual("".join(recus), texte) + + def test_une_enveloppe_en_erreur_est_un_message(self): + backend = ClaudeCliBackend( + run=self.lanceur( + [(1, enveloppe("budget dépassé", erreur=True), "")] + ) + ) + tour = Conversation(backend).ask("salut") + self.assertEqual(tour.role, "error") + self.assertIn("budget dépassé", tour.text) + + def test_une_sortie_qui_n_est_pas_du_json_cite_la_sortie(self): + backend = ClaudeCliBackend( + run=self.lanceur([(1, "", "unknown option --tools\n")]) + ) + tour = Conversation(backend).ask("salut") + self.assertEqual(tour.role, "error") + self.assertIn("unknown option", tour.text) + self.assertNotIn("\n", tour.text) + + def test_un_claude_absent_est_un_message_pas_un_plantage(self): + def introuvable(argv, stdin_text): + raise FileNotFoundError(2, "No such file or directory", "claude") + + backend = ClaudeCliBackend(run=introuvable) + tour = Conversation(backend).ask("salut") + self.assertEqual(tour.role, "error") + self.assertIn("PATH", tour.text) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_assistant_fingerprint.py b/test/test_assistant_fingerprint.py new file mode 100644 index 0000000..1a2f76e --- /dev/null +++ b/test/test_assistant_fingerprint.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""L'échelle de reconnaissance, sans un seul octet de réseau. + +Ce fichier ne sonde rien : il appelle `identify` sur les corps de référence de +`llm_fake_server.FIXTURES`, ceux-là mêmes que le faux serveur sert au +transport. Les deux moitiés lisent donc les MÊMES octets ; sans ce partage, +l'analyse serait vérifiée contre une idée du protocole et le transport contre +une autre, et l'écart ne se verrait qu'en production. + +Ce qu'il défend est l'ORDRE des étages, qui casse en silence. LocalAI sert +l'API native d'Ollama en entier, jusqu'à la chaîne « Ollama is running » sur +« / » : glisser l'étage LocalAI sous l'étage Ollama nomme « ollama » toutes +les machines LocalAI, sans lever, sans rien afficher d'anormal, et la +conversation part ensuite vers un serveur mal caractérisé. Le test le plus +important du fichier construit un corps qui satisfait les DEUX signatures et +exige « localai ». + +Le reste défend la règle « l'identité se lit dans le corps » : ni le port ni +le code de statut ne nomment un logiciel, et un corps HTML, vide ou coupé rend +une empreinte sans logiciel plutôt qu'une exception. +""" + +import unittest + +from llm_fake_server import FIXTURES + +from script.todo.assistant.fingerprint import ( + GPT4ALL_PORT, + OPENAI_HOST, + PORTS, + Fingerprint, + identify, + probe_plan, +) + +# Ce que chaque famille de référence doit se voir nommer. « gpt4all » et +# « routeur » n'y sont pas : le premier ne s'atteint que par son port et le +# second ne doit rien nommer du tout, donc chacun a son propre test. +EXPECTED = { + "ollama": "ollama", + "localai": "localai", + "localai_starting": "localai", + "llamacpp": "llamacpp", + "vllm": "vllm", + "lmstudio": "lmstudio", + "koboldcpp": "koboldcpp", + "jan": "jan", + "open_webui": "open_webui", + "textgen_webui": "textgen_webui", + "tabbyapi": "tabbyapi", +} + + +class Echelle(unittest.TestCase): + """L'ordre des étages, qui est la seule chose que le corps ne dit pas.""" + + def test_localai_est_ecarte_avant_qu_on_interroge_ollama(self): + double = {**FIXTURES["ollama"], **FIXTURES["localai"]} + # Le corps satisfait les deux signatures à la fois : c'est ce que + # rend une machine LocalAI, et rien dans l'API d'Ollama ne l'en + # distingue. + self.assertIn("/api/tags", double) + self.assertIn("/", double) + self.assertIn("/readyz", double) + self.assertEqual(identify(double, port=11434).software, "localai") + + def test_readyz_est_ce_qui_separe_localai_d_ollama(self): + double = {**FIXTURES["ollama"], **FIXTURES["localai"]} + sans_readyz = { + path: answer + for path, answer in double.items() + if path != "/readyz" + } + # Un mandataire qui masque `/readyz` retire le SEUL point qui écarte + # LocalAI : l'échelle ne doit alors plus prétendre le reconnaître. + self.assertNotEqual(identify(sans_readyz).software, "localai") + + def test_une_version_0_9_0_figee_est_localai_pas_ollama(self): + fp = identify(FIXTURES["localai"]) + self.assertEqual(fp.software, "localai") + # Le littéral que LocalAI sert sur `/api/version` ne décrit pas + # LocalAI : il n'est jamais rendu comme sa version, et il ne peut pas + # non plus servir à trancher, ce numéro-là ayant existé comme version + # réelle d'Ollama. + self.assertNotEqual(fp.version, "0.9.0") + self.assertIn("version", fp.unknown) + vrai = identify(FIXTURES["ollama"]) + self.assertEqual((vrai.software, vrai.version), ("ollama", "0.6.2")) + + def test_le_port_ne_decide_jamais_de_l_identite(self): + self.assertTrue(PORTS, "la liste des ports à frapper est vide") + for port in PORTS: + with self.subTest(port=port): + fp = identify(FIXTURES["llamacpp"], port=port) + self.assertEqual(fp.software, "llamacpp") + # Le port d'Ollama sans aucune réponse ne nomme pas Ollama. + self.assertEqual(identify({}, port=11434).software, "") + + def test_gpt4all_ne_s_atteint_que_par_elimination(self): + corps = FIXTURES["gpt4all"] + self.assertEqual( + identify(corps, port=GPT4ALL_PORT).software, "gpt4all" + ) + # Le même corps ailleurs ne nomme rien : l'étage est une élimination, + # pas une signature. + self.assertEqual(identify(corps, port=8080).software, "") + # Et l'élimination ne passe jamais devant un accord réel. + self.assertEqual( + identify(FIXTURES["koboldcpp"], port=GPT4ALL_PORT).software, + "koboldcpp", + ) + # Sur le bon port, une page qui n'est pas un serveur LLM ne devient + # pas un GPT4All par défaut. + self.assertEqual( + identify(FIXTURES["routeur"], port=GPT4ALL_PORT).software, "" + ) + + def test_ollama_survit_a_une_confirmation_absente_pas_a_un_dementi(self): + corps = FIXTURES["ollama"] + sans_version = { + path: answer + for path, answer in corps.items() + if path != "/api/version" + } + fp = identify(sans_version) + # Une confirmation qui manque laisse l'accord debout : l'inconnu ne + # retire rien, il se dit inconnu. + self.assertEqual(fp.software, "ollama") + self.assertIn("version", fp.unknown) + # Un démenti, lui, retire l'accord : du JSON étranger monté sous + # `/api/version` n'est pas un Ollama. + dementi = { + **corps, + "/api/version": (200, b'{"version":"pas un numero"}'), + } + self.assertEqual(identify(dementi).software, "") + + def test_openai_se_nomme_par_son_hote_sans_aucun_scan(self): + fp = identify({}, host=OPENAI_HOST) + self.assertEqual(fp.software, "openai") + self.assertEqual(identify({}, host="localhost").software, "") + + +class Signatures(unittest.TestCase): + """Le champ précis qui accorde chaque étage, et pas un champ voisin.""" + + def test_chaque_famille_de_reference_se_nomme(self): + self.assertTrue(EXPECTED, "la table des familles attendues est vide") + for famille, logiciel in EXPECTED.items(): + with self.subTest(famille=famille): + self.assertIn(famille, FIXTURES) + fp = identify(FIXTURES[famille], port=8080) + self.assertEqual(fp.software, logiciel) + + def test_koboldcpp_se_nomme_par_son_champ_result(self): + fp = identify(FIXTURES["koboldcpp"]) + self.assertEqual((fp.software, fp.version), ("koboldcpp", "0.0")) + autre = {"/api/extra/version": (200, b'{"result":"autre chose"}')} + self.assertEqual(identify(autre).software, "") + + def test_open_webui_se_nomme_par_deployment_id(self): + self.assertEqual( + identify(FIXTURES["open_webui"]).software, "open_webui" + ) + # `name` et `version` se retrouvent chez d'autres interfaces ; seul + # `deployment_id` accorde l'étage. + sans = {"/api/config": (200, b'{"name":"autre","version":"0.0.0"}')} + self.assertEqual(identify(sans).software, "") + + def test_llamacpp_se_nomme_par_build_info_avec_chat_template_caps(self): + fp = identify(FIXTURES["llamacpp"]) + self.assertEqual( + (fp.software, fp.version), ("llamacpp", "b9999-0000000") + ) + # Les deux clés sont exigées ensemble : `build_info` seul se recopie. + seul = {"/props": (200, b'{"build_info":"b1-0000000"}')} + self.assertEqual(identify(seul).software, "") + # Derrière un mandataire qui masque `/props`, `owned_by` reste. + proxy = {"/v1/models": FIXTURES["llamacpp"]["/v1/models"]} + self.assertEqual(identify(proxy).software, "llamacpp") + + def test_vllm_repond_sur_version_pas_api_version(self): + self.assertEqual(identify(FIXTURES["vllm"]).software, "vllm") + # `/api/version` appartient à Ollama et à LocalAI : le même corps sur + # ce chemin ne nomme pas vLLM. + ailleurs = {"/api/version": (200, b'{"version":"0.0.0"}')} + self.assertEqual(identify(ailleurs).software, "") + + def test_un_401_sur_le_chemin_model_singulier_est_un_accord(self): + fp = identify(FIXTURES["tabbyapi"]) + self.assertEqual(fp.software, "tabbyapi") + # Le défi porte sur `/v1/model` au singulier ; le pluriel est servi + # par onze serveurs sur douze et ne nomme personne. + pluriel = {"/v1/models": (401, b'{"detail":"Invalid API key"}')} + self.assertEqual(identify(pluriel).software, "") + + +class CorpsHostiles(unittest.TestCase): + """Ce qu'un serveur mal élevé rend, et qui doit rester un résultat.""" + + def test_une_page_de_routeur_sur_8080_ne_nomme_rien(self): + fp = identify(FIXTURES["routeur"], port=8080) + self.assertEqual(fp.software, "") + self.assertEqual(fp.models, ()) + self.assertIn("software", fp.unknown) + + def test_un_503_starting_est_vivant_pas_mort(self): + fp = identify(FIXTURES["localai_starting"], port=8080) + self.assertEqual(fp.software, "localai") + # Le statut ne nomme pourtant rien par lui-même : `/health` rend 200 + # chez trois serveurs et 503 pendant un chargement, sans distinguer + # personne. + self.assertEqual(identify(FIXTURES["llamacpp_loading"]).software, "") + + def test_un_corps_tronque_ne_leve_pas(self): + self.assertTrue(FIXTURES, "les corps de référence ont disparu") + for famille, corps in FIXTURES.items(): + coupe = { + path: (status, body[:12]) + for path, (status, body) in corps.items() + } + with self.subTest(famille=famille): + self.assertIsInstance(identify(coupe, port=8080), Fingerprint) + coupe = {"/props": (200, b'{"build_in')} + self.assertEqual(identify(coupe).software, "") + + def test_un_serveur_inconnu_ne_nomme_rien(self): + anonyme = { + "/v1/models": ( + 200, + b'{"object":"list","data":[{"id":"un-modele"}]}', + ) + } + fp = identify(anonyme, port=8000) + self.assertEqual(fp.software, "") + self.assertIn("software", fp.unknown) + # Le catalogue reste lisible : un point de terminaison anonyme mais + # compatible OpenAI est utilisable, il n'est simplement pas nommé. + self.assertEqual(fp.models, ("un-modele",)) + self.assertNotIn("models", fp.unknown) + + def test_une_table_vide_ne_leve_pas(self): + fp = identify({}) + self.assertEqual(fp.software, "") + self.assertEqual( + fp.unknown, frozenset({"software", "version", "models"}) + ) + + +class Plan(unittest.TestCase): + """Ce que la découverte s'autorise à émettre, avant toute socket.""" + + def test_le_plan_ne_contient_que_des_GET_sans_doublon(self): + plan = probe_plan() + self.assertTrue(plan, "le plan de sonde est vide") + self.assertEqual({methode for methode, _ in plan}, {"GET"}) + chemins = [chemin for _, chemin in plan] + self.assertEqual(len(chemins), len(set(chemins))) + + def test_readyz_precede_tout_ce_qui_ressemble_a_ollama(self): + chemins = [chemin for _, chemin in probe_plan()] + self.assertEqual(chemins[0], "/readyz") + for tardif in ("/api/tags", "/", "/api/version"): + self.assertLess(chemins.index("/readyz"), chemins.index(tardif)) + + def test_aucun_chemin_du_plan_ne_declenche_une_generation(self): + chemins = [chemin for _, chemin in probe_plan()] + # `/api/show` est un POST : il appartient à l'interrogation des + # capacités, après le choix d'un serveur. Un balayage qui l'émettrait + # pourrait charger un modèle. + self.assertNotIn("/api/show", chemins) + for interdit in ("/completion", "/v1/chat/completions", "/api/chat"): + self.assertNotIn(interdit, chemins) + + def test_les_ports_a_frapper_sont_uniques_et_portent_celui_de_gpt4all( + self, + ): + self.assertEqual(len(PORTS), len(set(PORTS))) + self.assertEqual(len(PORTS), 11) + self.assertIn(GPT4ALL_PORT, PORTS) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_assistant_gpt_loader.py b/test/test_assistant_gpt_loader.py new file mode 100644 index 0000000..169d56e --- /dev/null +++ b/test/test_assistant_gpt_loader.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ce que le chargeur de gpts doit refuser, et ce qu'il ne doit jamais taire. + +`yaml.safe_load` n'est pas un validateur, et c'est là que ce chargeur se +joue : un en-tête qui est une liste rend une `list`, un scalaire nu une `str`, +un fichier vide `None`, et une clé RÉPÉTÉE est résolue sans un mot sur la +dernière. Aucun des quatre ne lève. Un chargeur qui n'attraperait que +`yaml.YAMLError` admettrait donc trois formes malformées et tomberait plus +loin, sur un attribut manquant. + +La clé répétée est le cas qui coûte le plus cher : deux blocs `requires` font +passer un gpt de « boucle locale seulement » à « n'importe où », donc changent +sa classe de sûreté en silence. C'est pourquoi l'en-tête est relu en TEXTE +BRUT avant d'être analysé. + +Les cas malformés viennent avant le cas heureux, dans ce fichier comme dans +l'ordre d'écriture : un chargeur se juge sur ce qu'il refuse. + +Aucun test ne lit le disque de la machine : les racines sont des répertoires +temporaires, et le répertoire personnel est détourné là où il compte. +""" +from __future__ import annotations + +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if RACINE not in sys.path: + sys.path.insert(0, RACINE) + +from script.todo.assistant import gpt as G # noqa: E402 + +# Le corps minimal acceptable : le marqueur de question est obligatoire. +CORPS = "\n\nRéécris : {phrase}\n" + +# Un en-tête complet, dont chaque champ est exercé quelque part plus bas. +ENTETE = """--- +gpt: 1 +name: Comment hygiene - rewrite narrative as mechanism +description: Rewrite each flagged sentence so the code is the subject +requires: + hosting: lan + context_window: 8000 + parameters: 12 +params: + temperature: 0.2 + max_tokens: 700 +inputs: + - name: path + type: repo_path + required: true +context: + files: + - .claude/rules/04-code-conventions.md +---""" + + +def cles(problemes): + """Les clés de problème, pour affirmer sans dépendre d'un libellé.""" + return [souci.key for souci in problemes] + + +class CeQuiEstRefuse(unittest.TestCase): + """Les formes qu'un analyseur YAML laisse passer sans lever.""" + + def test_un_fichier_vide_est_refuse(self): + gpt, problemes = G.parse("", stem="essai") + self.assertIsNone(gpt) + self.assertEqual(cles(problemes), [G.SANS_ENTETE]) + + def test_sans_en_tete_est_un_probleme_liste(self): + gpt, problemes = G.parse("juste un corps\n", stem="essai") + self.assertIsNone(gpt) + self.assertEqual(cles(problemes), [G.SANS_ENTETE]) + + def test_un_en_tete_non_ferme_est_refuse(self): + gpt, problemes = G.parse("---\ngpt: 1\n" + CORPS, stem="essai") + self.assertIsNone(gpt) + self.assertEqual(cles(problemes), [G.ENTETE_NON_FERMEE]) + + def test_un_en_tete_qui_est_une_liste_est_refuse(self): + """`safe_load` rend une `list` sans lever : le type est donc contrôlé + avant toute lecture de champ.""" + gpt, problemes = G.parse( + "---\n- un\n- deux\n---" + CORPS, stem="essai" + ) + self.assertIsNone(gpt) + self.assertEqual(cles(problemes), [G.ENTETE_PAS_UN_DICTIONNAIRE]) + self.assertEqual(problemes[0].detail, "list") + + def test_un_en_tete_scalaire_est_refuse(self): + gpt, problemes = G.parse("---\ndu texte\n---" + CORPS, stem="essai") + self.assertIsNone(gpt) + self.assertEqual(problemes[0].detail, "str") + + def test_un_en_tete_indente_par_tabulation_est_un_probleme_liste(self): + """La tabulation est l'un des deux seuls cas où `safe_load` lève ; + il ne doit pas remonter en trace.""" + gpt, problemes = G.parse("---\ngpt:\t1\n---" + CORPS, stem="essai") + self.assertIsNone(gpt) + self.assertEqual(cles(problemes), [G.ENTETE_ILLISIBLE]) + + def test_une_cle_dupliquee_est_signalee_pas_perdue(self): + """Le cas qui coûte le plus cher : `safe_load` garde la DERNIÈRE, donc + un second bloc `requires` change la classe de sûreté sans un mot.""" + texte = ( + "---\ngpt: 1\nname: N\ndescription: D\n" + "requires:\n hosting: loopback\n" + "requires:\n hosting: any\n---" + CORPS + ) + gpt, problemes = G.parse(texte, stem="essai") + self.assertIsNone(gpt) + self.assertEqual(cles(problemes), [G.CLE_REPETEE]) + self.assertEqual(problemes[0].detail, "requires") + + def test_un_nom_absent_est_un_probleme_liste(self): + texte = "---\ngpt: 1\ndescription: D\n---" + CORPS + gpt, problemes = G.parse(texte, stem="essai") + self.assertIsNone(gpt) + self.assertEqual(cles(problemes), [G.NOM_ABSENT]) + + def test_une_description_absente_est_un_probleme_liste(self): + texte = "---\ngpt: 1\nname: N\n---" + CORPS + gpt, problemes = G.parse(texte, stem="essai") + self.assertIsNone(gpt) + self.assertEqual(cles(problemes), [G.DESCRIPTION_ABSENTE]) + + def test_un_schema_absent_est_refuse(self): + texte = "---\nname: N\ndescription: D\n---" + CORPS + gpt, problemes = G.parse(texte, stem="essai") + self.assertIsNone(gpt) + self.assertEqual(cles(problemes), [G.SCHEMA_ABSENT]) + + def test_une_version_de_schema_inconnue_grise_au_lieu_de_deviner(self): + """Deviner le sens d'un champ inconnu est la façon la plus sûre de + trahir un gpt : la version trop récente se refuse en le disant.""" + texte = "---\ngpt: 99\nname: N\ndescription: D\n---" + CORPS + gpt, problemes = G.parse(texte, stem="essai") + self.assertIsNone(gpt) + self.assertEqual(cles(problemes), [G.SCHEMA_TROP_RECENT]) + self.assertEqual(problemes[0].detail, "99") + + def test_un_marqueur_question_absent_est_un_probleme_liste(self): + texte = ( + "---\ngpt: 1\nname: N\ndescription: D\n---\n" + "\nUne invite, et rien à demander.\n" + ) + gpt, problemes = G.parse(texte, stem="essai") + self.assertIsNone(gpt) + self.assertEqual(cles(problemes), [G.MARQUEUR_QUESTION_ABSENT]) + + def test_un_requires_qui_n_est_pas_un_dictionnaire_est_jete(self): + """Une liste sous `requires` vient d'une indentation fautive. En + tirer des exigences inventerait une classe de sûreté.""" + texte = ( + "---\ngpt: 1\nname: N\ndescription: D\n" + "requires:\n - hosting\n---" + CORPS + ) + gpt, problemes = G.parse(texte, stem="essai") + self.assertIsNotNone(gpt) + self.assertEqual(gpt.requires, {}) + + +class CeQuiEstSignaleSansEtreFatal(unittest.TestCase): + """Un gpt utilisable peut porter quelque chose à relire.""" + + def test_une_cle_inconnue_est_signalee_et_le_gpt_reste(self): + """Une faute de frappe sur « requires » retirerait toutes les + exigences en silence, donc la clé inconnue se dit.""" + texte = ( + "---\ngpt: 1\nname: N\ndescription: D\nrequiers: {}\n---" + CORPS + ) + gpt, problemes = G.parse(texte, stem="essai") + self.assertIsNotNone(gpt) + self.assertEqual(cles(problemes), [G.CLE_INCONNUE]) + self.assertEqual(problemes[0].detail, "requiers") + self.assertFalse(problemes[0].fatal) + + def test_name_fr_dans_le_depot_est_signale(self): + """Le fichier des traductions est la source unique ; un `name_fr` ici + en créerait une seconde.""" + texte = ( + "---\ngpt: 1\nname: N\ndescription: D\nname_fr: Nom\n---" + CORPS + ) + gpt, problemes = G.parse(texte, stem="essai", from_repo=True) + self.assertIsNotNone(gpt) + self.assertEqual(cles(problemes), [G.NAME_FR_HORS_PLACE]) + self.assertFalse(problemes[0].fatal) + + +class HorsDuDepot(unittest.TestCase): + """Un fichier que personne n'a relu est de la configuration, pas une + donnée.""" + + BASE = "---\ngpt: 1\nname: N\ndescription: D\n" + + def test_un_gpt_hors_du_depot_est_force_a_loopback(self): + texte = self.BASE + "requires:\n hosting: any\n---" + CORPS + gpt, _ = G.parse(texte, stem="essai", from_repo=False) + self.assertIsNotNone(gpt) + self.assertEqual(gpt.requires["hosting"], "loopback") + + def test_un_gpt_du_depot_garde_son_hosting(self): + texte = self.BASE + "requires:\n hosting: any\n---" + CORPS + gpt, _ = G.parse(texte, stem="essai", from_repo=True) + self.assertEqual(gpt.requires["hosting"], "any") + + def test_un_gpt_hors_du_depot_ne_peut_declarer_aucune_commande(self): + texte = ( + self.BASE + + "context:\n commands:\n - argv: [echo, salut]\n---" + + CORPS + ) + gpt, problemes = G.parse(texte, stem="essai", from_repo=False) + self.assertIsNone(gpt) + self.assertEqual( + cles(problemes), + [G.HORS_DEPOT_SANS_COMMANDE], + ) + + def test_un_gpt_hors_du_depot_garde_son_name_fr(self): + """Un fichier hors du dépôt ne peut pas ajouter une clé à un fichier + versionné : l'exception est structurelle, non stylistique.""" + texte = self.BASE + "name_fr: Mon outil\n---" + CORPS + gpt, problemes = G.parse(texte, stem="essai", from_repo=False) + self.assertIsNotNone(gpt) + self.assertEqual(gpt.name_fr, "Mon outil") + self.assertEqual(problemes, []) + + +class LeCasHeureux(unittest.TestCase): + """Ce qu'un gpt bien formé rend, une fois tout le reste écarté.""" + + def setUp(self): + self.gpt, self.problemes = G.parse( + ENTETE + "\n\n\nTu réécris." + CORPS, + stem="comment-hygiene", + ) + + def test_un_gpt_bien_forme_ne_porte_aucun_probleme(self): + self.assertEqual(self.problemes, []) + self.assertIsNotNone(self.gpt) + + def test_le_radical_du_nom_de_fichier_est_l_identite(self): + """Il n'y a pas de champ `id` : deux sources de vérité pour un nom + finissent par diverger.""" + self.assertEqual(self.gpt.stem, "comment-hygiene") + + def test_le_corps_se_coupe_en_systeme_et_question(self): + self.assertEqual(self.gpt.system, "Tu réécris.") + self.assertEqual(self.gpt.question, "Réécris : {phrase}") + + def test_les_exigences_et_les_parametres_sont_lus(self): + self.assertEqual(self.gpt.requires["hosting"], "lan") + self.assertEqual(self.gpt.requires["context_window"], 8000) + self.assertEqual(self.gpt.params["temperature"], 0.2) + + def test_une_entree_sans_nom_est_ecartee(self): + """Elle ne pourrait ni se demander ni se substituer au gabarit.""" + texte = ( + "---\ngpt: 1\nname: N\ndescription: D\n" + "inputs:\n - type: repo_path\n - name: chemin\n---" + CORPS + ) + gpt, _ = G.parse(texte, stem="essai") + self.assertEqual([e["name"] for e in gpt.inputs], ["chemin"]) + + +class LesRacines(unittest.TestCase): + """Deux racines, deux niveaux de confiance, et un recouvrement par nom.""" + + BON = "---\ngpt: 1\nname: N\ndescription: D\n---" + CORPS + + def setUp(self): + self.depot = tempfile.TemporaryDirectory() + self.maison = tempfile.TemporaryDirectory() + self.addCleanup(self.depot.cleanup) + self.addCleanup(self.maison.cleanup) + + def _ecrire(self, dossier, nom, texte): + chemin = Path(dossier) / nom + chemin.write_text(texte, encoding="utf-8") + return chemin + + def test_une_racine_absente_n_est_pas_une_erreur(self): + """Le répertoire de l'utilisateur n'existe d'ordinaire pas.""" + gpts, problemes = G.load_all( + roots=[(Path(self.depot.name) / "inexistant", True)] + ) + self.assertEqual(gpts, []) + self.assertEqual(problemes, []) + + def test_un_fichier_illisible_ne_cache_pas_les_autres(self): + self._ecrire(self.depot.name, "bon.md", self.BON) + self._ecrire(self.depot.name, "casse.md", "---\n- liste\n---" + CORPS) + gpts, problemes = G.load_all(roots=[(self.depot.name, True)]) + self.assertEqual([g.stem for g in gpts], ["bon"]) + self.assertEqual(cles(problemes), [G.ENTETE_PAS_UN_DICTIONNAIRE]) + + def test_une_racine_posterieure_ecrase_par_nom_et_le_dit(self): + self._ecrire(self.depot.name, "outil.md", self.BON) + self._ecrire( + self.maison.name, + "outil.md", + "---\ngpt: 1\nname: Autre\ndescription: D\n---" + CORPS, + ) + gpts, problemes = G.load_all( + roots=[(self.depot.name, True), (self.maison.name, False)] + ) + self.assertEqual([g.stem for g in gpts], ["outil"]) + self.assertEqual(gpts[0].name, "Autre") + self.assertIn(G.ECRASE, cles(problemes)) + + def test_un_ecrasement_refuse_laisse_le_gpt_du_depot(self): + """Le refus d'un fichier hors du dépôt ne doit pas emporter avec lui + celui que le dépôt livrait.""" + self._ecrire(self.depot.name, "outil.md", self.BON) + self._ecrire( + self.maison.name, + "outil.md", + "---\ngpt: 1\nname: N\ndescription: D\n" + "context:\n commands:\n - argv: [echo]\n---" + CORPS, + ) + gpts, problemes = G.load_all( + roots=[(self.depot.name, True), (self.maison.name, False)] + ) + self.assertEqual([g.stem for g in gpts], ["outil"]) + self.assertEqual(gpts[0].name, "N") + self.assertIn( + G.HORS_DEPOT_SANS_COMMANDE, + cles(problemes), + ) + + def test_un_nom_de_fichier_base_md_est_refuse(self): + """`make doc_markdown` ne balaie que les `*.base.md` : un gpt ainsi + nommé serait réécrit par la chaîne de documentation.""" + self._ecrire(self.depot.name, "outil.base.md", self.BON) + gpts, problemes = G.load_all(roots=[(self.depot.name, True)]) + self.assertEqual(gpts, []) + self.assertEqual(cles(problemes), [G.NOM_BASE_MD_REFUSE]) + + def test_seuls_les_fichiers_md_sont_lus(self): + self._ecrire(self.depot.name, "outil.md", self.BON) + self._ecrire(self.depot.name, "notes.txt", "rien") + self._ecrire(self.depot.name, "README", "rien") + gpts, _ = G.load_all(roots=[(self.depot.name, True)]) + self.assertEqual([g.stem for g in gpts], ["outil"]) + + def test_le_catalogue_est_trie_par_radical(self): + for nom in ("zeta.md", "alpha.md", "mu.md"): + self._ecrire(self.depot.name, nom, self.BON) + gpts, _ = G.load_all(roots=[(self.depot.name, True)]) + self.assertEqual([g.stem for g in gpts], ["alpha", "mu", "zeta"]) + + def test_une_lecture_qui_leve_est_un_probleme_liste(self): + self._ecrire(self.depot.name, "outil.md", self.BON) + + def refuser(chemin): + raise OSError("permission refusée") + + gpts, problemes = G.load_all( + roots=[(self.depot.name, True)], read=refuser + ) + self.assertEqual(gpts, []) + self.assertEqual(cles(problemes), [G.FICHIER_ILLISIBLE]) + + def test_le_catalogue_s_ouvre_sans_pyyaml(self): + """Le catalogue est un supplément ; la question libre marche sans + lui, donc son absence se dit et ne lève pas.""" + with patch.object(G, "_yaml_disponible", return_value=False): + gpts, problemes = G.load_all(roots=[(self.depot.name, True)]) + self.assertEqual(gpts, []) + self.assertEqual( + cles(problemes), + [G.PYYAML_ABSENT], + ) + + def test_le_repertoire_personnel_est_detournable(self): + """Sans ce détournement, un test lirait les gpts de la machine qui le + lance, et son résultat dépendrait de qui l'exécute.""" + racines = G.default_roots(home=self.maison.name) + chemins = [str(chemin) for chemin, _ in racines] + self.assertTrue(chemins) + self.assertTrue(chemins[-1].startswith(self.maison.name)) + self.assertEqual([relu for _, relu in racines], [True, False]) + + +class LaFrontiere(unittest.TestCase): + """Le paquet doit rester importable sans le CLI.""" + + def test_le_chargeur_n_importe_pas_todo(self): + self.assertNotIn("script.todo.todo", sys.modules) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_assistant_hosts.py b/test/test_assistant_hosts.py new file mode 100644 index 0000000..3a93ed6 --- /dev/null +++ b/test/test_assistant_hosts.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Les deux sources de machines qui viennent d'ailleurs : QEMU et SSH. + +L'énumération des VM libvirt et la résolution d'un alias SSH sont des +méthodes de la classe TODO, et le paquet `assistant` n'a pas le droit +d'importer `todo.py` — l'import coûte près d'une seconde et imprime sur la +sortie. Les deux sources arrivent donc INJECTÉES, et ce que ces tests +défendent est le contrat de cette injection : une source non branchée rend +une liste VIDE et non une erreur, et aucun de ces tests ne touche `virsh`, +`ssh -G` ni la configuration SSH réelle. + +Le danger que la maison connaît est le test qui n'affirme rien. Cette +machine-ci n'a AUCUN domaine libvirt et AUCUNE configuration SSH : un test +qui boucle sur la sortie réelle ne s'exécute jamais et passe pour la mauvaise +raison. Chaque boucle est donc précédée d'une garde sur le jeu d'essai, et +tout ce qui est parcouru vient de constantes de ce fichier. + +Deux régressions sont visées par leur nom. + +**Le résolveur patient.** Les deux résolveurs d'adresse de VM qui attendent +patientent jusqu'à dix minutes PAR VM ; seul celui qui lit le bail une fois +convient à l'affichage d'une liste. Un test lit le TEXTE du module pour que +substituer l'un à l'autre casse, parce qu'aucune assertion de comportement ne +distingue « lent » de « rapide ». + +**Les motifs pris pour des machines.** Un joker est une règle et un motif nié +retire un nom : ni l'un ni l'autre ne désigne une machine à sonder. +L'énumérateur du dépôt écarte le premier et laisse passer le second, donc +l'énumérateur d'essai d'ici rend les DEUX, et c'est la source testée qui doit +les refuser. + +Les alias, les noms de VM et les adresses sont INVENTÉS : +`.claude/rules/04-code-conventions.md` l'exige pour la valeur qui illustre un +interdit, et demande d'en vérifier l'absence ailleurs. Le domaine de premier +niveau `.invalid` est réservé à cet usage, et « azurite », « obsidienne », +« malachite » et « basalte » ne paraissent nulle part ailleurs dans le dépôt. +""" + +import os +import re +import sys +import unittest + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.todo.assistant import discover # noqa: E402 + +# Une configuration SSH d'essai. Deux noms sur une seule ligne « Host », un +# joker, un motif nié, et une entrée SANS `HostName` — les quatre formes que +# la source doit traiter différemment. +CONFIG = """ +Host azurite.invalid obsidienne.invalid + HostName 192.0.2.21 + Port 2222 + +Host * + ServerAliveInterval 30 + +Host !malachite.invalid + User personne + +Host basalte.invalid +""" + +# Ce que `ssh -G` rend pour les alias qui déclarent quelque chose. Les clés +# sont en minuscules, comme celles du résolveur du dépôt. +RESOLU = { + "azurite.invalid": {"hostname": "192.0.2.21", "port": "2222"}, + "obsidienne.invalid": {"hostname": "192.0.2.21", "port": "2222"}, +} + +# Des noms de VM inventés. Le second n'a pas d'adresse, le troisième en fait +# lever la résolution. +VM_AVEC_IP = "vm-azurite" +VM_SANS_IP = "vm-obsidienne" +VM_QUI_LEVE = "vm-basalte" +IP_DE_VM = "192.0.2.31" + + +def alias_naifs(texte=CONFIG): + """Un énumérateur d'alias qui rend TOUT ce qu'une ligne « Host » porte. + + Il découpe les noms multiples comme l'énumérateur du dépôt, mais ne + filtre NI le joker NI le motif nié : le dépôt écarte le premier et laisse + passer le second, et rendre les deux met la charge du filtrage sur la + source testée, qui est ce qu'on veut vérifier. + """ + + def lister(): + noms = [] + for ligne in texte.splitlines(): + if not re.match(r"^[ \t]*Host[ \t]+", ligne): + continue + noms.extend(ligne.split()[1:]) + return noms + + return lister + + +def resolveur(table=None, appels=None): + """Un `ssh -G` d'essai, y compris son comportement par défaut. + + Un alias absent de la table rend l'alias LUI-MÊME comme hôte et le port + 22, ce qui est ce que `ssh -G` annonce quand rien n'est déclaré : la + branche sans `HostName` ne s'invente donc pas, elle se recopie. + """ + table = RESOLU if table is None else table + + def resolve(alias): + if appels is not None: + appels.append(alias) + return dict(table.get(alias, {"hostname": alias, "port": "22"})) + + return resolve + + +def domaines(noms, appels=None): + """Un énumérateur de domaines libvirt, qui note qu'on l'a appelé.""" + + def lister(): + if appels is not None: + appels.append("list") + return list(noms) + + return lister + + +def adresse_de_vm(appels=None): + """Le résolveur d'adresse BON MARCHÉ : une lecture, aucune attente.""" + + def vm_ip(name): + if appels is not None: + appels.append(name) + if name == VM_AVEC_IP: + return IP_DE_VM + if name == VM_QUI_LEVE: + raise OSError("bail illisible") + return None + + return vm_ip + + +class LesHotesSsh(unittest.TestCase): + def test_un_ssh_config_absent_est_une_liste_vide_pas_une_panne(self): + """Le fichier n'existe pas sur beaucoup de machines : c'est un FAIT, + et le menu doit s'ouvrir quand même.""" + vide = discover.ssh_hosts(list_aliases=lambda: [], resolve=resolveur()) + self.assertEqual([], vide) + + def leve(): + raise OSError("~/.ssh/config") + + self.assertEqual( + [], discover.ssh_hosts(list_aliases=leve, resolve=resolveur()) + ) + + def test_une_ligne_host_multi_noms_rend_chaque_nom(self): + """Une ligne « Host a b » déclare DEUX machines : les rendre comme une + seule clé portant un espace est la faute d'un analyseur du venv que + le dépôt n'utilise pas.""" + cibles = discover.ssh_hosts( + list_aliases=alias_naifs(), resolve=resolveur() + ) + self.assertTrue(cibles, "aucune cible : le jeu d'essai ne colle plus") + par_alias = {alias: (host, port) for alias, host, port in cibles} + self.assertIn("azurite.invalid", par_alias) + self.assertIn("obsidienne.invalid", par_alias) + self.assertEqual(("192.0.2.21", 2222), par_alias["azurite.invalid"]) + self.assertEqual(("192.0.2.21", 2222), par_alias["obsidienne.invalid"]) + + def test_un_joker_et_un_motif_nie_ne_sont_pas_des_machines(self): + alias = alias_naifs()() + self.assertIn("*", alias, "le jeu d'essai doit porter le joker") + self.assertIn("!malachite.invalid", alias, "et le motif nié aussi") + cibles = discover.ssh_hosts( + list_aliases=alias_naifs(), resolve=resolveur() + ) + self.assertTrue(cibles, "aucune cible") + for nom, host, _port in cibles: + self.assertNotIn("*", nom) + self.assertNotIn("?", nom) + self.assertFalse(nom.startswith("!")) + self.assertNotIn("malachite", nom) + self.assertNotIn("malachite", host) + + def test_une_entree_sans_hostname_garde_l_alias_pour_cible(self): + """`ssh -G` remplit l'hôte avec l'alias quand aucun `HostName` n'est + déclaré : l'alias EST une cible sondable, que le DNS ou le fichier + des hôtes résout.""" + cibles = discover.ssh_hosts( + list_aliases=alias_naifs(), resolve=resolveur() + ) + par_alias = {alias: (host, port) for alias, host, port in cibles} + self.assertIn("basalte.invalid", par_alias) + self.assertEqual(("basalte.invalid", 22), par_alias["basalte.invalid"]) + + def test_un_resolveur_muet_garde_l_alias_avec_le_port_de_ssh(self): + """Un résolveur qui rend {} n'a rien appris ; le repli vaut ce que + `ssh` lui-même annonce, donc il n'invente rien.""" + cibles = discover.ssh_hosts( + list_aliases=lambda: ["basalte.invalid"], + resolve=lambda alias: {}, + ) + self.assertEqual([("basalte.invalid", "basalte.invalid", 22)], cibles) + + def test_un_resolveur_qui_leve_ne_perd_pas_les_autres_alias(self): + def resolve(alias): + if alias == "azurite.invalid": + raise OSError("ssh absent") + return {"hostname": alias, "port": "22"} + + cibles = discover.ssh_hosts( + list_aliases=lambda: ["azurite.invalid", "basalte.invalid"], + resolve=resolve, + ) + self.assertEqual( + ["azurite.invalid", "basalte.invalid"], + [alias for alias, _h, _p in cibles], + ) + + +class LesVmQemu(unittest.TestCase): + def test_zero_domaine_libvirt_est_une_source_vide(self): + """« libvirt répond, aucune VM définie » est un FAIT : le résolveur + n'est pas même appelé.""" + appels = [] + self.assertEqual( + [], + discover.qemu_hosts( + list_domains=domaines([]), vm_ip=adresse_de_vm(appels) + ), + ) + self.assertEqual([], appels) + + def test_une_vm_sans_ip_est_listee_inconnue_pas_ecartee(self): + """Elle est définie et peut être démarrée : la faire disparaître de + la liste ne le dirait pas.""" + appels = [] + trouves = discover.qemu_hosts( + list_domains=domaines([VM_AVEC_IP, VM_SANS_IP]), + vm_ip=adresse_de_vm(appels), + ) + self.assertEqual([(VM_AVEC_IP, IP_DE_VM), (VM_SANS_IP, None)], trouves) + self.assertEqual([VM_AVEC_IP, VM_SANS_IP], appels) + + def test_une_resolution_qui_leve_garde_la_vm_dans_la_liste(self): + trouves = discover.qemu_hosts( + list_domains=domaines([VM_QUI_LEVE, VM_AVEC_IP]), + vm_ip=adresse_de_vm(), + ) + self.assertEqual( + [(VM_QUI_LEVE, None), (VM_AVEC_IP, IP_DE_VM)], trouves + ) + + def test_un_virsh_qui_leve_est_une_source_vide(self): + def leve(): + raise OSError("virsh absent") + + self.assertEqual( + [], discover.qemu_hosts(list_domains=leve, vm_ip=adresse_de_vm()) + ) + + def test_sans_resolveur_les_domaines_sortent_sans_adresse(self): + trouves = discover.qemu_hosts(list_domains=domaines([VM_AVEC_IP])) + self.assertEqual([(VM_AVEC_IP, None)], trouves) + + +class LeResolveurPatient(unittest.TestCase): + def test_aucune_source_n_utilise_le_resolveur_patient(self): + """Le nom du résolveur bon marché a celui du patient pour PRÉFIXE : + c'est la frontière de mot qui les sépare, et une recherche de + sous-chaîne interdirait aussi de nommer le bon.""" + source = _source() + self.assertIsNone( + re.search(r"\b_qemu_vm_ip\b", source), + "le résolveur patient est nommé : il gèle le menu par VM", + ) + self.assertNotIn("_qemu_resolve_ips", source) + self.assertIn( + "_qemu_vm_ip_now", + source, + "le résolveur bon marché doit être nommé, pas seulement décrit", + ) + + def test_le_resolveur_injecte_est_celui_qui_est_appele(self): + appels = [] + discover.qemu_hosts( + list_domains=domaines([VM_AVEC_IP, VM_SANS_IP]), + vm_ip=adresse_de_vm(appels), + ) + self.assertEqual([VM_AVEC_IP, VM_SANS_IP], appels) + + +class SansInjection(unittest.TestCase): + def test_une_source_sans_injection_rend_une_liste_vide(self): + """Une source qu'on n'a pas branchée n'a rien à dire ; ce n'est pas + une panne du menu, et ce n'est pas une raison de lever.""" + self.assertEqual([], discover.qemu_hosts()) + self.assertEqual([], discover.ssh_hosts()) + + def test_une_moitie_d_injection_ne_suffit_pas_a_ssh(self): + """Sans résolveur, l'hôte et le port ne se sauraient pas, et les + inventer serait une devinette là où `ssh -G` a la réponse.""" + appels = [] + self.assertEqual([], discover.ssh_hosts(list_aliases=alias_naifs())) + self.assertEqual( + [], discover.ssh_hosts(resolve=resolveur(appels=appels)) + ) + self.assertEqual([], appels) + + +class LaFrontiere(unittest.TestCase): + def test_les_sources_ne_tirent_pas_todo(self): + """Importer `script.todo.todo` coûte près d'une seconde et imprime + sur la sortie : le paquet doit rester importable seul.""" + self.assertNotIn("script.todo.todo", sys.modules) + + def test_aucun_analyseur_de_config_ssh_n_est_reecrit_ici(self): + """`ssh -G` a raison sur les `Include`, les `Match`, l'héritage des + jokers et ses propres défauts ; recopier son travail est ce que + l'injection évite. + + L'affirmation porte sur ce que le module NE FAIT PAS lui-même : il ne + lit pas le fichier de configuration et n'appelle pas `ssh -G`. La + présence du mot « ssh » ne dit rien à ce sujet — `ssh_runner` lance + `ip` À TRAVERS ssh, ce qui délègue la configuration à ssh au lieu de + la relire, donc respecte la règle en la nommant. + """ + source = _source() + self.assertNotIn("sshconf", source, "sshconf importé ici") + # Le drapeau comme ARGUMENT, et non le nom de la commande dans une + # docstring : les docstrings nomment `ssh -G` exprès, pour dire à qui + # le travail est délégué. + self.assertTrue( + '"-G"' not in source and "'-G'" not in source, + "« -G » passé en argument : la résolution est réécrite ici", + ) + + +def _source(): + """Le texte du module de découverte, non vide.""" + with open(discover.__file__, encoding="utf-8") as fh: + texte = fh.read() + if not re.search(r"def ssh_hosts\(", texte): + raise AssertionError("source illisible : le module a changé de forme") + return texte + + +class ReseauxDistants(unittest.TestCase): + """Les réseaux lus sur une AUTRE machine, par le même analyseur. + + Quand le CLI tourne dans une machine virtuelle, les réseaux qu'il porte + sont ceux de l'hyperviseur et le parc réel est hors-lien. La machine du + dessus porte les bons préfixes ; `remote_networks` les lit chez elle en + passant un exécuteur SSH à `local_networks`, ce qui réutilise la lecture + des adresses, la détection des ponts et l'ordre par route par défaut sans + en dupliquer une ligne. + """ + + ADRESSES = ( + "1: lo inet 127.0.0.1/8 scope host lo\n" + "2: lien0 inet 198.51.100.9/24 scope global lien0\n" + "3: pont0 inet 192.0.2.1/24 scope global pont0\n" + ) + LIENS = ( + "1: lo: mtu 65536 qdisc noqueue state UNKNOWN\n" + "2: lien0: mtu 1500 qdisc fq_codel state UP\n" + "3: pont0: mtu 1500 qdisc noqueue state UP " + "link/ether aa:bb:cc:dd:ee:07 bridge_id 8000.0 bridge\n" + ) + DEFAUT = "default via 198.51.100.1 dev lien0 proto dhcp\n" + + def _executeur(self, journal): + """Un exécuteur qui rend ce qu'annoncerait l'hôte distant.""" + + def executer(argv): + journal.append(tuple(argv)) + if "addr" in argv: + return self.ADRESSES + if "link" in argv: + return self.LIENS + if "route" in argv: + return self.DEFAUT + return "" + + return executer + + def test_les_reseaux_du_distant_sont_lus_et_le_pont_marque(self): + journal = [] + reseaux = discover.remote_networks( + "machine.invalid", run=self._executeur(journal) + ) + self.assertTrue(journal, "aucune commande lancée sur l'hôte") + self.assertEqual( + [(r.cidr, r.name, r.is_bridge) for r in reseaux], + [ + ("198.51.100.0/24", "lien0", False), + ("192.0.2.0/24", "pont0", True), + ], + ) + + def test_la_boucle_locale_du_distant_est_ecartee(self): + reseaux = discover.remote_networks( + "machine.invalid", run=self._executeur([]) + ) + self.assertTrue(reseaux) + for reseau in reseaux: + self.assertNotIn("127.0.0", reseau.cidr) + + def test_un_hote_muet_rend_une_liste_vide(self): + reseaux = discover.remote_networks( + "machine.invalid", run=lambda argv: "" + ) + self.assertEqual(reseaux, []) + + def test_un_executeur_qui_leve_rend_une_liste_vide(self): + def executer(argv): + raise OSError("hôte injoignable") + + self.assertEqual( + discover.remote_networks("machine.invalid", run=executer), [] + ) + + def test_l_executeur_ssh_refuse_toute_invite_et_cite_ses_arguments(self): + """`BatchMode=yes` empêche une demande de mot de passe de tenir le + menu sur une question que personne ne voit venir, et la commande + distante est relue par un interpréteur là-bas.""" + source = _source() + self.assertIn("BatchMode=yes", source, "une invite pourrait bloquer") + self.assertIn("shlex.quote", source, "arguments non cités") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_assistant_menu.py b/test/test_assistant_menu.py new file mode 100644 index 0000000..337287e --- /dev/null +++ b/test/test_assistant_menu.py @@ -0,0 +1,561 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ce que le câblage du menu assistant doit tenir. + +Six sites de `todo.py` participent à un sous-menu — l'import, les bases de la +classe, l'étiquette du fil d'Ariane, les deux listes jumelles du menu parent, +et les clés de traduction. Aucun n'échoue bruyamment quand il manque : une +entrée mal branchée appelle simplement autre chose, une étiquette absente +retire une miette du fil, et `t()` rend une clé inconnue telle quelle, donc une +faute de frappe s'affiche en clair à l'utilisateur sans que rien ne lève. + +Ce fichier est le SEUL de la famille assistant à importer `TODO` : cet import +coûte près d'une seconde et imprime sur la sortie, et le faire payer aux neuf +autres fichiers rendrait la boucle d'écriture inutilisable. La contrepartie est +vérifiée ici même — le paquet, lui, doit rester importable seul. + +`_menu_header()` enregistre une télémétrie dans `~/.erplibre` : tout test qui +appelle une méthode de menu la neutralise, sinon il écrit pour de vrai. +""" +from __future__ import annotations + +import ast +import collections +import os +import subprocess +import sys +import unittest +from unittest.mock import patch + +from script.todo.todo_i18n import t + +RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +MENU = os.path.join(RACINE, "script", "todo", "assistant_menu.py") + + +def _litteral(noeud): + """La chaîne d'un nœud constant, ou `None`.""" + if isinstance(noeud, ast.Constant) and isinstance(noeud.value, str): + return noeud.value + return None + + +def cles_de_traduction(chemin): + """Les clés littérales que le fichier confie à `t()`, directement ou non. + + Lit l'arbre syntaxique plutôt que le texte : une expression régulière + attraperait aussi les appels commentés et raterait les appels sur + plusieurs lignes. + + Deux formes comptent. `t("clé")` est la forme directe. `_llm_count(n, + "singulier", "pluriel")` en est une INDIRECTE : ses deux derniers + arguments sont des clés que l'accord choisit à l'exécution, et une faute + de frappe y afficherait « hosts swept » en clair sans que rien ne lève — + exactement ce que la forme directe protège déjà. + """ + with open(chemin) as fichier: + arbre = ast.parse(fichier.read()) + cles = set() + for noeud in ast.walk(arbre): + if not isinstance(noeud, ast.Call): + continue + cible = noeud.func + nom = getattr(cible, "id", None) or getattr(cible, "attr", None) + if nom == "t" and noeud.args: + valeur = _litteral(noeud.args[0]) + if valeur is not None: + cles.add(valeur) + elif nom == "_llm_count" and len(noeud.args) >= 3: + for argument in noeud.args[1:3]: + valeur = _litteral(argument) + if valeur is not None: + cles.add(valeur) + return cles + + +class Cablage(unittest.TestCase): + """Les six sites de `todo.py` que le sous-menu réclame.""" + + def test_todo_expose_le_sous_menu_llm(self): + from script.todo.todo import TODO + + self.assertTrue(hasattr(TODO, "prompt_assistant_llm")) + + def test_l_etiquette_de_fil_d_ariane_existe(self): + """Le fil se dérive de la pile d'appels : une méthode absente de + `_MENU_LABELS` ne contribue AUCUNE miette, en silence. Le sous-menu + VPN a été livré ainsi et ne se situe donc nulle part.""" + from script.todo.todo import TODO + + self.assertEqual(TODO._MENU_LABELS.get("prompt_assistant_llm"), "LLM") + + def test_un_dispatche_vers_le_sous_menu_llm_seulement(self): + """Les deux listes du menu parent sont tenues à la main et rien ne + les rapproche : `[1]` peut afficher une entrée et appeler l'autre.""" + from script.todo.todo import TODO + + todo = TODO() + with patch.object(TODO, "prompt_assistant_llm") as mock_llm, patch( + "script.todo.mail.menu.prompt_execute_mail" + ) as mock_mail, patch("click.prompt", side_effect=["1", "0"]), patch( + "script.todo.todo_telemetry.record" + ): + todo.prompt_assistant() + mock_llm.assert_called_once_with() + mock_mail.assert_not_called() + + def test_deux_dispatche_toujours_vers_le_courriel_seulement(self): + from script.todo.todo import TODO + + todo = TODO() + with patch.object(TODO, "prompt_assistant_llm") as mock_llm, patch( + "script.todo.mail.menu.prompt_execute_mail" + ) as mock_mail, patch("click.prompt", side_effect=["2", "0"]), patch( + "script.todo.todo_telemetry.record" + ): + todo.prompt_assistant() + mock_mail.assert_called_once() + mock_llm.assert_not_called() + + def test_le_sous_menu_s_ouvre_sans_aucun_serveur_configure(self): + """Une machine sans serveur est le cas de la PREMIÈRE utilisation. + + La sonde et le registre sont injectés : le menu ne doit ni ouvrir de + socket, ni lire la configuration réelle du poste qui lance la suite. + """ + from script.todo.todo import TODO + + todo = TODO() + with patch( + "script.todo.assistant.fingerprint.collect", return_value={} + ), patch("script.todo.assistant.servers.load", return_value=[]), patch( + "click.prompt", side_effect=["0"] + ), patch( + "script.todo.todo_telemetry.record" + ): + todo.prompt_assistant_llm() + + +class ClesDeTraduction(unittest.TestCase): + """Ce qu'une clé manquante coûte : du texte anglais brut à l'écran.""" + + def test_chaque_cle_du_menu_est_declaree(self): + from script.todo.todo_i18n import TRANSLATIONS + + cles = cles_de_traduction(MENU) + self.assertTrue(cles, "aucune clé t() trouvée dans le menu") + manquantes = sorted(c for c in cles if c not in TRANSLATIONS) + self.assertEqual(manquantes, []) + + def test_chaque_commande_annoncee_est_traduite(self): + """« /? » imprime l'aide de chaque commande servie : une aide non + traduite s'y afficherait en anglais au milieu du français.""" + from script.todo.assistant.chat import COMMANDS + from script.todo.assistant_menu import COMMANDES_PHASE_1 + from script.todo.todo_i18n import TRANSLATIONS + + self.assertTrue(COMMANDES_PHASE_1) + for nom in COMMANDES_PHASE_1: + self.assertIn(nom, COMMANDS) + self.assertIn(COMMANDS[nom], TRANSLATIONS) + + def test_les_doublons_de_translations_restent_les_trois_connus(self): + """Une clé répétée écrase la précédente en silence, et l'écrasement + s'est déjà payé d'une mauvaise étiquette de menu principal. Trois + doublons préexistent ; ce test refuse le quatrième sans exiger de + réparer les trois, qui sont hors du sujet de ce câblage.""" + chemin = os.path.join(RACINE, "script", "todo", "todo_i18n.py") + with open(chemin) as fichier: + arbre = ast.parse(fichier.read()) + litteral = None + for noeud in ast.walk(arbre): + if isinstance(noeud, ast.Assign) and any( + isinstance(c, ast.Name) and c.id == "TRANSLATIONS" + for c in noeud.targets + ): + litteral = noeud.value + self.assertIsInstance(litteral, ast.Dict) + noms = [ + c.value + for c in litteral.keys + if isinstance(c, ast.Constant) and isinstance(c.value, str) + ] + self.assertTrue(noms) + compte = collections.Counter(noms) + doublons = sorted(k for k, n in compte.items() if n > 1) + self.assertEqual(doublons, ["Maintenance", "none", "pass"]) + + +class JournalDuTransport(unittest.TestCase): + """La conversation ne doit pas être coupée par des lignes de journal. + + `todo.py` pose un gestionnaire sur le logger racine à l'import, et le + transport HTTP journalise chaque requête en INFO : sans réglage, une ligne + « HTTP Request: POST … » paraît à chaque réponse du modèle. + """ + + def test_le_transport_du_client_openai_est_silencieux(self): + """Le nom du transport n'est pas stable : le venv porte `httpx` ET + `httpx2`, et c'est la version du client `openai` qui décide lequel + émet. Ce test part du transport RÉELLEMENT importé, pour tomber en + rouge le jour où le client en change plutôt que de laisser la ligne + revenir en silence.""" + import logging + + from script.todo.assistant_menu import AssistantMenuMixin + + AssistantMenuMixin._llm_quiet_http() + + import types + + import openai._base_client as base + + # Le client importe son transport sous le nom du paquet, qui est + # justement ce qui change : on cherche donc tout module dont le nom + # de tête commence par « httpx », plutôt qu'un attribut fixe. + noms = { + valeur.__name__.split(".")[0] + for valeur in vars(base).values() + if isinstance(valeur, types.ModuleType) + and valeur.__name__.split(".")[0].startswith("httpx") + } + self.assertTrue(noms, "aucun transport httpx dans le client openai") + for nom in sorted(noms): + self.assertGreaterEqual( + logging.getLogger(nom).getEffectiveLevel(), + logging.WARNING, + f"le logger « {nom} » parlerait pendant la conversation", + ) + + def test_les_deux_transports_connus_sont_nommes(self): + import logging + + from script.todo.assistant_menu import AssistantMenuMixin + + AssistantMenuMixin._llm_quiet_http() + for nom in ("httpx", "httpx2"): + self.assertGreaterEqual( + logging.getLogger(nom).getEffectiveLevel(), logging.WARNING + ) + + +class Balayage(unittest.TestCase): + """Ce que le balayage doit atteindre, et ce qu'il ne doit pas taire. + + Un serveur vit souvent sur un réseau que la machine ne PORTE pas, + joignable par la passerelle. Les deux mécanismes qui semblaient couvrir ce + cas ne le couvrent pas : la saisie d'une adresse ne prend qu'un hôte, et + la table de voisinage est link-local, donc elle ne connaît jamais un hôte + routé. Sans réseau saisi à la main, un tel serveur est hors d'atteinte. + """ + + def _todo(self): + from script.todo.todo import TODO + + todo = TODO() + todo._llm_session = { + "serveur": None, + "sonde": [], + "confirmes": set(), + } + return todo + + def test_un_reseau_non_porte_est_balayable(self): + """Aucune interface ne porte ce préfixe, et il doit être planifiable + quand même : c'est le cas d'un CLI qui tourne dans une VM, dont le + « réseau local » est celui de l'hyperviseur.""" + from script.todo.assistant import discover as llm_disc + + todo = self._todo() + vus = {} + with patch.object( + llm_disc, "local_networks", return_value=[] + ), patch.object(llm_disc, "run_ip", return_value=""), patch.object( + todo, "_qemu_host_addresses", staticmethod(lambda: set()) + ), patch.object( + todo, + "_llm_probe_and_keep", + lambda adresses, **kw: vus.update({"n": len(adresses), "kw": kw}), + ), patch( + "click.prompt", side_effect=["198.51.100.0/24", "o"] + ): + todo._llm_search_cidr() + self.assertEqual(vus.get("n"), 254) + self.assertEqual(vus["kw"]["cible"], "198.51.100.0/24") + self.assertFalse(vus["kw"]["restreint"]) + + def test_le_defaut_est_le_reseau_entier_pas_les_hotes_deja_vus(self): + """Rétrécir par défaut se retourne : la table de voisinage ne porte + souvent que la passerelle, le balayage tombe à une adresse, et le + résumé annonce un réseau vide là où une seule adresse a été vue.""" + from script.todo.assistant import discover as llm_disc + + todo = self._todo() + vus = {} + voisinage = "198.51.100.1 dev lien0 lladdr aa:bb:cc:dd:ee:01 REACHABLE" + with patch.object( + llm_disc, "run_ip", return_value=voisinage + ), patch.object( + todo, "_qemu_host_addresses", staticmethod(lambda: set()) + ), patch.object( + todo, + "_llm_probe_and_keep", + lambda adresses, **kw: vus.update({"n": len(adresses), "kw": kw}), + ), patch( + "click.prompt", side_effect=["o"] + ): + todo._llm_sweep_cidr("198.51.100.0/24") + self.assertEqual(vus.get("n"), 254) + self.assertFalse(vus["kw"]["restreint"]) + + def test_la_lettre_v_restreint_et_le_dit(self): + from script.todo.assistant import discover as llm_disc + + todo = self._todo() + vus = {} + voisinage = "198.51.100.1 dev lien0 lladdr aa:bb:cc:dd:ee:01 REACHABLE" + with patch.object( + llm_disc, "run_ip", return_value=voisinage + ), patch.object( + todo, "_qemu_host_addresses", staticmethod(lambda: set()) + ), patch.object( + todo, + "_llm_probe_and_keep", + lambda adresses, **kw: vus.update({"n": len(adresses), "kw": kw}), + ), patch( + "click.prompt", side_effect=["v"] + ): + todo._llm_sweep_cidr("198.51.100.0/24") + self.assertEqual(vus.get("n"), 1) + self.assertTrue(vus["kw"]["restreint"]) + + def test_un_balayage_restreint_sans_trouvaille_le_dit(self): + """« Aucun serveur sur ce /24 » après une adresse regardée est un + faux négatif présenté comme un fait.""" + import io + from contextlib import redirect_stdout + + from script.todo.assistant import discover as llm_disc + + todo = self._todo() + sortie = io.StringIO() + with patch.object(llm_disc, "sweep", return_value=[]), redirect_stdout( + sortie + ): + todo._llm_probe_and_keep( + ["198.51.100.1"], cible="198.51.100.0/24", restreint=True + ) + texte = sortie.getvalue() + self.assertIn(t("Only part of that network was swept."), texte) + + def test_un_balayage_complet_sans_trouvaille_ne_le_dit_pas(self): + import io + from contextlib import redirect_stdout + + from script.todo.assistant import discover as llm_disc + + todo = self._todo() + sortie = io.StringIO() + with patch.object(llm_disc, "sweep", return_value=[]), redirect_stdout( + sortie + ): + todo._llm_probe_and_keep(["198.51.100.1"], cible="198.51.100.0/24") + self.assertNotIn( + t("Only part of that network was swept."), sortie.getvalue() + ) + + def test_plus_large_qu_un_slash24_est_refuse(self): + from script.todo.assistant import discover as llm_disc + + todo = self._todo() + appels = [] + with patch.object( + todo, "_qemu_host_addresses", staticmethod(lambda: set()) + ), patch.object(llm_disc, "run_ip", return_value=""), patch.object( + todo, "_llm_probe_and_keep", lambda *a, **k: appels.append(a) + ), patch( + "click.prompt", side_effect=[] + ): + todo._llm_sweep_cidr("10.0.0.0/8") + self.assertEqual(appels, []) + + +class ReglagesDuBalayage(unittest.TestCase): + """Les réglages viennent des préférences, et une valeur abîmée n'y passe + pas. + + Le délai est le seul réglage du balayage qui fabrique des faux négatifs : + sous mille connexions simultanées, un hôte joignable en une milliseconde + se manque à cinq centièmes de délai. Une valeur illisible ou négative doit + donc retomber sur le défaut du module, jamais s'appliquer. + """ + + def test_les_prefs_sont_passees_au_balayage(self): + from script.todo import todo_prefs + from script.todo.assistant_menu import AssistantMenuMixin + + with patch.object( + todo_prefs, + "get", + lambda cle, defaut=None: { + "assistant_sweep_workers": 64, + "assistant_sweep_timeout": 0.75, + }[cle], + ): + reglages = AssistantMenuMixin._llm_sweep_tuning() + self.assertEqual(reglages, {"workers": 64, "timeout": 0.75}) + + def test_une_valeur_illisible_retombe_sur_le_defaut_du_module(self): + from script.todo import todo_prefs + from script.todo.assistant_menu import AssistantMenuMixin + + for mauvais in ("beaucoup", None, -1, 0): + with patch.object( + todo_prefs, "get", lambda cle, defaut=None: mauvais + ): + reglages = AssistantMenuMixin._llm_sweep_tuning() + self.assertNotIn("workers", reglages, f"accepté : {mauvais!r}") + self.assertNotIn("timeout", reglages, f"accepté : {mauvais!r}") + + def test_le_defaut_du_module_ne_suit_pas_le_nombre_de_coeurs(self): + """Dimensionner par cœurs est l'erreur que ce module refuse : ces + fils attendent le réseau. Le plafond réel est le nombre de sondes, + que le balayage applique lui-même.""" + import os + + from script.todo.assistant import discover as llm_disc + + self.assertNotEqual(llm_disc.MAX_WORKERS, os.cpu_count()) + self.assertGreaterEqual(llm_disc.MAX_WORKERS, 1024) + self.assertNotIn("cpu_count", _source_de_la_decouverte()) + + def test_les_prefs_declarent_les_deux_reglages(self): + from script.todo import todo_prefs + + for cle in ("assistant_sweep_workers", "assistant_sweep_timeout"): + self.assertIn(cle, todo_prefs.DEFAULTS) + + +def _source_de_la_decouverte(): + chemin = os.path.join(RACINE, "script", "todo", "assistant", "discover.py") + with open(chemin) as fichier: + return fichier.read() + + +class SessionsClaudeCode(unittest.TestCase): + """Le câblage de la phase 4, sous « GPT code » et non sous le LLM. + + Une session est un processus adressé par identifiant ; un serveur est un + hôte adressé par port. Les mêler dans une liste numérotée ferait partager + des numéros à deux modèles mentaux, et toutes les entrées Claude vivent + déjà sous ce menu-là. + """ + + def test_todo_expose_le_sous_menu_des_sessions(self): + from script.todo.todo import TODO + + self.assertTrue(hasattr(TODO, "prompt_claude_sessions")) + + def test_l_etiquette_de_fil_d_ariane_existe(self): + from script.todo.todo import TODO + + self.assertEqual( + TODO._MENU_LABELS.get("prompt_claude_sessions"), "Claude Code" + ) + + def test_six_dispatche_vers_les_sessions_seulement(self): + """Les deux listes de `prompt_execute_gpt_code` sont tenues à la + main : l'entrée peut s'afficher et appeler autre chose.""" + from script.todo.todo import TODO + + todo = TODO() + with patch.object( + TODO, "prompt_claude_sessions" + ) as mock_sessions, patch.object( + TODO, "prompt_execute_claude_plugins" + ) as mock_plugins, patch( + "click.prompt", side_effect=["6", "0"] + ), patch( + "script.todo.todo_telemetry.record" + ): + todo.prompt_execute_gpt_code() + mock_sessions.assert_called_once_with() + mock_plugins.assert_not_called() + + def test_le_sous_menu_s_ouvre_sans_aucune_session(self): + """Une machine sans Claude Code n'est pas une panne du menu.""" + from script.todo.todo import TODO + + todo = TODO() + with patch( + "script.todo.assistant.claude_sessions.fleet", return_value=[] + ), patch("click.prompt", side_effect=["0"]), patch( + "script.todo.todo_telemetry.record" + ): + todo.prompt_claude_sessions() + + def test_un_claude_absent_est_un_message_pas_un_plantage(self): + import io + from contextlib import redirect_stdout + + from script.todo.todo import TODO + + todo = TODO() + sortie = io.StringIO() + with patch("shutil.which", return_value=None), redirect_stdout(sortie): + todo._claude_questionner([]) + todo._claude_reprendre([]) + self.assertIn(t("claude is not on the PATH."), sortie.getvalue()) + + def test_aucune_session_a_reprendre_le_dit(self): + import io + from contextlib import redirect_stdout + + from script.todo.todo import TODO + + todo = TODO() + sortie = io.StringIO() + with patch("shutil.which", return_value="/usr/bin/claude"), patch( + "click.prompt", side_effect=["0"] + ), redirect_stdout(sortie): + todo._claude_reprendre([]) + self.assertIn(t("No session on this machine."), sortie.getvalue()) + + +class Frontiere(unittest.TestCase): + """Le paquet doit vivre sans le CLI qui l'appelle.""" + + def test_le_paquet_assistant_n_importe_pas_todo(self): + """Vérifié dans un processus NEUF : ce fichier-ci importe `TODO`, donc + `sys.modules` le porte déjà et l'assertion passerait ici pour de + mauvaises raisons. + + Ce que la frontière achète est mesurable : importer `todo.py` coûte + près d'une seconde et imprime sur la sortie, et neuf fichiers de test + le paieraient à chaque exécution. + """ + code = ( + "import sys;" + "import script.todo.assistant.backends;" + "import script.todo.assistant.capabilities;" + "import script.todo.assistant.chat;" + "import script.todo.assistant.fingerprint;" + "import script.todo.assistant.servers;" + "print('script.todo.todo' in sys.modules)" + ) + res = subprocess.run( + [sys.executable, "-c", code], + cwd=RACINE, + capture_output=True, + text=True, + env={**os.environ, "PYTHONPATH": RACINE}, + timeout=60, + ) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(res.stdout.strip(), "False", res.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_assistant_probe_transport.py b/test/test_assistant_probe_transport.py new file mode 100644 index 0000000..36cb426 --- /dev/null +++ b/test/test_assistant_probe_transport.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le transport de la découverte, contre un VRAI serveur qui se conduit mal. + +`collect` ouvre de vraies sockets vers `llm_fake_server.FakeLLM`, lié à la +boucle locale sur un port choisi par le système. Rien ne quitte la machine, et +aucune de ces requêtes ne touche un serveur LLM réel. + +Un double de `requests` ne rendrait que ce qu'on aurait imaginé en l'écrivant, +et tout ce qui casse une découverte vient du TRANSPORT : une page +d'administration qui rend du HTML là où l'échelle attend du JSON, un défi 401, +un 503 pendant le chargement d'un modèle, une connexion coupée en plein corps, +un corps qui ne finit pas, un écouteur qui accepte et ne répond jamais. + +Ce que ce fichier défend, et qui coûte cher à qui le casse : le PLAFOND de +lecture, qui garde un corps de huit mégaoctets hors de la mémoire du menu ; le +DÉLAI, qui empêche un écouteur bloqué de figer le balayage ; et les deux +interdits du balayage — GET seulement, jamais d'en-tête `Authorization` — sans +lesquels une découverte pourrait charger un modèle ou dépenser un jeton. + +Les corps servis sont ceux de `FIXTURES`, que lisent aussi les tests purs de +`test_assistant_fingerprint.py` : les deux moitiés du module se vérifient donc +contre les mêmes octets. +""" + +import socketserver +import time +import unittest + +from llm_fake_server import ( + FIXTURES, + HOST, + Cut, + FakeLLM, + Huge, + Silent, + port_ferme, +) + +from script.todo.assistant.fingerprint import ( + BODY_CAP, + collect, + identify, + probe_plan, +) + +# Les familles que le transport doit reconnaître de bout en bout. « gpt4all » +# demande son port et « openai » son nom d'hôte : ni l'un ni l'autre ne se +# joue dans le transport. +# `FakeLLM.__exit__` appelle `shutdown()`, qui attend que la boucle d'accueil +# remarque la demande. Sans argument, `serve_forever()` la sonde toutes les +# 0,5 s : chaque serveur jetable coûte alors une demi-seconde de sommeil, et ce +# fichier en démarre une vingtaine. Raccourcir la période de sondage ne change +# ni ce qui est servi ni ce qui est reçu — elle ne règle que la vitesse à +# laquelle un serveur accepte de mourir, donc si ce fichier reste une boucle +# rapide. +_SERVE_FOREVER = socketserver.BaseServer.serve_forever + + +def _serve_forever_reactif(self, poll_interval=0.01): + return _SERVE_FOREVER(self, poll_interval) + + +def setUpModule(): + socketserver.BaseServer.serve_forever = _serve_forever_reactif + + +def tearDownModule(): + socketserver.BaseServer.serve_forever = _SERVE_FOREVER + + +FAMILIES = ( + "ollama", + "localai", + "localai_starting", + "llamacpp", + "koboldcpp", + "jan", + "lmstudio", + "tabbyapi", + "textgen_webui", + "vllm", +) + + +class Transport(unittest.TestCase): + """Ce que `collect` rend quand le serveur en face n'est pas poli.""" + + def test_un_port_mort_est_absent_pas_une_exception(self): + # Le port vient d'être rendu par le système : la connexion y est + # refusée tout de suite, elle n'expire pas. + bodies = collect(HOST, port_ferme(), budget=1.0) + self.assertEqual(bodies, {}) + + def test_du_html_sur_props_n_est_pas_un_llamacpp(self): + with FakeLLM("routeur") as serveur: + bodies = collect(serveur.host, serveur.port, budget=2.0) + # La page a bien répondu, et elle a répondu à `/props` : c'est le cas + # où une lecture non enveloppée lèverait. + self.assertEqual(bodies["/props"][0], 200) + self.assertTrue(bodies["/props"][1].startswith(b" mtu 65536 state UNKNOWN" + "\\ link/loopback 00:00:00:00:00:00 addrgenmode eui64\n" + "2: lien0: mtu 1500 state UP" + "\\ link/ether aa:bb:cc:dd:ee:01 promiscuity 0" + " bridge_slave state forwarding\n" + "3: pont0: mtu 1500 state DOWN" + "\\ link/ether aa:bb:cc:dd:ee:02 promiscuity 0" + "\\ bridge forward_delay 200 bridge_id 8000.aa:bb:cc:dd:ee:02\n" +) + +# La route par défaut désigne l'interface qui sort de la machine. Le noyau a +# déjà tranché, et c'est cette réponse-là qui ouvre la liste. +ROUTE = ( + "default via 192.0.2.1 dev lien0 proto dhcp src 192.0.2.10 metric 1024\n" +) + +# Une table de voisinage. Les deux premières ont répondu — elles portent une +# adresse matérielle ; les deux suivantes ont été sollicitées sans répondre ; +# la dernière répète la première sur une autre interface. +NEIGH = ( + "192.0.2.11 dev lien0 lladdr aa:bb:cc:dd:ee:11 REACHABLE\n" + "192.0.2.12 dev lien0 lladdr aa:bb:cc:dd:ee:12 STALE\n" + "192.0.2.13 dev lien0 FAILED\n" + "192.0.2.14 dev lien0 INCOMPLETE\n" + "192.0.2.11 dev pont0 lladdr aa:bb:cc:dd:ee:11 STALE\n" +) + + +def faux_ip(addr=ADDR, link=LINK, route=ROUTE): + """Un exécuteur de « ip » qui rend du texte choisi, sans sous-processus. + + Se branche sur `local_networks(run=…)` et distingue les trois questions + par leur verbe, comme la commande elle-même. + """ + + def run(argv): + if "addr" in argv: + return addr + if "link" in argv: + return link + if "route" in argv: + return route + return "" + + return run + + +class Horloge: + """Une horloge injectée qui avance d'une seconde par lecture. + + Une durée rendue par elle vaut au moins une seconde, là où le balayage + d'un test dure quelques centièmes : une durée supérieure à la seconde + PROUVE donc que c'est bien elle qui a été lue, et non l'horloge du + système. + """ + + def __init__(self): + self.lectures = 0 + + def __call__(self): + self.lectures += 1 + return 1000.0 + self.lectures + + +class Compteur: + """Un connecteur injecté : rend vrai sur les couples ouverts, et compte + ses appels.""" + + def __init__(self, ouverts): + self.ouverts = set(ouverts) + self.appels = 0 + + def __call__(self, host, port, timeout): + self.appels += 1 + return (host, port) in self.ouverts + + +class LesReseauxLocaux(unittest.TestCase): + def test_les_deux_slash24_locaux_sont_offerts(self): + reseaux = discover.local_networks(run=faux_ip()) + self.assertTrue(reseaux, "aucun réseau lu : le motif ne colle plus") + self.assertEqual([LAN, PONT], [item.cidr for item in reseaux]) + self.assertEqual(["lien0", "pont0"], [i.name for i in reseaux]) + + def test_la_boucle_locale_n_est_pas_un_reseau_a_balayer(self): + """Elle se sonde en quelques millisecondes, sans balayage : l'offrir + ferait payer 254 adresses pour une seule.""" + reseaux = discover.local_networks(run=faux_ip()) + self.assertTrue(reseaux, "aucun réseau lu") + for item in reseaux: + self.assertNotIn("127.", item.cidr) + self.assertNotEqual("lo", item.name) + + def test_le_pont_libvirt_est_signale_comme_tel(self): + """Le type « bridge » nomme un pont ; les attributs « bridge_slave » + et « bridge_id » n'en nomment pas un, et la ligne de l'interface + ordinaire porte le premier pour le prouver.""" + par_nom = {i.name: i for i in discover.local_networks(run=faux_ip())} + self.assertTrue(par_nom, "aucune interface lue") + self.assertTrue(par_nom["pont0"].is_bridge) + self.assertFalse(par_nom["lien0"].is_bridge) + + def test_l_interface_de_la_route_par_defaut_ouvre_la_liste(self): + reseaux = discover.local_networks( + run=faux_ip(addr=ADDR_INVERSE, route=ROUTE) + ) + self.assertTrue(reseaux, "aucun réseau lu") + self.assertEqual("lien0", reseaux[0].name) + self.assertEqual({"lien0", "pont0"}, {i.name for i in reseaux}) + + def test_un_prefixe_plus_large_est_rendu_tel_quel_pas_retreci(self): + """Rétrécir en silence présenterait une hypothèse comme une lecture : + le refus appartient au planificateur, qui peut le DIRE.""" + large = "2: lien0 inet 192.0.2.10/16 scope global lien0\n" + reseaux = discover.local_networks(run=faux_ip(addr=large)) + self.assertEqual(["192.0.0.0/16"], [i.cidr for i in reseaux]) + self.assertEqual([], discover.plan_sweep(reseaux[0].cidr)) + + def test_un_ip_absent_est_une_liste_vide_pas_une_panne(self): + def leve(argv): + raise FileNotFoundError(argv[0]) + + self.assertEqual([], discover.local_networks(run=leve)) + self.assertEqual([], discover.local_networks(run=lambda argv: "")) + + +class LePlanDeBalayage(unittest.TestCase): + def test_plus_large_qu_un_slash24_est_refuse(self): + for cidr in ("192.0.2.0/23", "192.0.2.0/16", "10.0.0.0/8", "::/64"): + self.assertEqual([], discover.plan_sweep(cidr), cidr) + self.assertEqual(254, discover.MAX_HOSTS) + + def test_un_slash24_donne_tous_ses_hotes_fois_tous_les_ports(self): + jobs = discover.plan_sweep(LAN) + attendu = discover.MAX_HOSTS * len(fingerprint.PORTS) + self.assertEqual(attendu, len(jobs)) + self.assertEqual(("192.0.2.1", fingerprint.PORTS[0]), jobs[0]) + + def test_les_ports_par_defaut_sont_ceux_de_l_echelle(self): + jobs = discover.plan_sweep("192.0.2.7/32") + self.assertEqual( + [(str("192.0.2.7"), port) for port in fingerprint.PORTS], jobs + ) + + def test_les_adresses_de_l_hote_sont_ecartees(self): + """Sans ce retrait, un balayage se reconnaît lui-même et offre la + passerelle d'un pont comme un serveur découvert.""" + siennes = ("192.0.2.10", "192.0.2.1") + jobs = discover.plan_sweep(LAN, (11434,), skip=siennes) + self.assertTrue(jobs, "plan vide : le retrait a tout emporté") + self.assertEqual(discover.MAX_HOSTS - 2, len(jobs)) + for adresse in siennes: + self.assertNotIn((adresse, 11434), jobs) + + def test_un_cidr_illisible_est_une_liste_vide(self): + for cidre in ("", " ", "pas-un-cidr", "192.0.2.0/33", None, 42): + self.assertEqual([], discover.plan_sweep(cidre), repr(cidre)) + + def test_un_ensemble_de_ports_vide_ne_planifie_rien(self): + self.assertEqual([], discover.plan_sweep(LAN, ())) + + +class LaTableDeVoisinage(unittest.TestCase): + def test_la_table_de_voisinage_ne_rend_que_ce_qui_a_parle(self): + adresses = discover.neigh_hosts(NEIGH) + self.assertEqual(["192.0.2.11", "192.0.2.12"], adresses) + + def test_une_table_vide_ou_abimee_ne_leve_pas(self): + for texte in ("", None, "n'importe quoi\nlladdr sans adresse\n"): + self.assertEqual([], discover.neigh_hosts(texte), repr(texte)) + + +class LaFrappe(unittest.TestCase): + def test_un_slash24_complet_n_emet_aucun_paquet(self): + """Le connecteur injecté est le seul chemin : une socket ouverte par + un chemin oublié tombe en rouge au lieu de parler au réseau.""" + jobs = discover.plan_sweep(LAN) + ouverts = {("192.0.2.7", 11434), ("192.0.2.42", 8080)} + connect = Compteur(ouverts) + with patch( + "script.todo.assistant.discover.socket.create_connection", + side_effect=AssertionError("aucun paquet ne doit sortir"), + ): + trouves = discover.sweep(jobs, connect=connect) + self.assertEqual(len(jobs), connect.appels) + self.assertEqual(ouverts, set(trouves)) + + def test_le_battement_part_sur_l_horloge_injectee(self): + horloge = Horloge() + evenements = [] + + def lent(host, port, timeout): + time.sleep(0.06) + return False + + discover.sweep( + [("192.0.2.7", 11434)], + connect=lent, + heartbeat_sec=0.01, + now=horloge, + on_event=evenements.append, + ) + battements = [e for e in evenements if e[0] == "heartbeat"] + self.assertTrue(battements, "aucun battement : la branche est muette") + for _nom, faits, total, secondes in battements: + self.assertLessEqual(faits, total) + self.assertGreaterEqual(secondes, 1.0) + + def test_un_hote_bloque_n_arrete_pas_les_autres(self): + bloque = ("192.0.2.99", 11434) + ouverts = {("192.0.2.7", 11434), ("192.0.2.8", 1234)} + + def connect(host, port, timeout): + if (host, port) == bloque: + time.sleep(0.05) + return False + return (host, port) in ouverts + + jobs = sorted(ouverts | {bloque, ("192.0.2.9", 5001)}) + trouves = discover.sweep( + jobs, connect=connect, heartbeat_sec=0.01, timeout=0.01 + ) + self.assertEqual(ouverts, set(trouves)) + + def test_un_connecteur_qui_leve_compte_pour_un_port_ferme(self): + def connect(host, port, timeout): + if host == "192.0.2.99": + raise OSError("pile réseau à bout") + return port == 11434 + + jobs = [("192.0.2.99", 11434), ("192.0.2.7", 11434)] + self.assertEqual( + [("192.0.2.7", 11434)], discover.sweep(jobs, connect=connect) + ) + + def test_les_evenements_rendent_les_memes_couples_que_le_retour(self): + """L'appelant peut affirmer sur des DONNÉES et non sur du texte + capté : c'est ce qui rend l'affichage remplaçable.""" + jobs = discover.plan_sweep("192.0.2.0/24", (11434, 1234)) + ouverts = {("192.0.2.7", 11434), ("192.0.2.9", 1234)} + evenements = [] + trouves = discover.sweep( + jobs, + connect=Compteur(ouverts), + on_event=evenements.append, + ) + dits = [(e[1], e[2]) for e in evenements if e[0] == "hit"] + self.assertTrue(dits, "aucune trouvaille annoncée") + self.assertEqual(sorted(trouves), sorted(dits)) + self.assertEqual( + [("done", len(trouves), len(jobs))], + [e[:3] for e in evenements if e[0] == "done"], + ) + + def test_un_plan_vide_se_termine_sans_piscine(self): + evenements = [] + self.assertEqual([], discover.sweep([], on_event=evenements.append)) + self.assertEqual(["done"], [e[0] for e in evenements]) + + +class LesSourcesInjectees(unittest.TestCase): + def test_la_piscine_est_dimensionnee_par_vagues_pas_par_coeurs(self): + """Ces fils attendent le réseau : un compte de cœurs à deux chiffres + multiplierait par quinze la durée d'un /24.""" + source = _source() + self.assertIn("min(len(jobs), workers)", source) + self.assertNotIn("cpu_count", source) + + def test_la_sonde_est_une_connexion_jamais_un_sous_processus_ping(self): + """Un `ping` par adresse coûterait un millier de fork+exec sur un + /24, là où une socket n'en coûte aucun.""" + source = _source() + self.assertIn("socket.create_connection", source) + self.assertNotIn('"ping"', source) + + +class LaFrontiere(unittest.TestCase): + def test_le_balayage_ne_tire_pas_todo(self): + """Importer `script.todo.todo` coûte près d'une seconde et imprime + sur la sortie : le paquet doit rester importable seul.""" + self.assertNotIn("script.todo.todo", sys.modules) + + def test_aucun_asyncio_du_depot_n_est_utilise(self): + """`AsyncioPool` enveloppe des sous-processus et non des sockets, et + passe à `asyncio.wait` un argument retiré de Python 3.10.""" + self.assertNotIn("lib_asyncio", _source()) + + +def _source(): + """Le texte du module de découverte, non vide.""" + with open(discover.__file__, encoding="utf-8") as fh: + texte = fh.read() + if not re.search(r"def sweep\(", texte): + raise AssertionError("source illisible : le module a changé de forme") + return texte + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_menu.py b/test/test_mail_menu.py index 593f7e8..554eb68 100644 --- a/test/test_mail_menu.py +++ b/test/test_mail_menu.py @@ -587,7 +587,7 @@ class TestTodoWiring(unittest.TestCase): def test_todo_keeps_the_ai_question(self): from script.todo.todo import TODO - self.assertTrue(hasattr(TODO, "_assistant_question")) + self.assertTrue(hasattr(TODO, "prompt_assistant_llm")) def test_assistant_key_is_translated(self): from script.todo.todo_i18n import TRANSLATIONS @@ -598,7 +598,7 @@ class TestTodoWiring(unittest.TestCase): def test_one_dispatches_to_assistant_question_only(self): """`hasattr` seul ne verrait pas deux branches de menu échangées — on pilote `click.prompt` et on vérifie que `[1]` appelle - `_assistant_question`, PAS `prompt_execute_mail`. + `prompt_assistant_llm`, PAS `prompt_execute_mail`. `_menu_header()` enregistre aussi une télémétrie best-effort dans `~/.erplibre` : on la neutralise, sinon ce test écrirait pour de @@ -609,9 +609,13 @@ class TestTodoWiring(unittest.TestCase): from script.todo.todo import TODO todo = TODO() - with patch.object(TODO, "_assistant_question") as mock_question, patch( + with patch.object( + TODO, "prompt_assistant_llm" + ) as mock_question, patch( "script.todo.mail.menu.prompt_execute_mail" - ) as mock_mail, patch("click.prompt", side_effect=["1", "0"]), patch( + ) as mock_mail, patch( + "click.prompt", side_effect=["1", "0"] + ), patch( "script.todo.todo_telemetry.record" ): todo.prompt_assistant() @@ -621,15 +625,19 @@ class TestTodoWiring(unittest.TestCase): def test_two_dispatches_to_mail_only(self): """Symétrique : `[2]` appelle `prompt_execute_mail`, PAS - `_assistant_question`.""" + `prompt_assistant_llm`.""" from unittest.mock import patch from script.todo.todo import TODO todo = TODO() - with patch.object(TODO, "_assistant_question") as mock_question, patch( + with patch.object( + TODO, "prompt_assistant_llm" + ) as mock_question, patch( "script.todo.mail.menu.prompt_execute_mail" - ) as mock_mail, patch("script.todo.todo_telemetry.record"), patch( + ) as mock_mail, patch( + "script.todo.todo_telemetry.record" + ), patch( "click.prompt", side_effect=["2", "0"] ): todo.prompt_assistant() From eb28952d6c22f52f78e614043440747197adb9cb Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 9 Sep 2026 04:29:46 -0400 Subject: [PATCH 02/12] =?UTF-8?q?[FIX]=20execute=20:=20caviarder=20les=20c?= =?UTF-8?q?l=C3=A9s=20d'API=20et=20les=20jetons=20Bearer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le filtre ne connaissait que trois noms de variable — mot de passe, secret, jeton — donc `OPENAI_API_KEY=` partait en clair dans le terminal, dans les journaux et dans toute sortie CI qui les capture. Un jeton d'en-tête échappait aux deux règles par construction : il ne porte ni nom d'option ni nom de variable, il suit le mot « Bearer ». Le filtre couvre maintenant `API_KEY` côté variables et `Authorization: Bearer|Basic` côté en-têtes, sans casse, la valeur allant jusqu'au premier blanc. Il protège au même titre la restauration de base, qui l'appelle sur six sorties. Reste le dernier rempart et non le premier : argv est lisible par tout compte de la machine, où aucun caviardage n'atteint. --- EN --- The filter knew only three variable names — password, secret, token — so `OPENAI_API_KEY=` went out in the clear to the terminal, to the logs and to any CI output capturing them. A header token escaped both rules by construction: it carries neither an option name nor a variable name, it follows the word "Bearer". The filter now covers `API_KEY` on the variable side and `Authorization: Bearer|Basic` on the header side, case-insensitively, the value running to the first blank. It protects database restore just as much, which calls it on six outputs. It stays the last line of defence, not the first: argv is readable by every account on the machine, where no redaction reaches. Assisted-by: Claude Opus 5 --- script/execute/execute.py | 15 ++++++-- script/todo/assistant/backends.py | 6 +-- script/todo/assistant_menu.py | 7 ++-- test/test_execute.py | 63 ++++++++++++++++++++++++++++++- 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/script/execute/execute.py b/script/execute/execute.py index 09515b8..e3ba8e7 100644 --- a/script/execute/execute.py +++ b/script/execute/execute.py @@ -46,13 +46,21 @@ _SECRET_OPTION = re.compile( re.IGNORECASE, ) _SECRET_ENV = re.compile( - r"(?P\b\w*(?:PASSWORD|PASSWD|SECRET|TOKEN)\w*=)" + r"(?P\b\w*(?:PASSWORD|PASSWD|SECRET|TOKEN|API_?KEY)\w*=)" r"(?P'[^']*'|\"[^\"]*\"|\S+)" ) +# Un jeton porté par un en-tête n'a ni nom d'option ni nom de variable : il +# suit le mot « Bearer », et les deux règles ci-dessus passent à côté. Le +# schéma est nommé par la RFC 7235 et se compare sans casse, la valeur allant +# jusqu'à la fin de la ligne — un jeton ne porte pas d'espace. +_SECRET_HEADER = re.compile( + r"(?P\bAuthorization:\s*(?:Bearer|Basic)\s+)(?P\S+)", + re.IGNORECASE, +) def redact_secrets(text): - """Remplace la valeur des options et variables porteuses de secret. + """Remplace la valeur des options, variables et en-têtes de secret. Appliqué à CHAQUE affichage d'une commande. Filtrer au point d'affichage plutôt qu'à la construction est ce qui rend la garantie tenable : il n'y a @@ -62,7 +70,8 @@ def redact_secrets(text): if not text: return text text = _SECRET_OPTION.sub(lambda m: m.group("opt") + "'***'", text) - return _SECRET_ENV.sub(lambda m: m.group("var") + "'***'", text) + text = _SECRET_ENV.sub(lambda m: m.group("var") + "'***'", text) + return _SECRET_HEADER.sub(lambda m: m.group("schema") + "'***'", text) new_path = os.path.normpath( diff --git a/script/todo/assistant/backends.py b/script/todo/assistant/backends.py index bfe92de..a83884e 100644 --- a/script/todo/assistant/backends.py +++ b/script/todo/assistant/backends.py @@ -16,9 +16,9 @@ l'historique local à une session qui la garde déjà la doublerait. **La clé ne quitte jamais le processus.** Elle va à `openai.OpenAI(api_key=…)` en mémoire, jamais sur une ligne de commande ni dans une variable d'environnement : un argv se lit par n'importe quel compte local dès que -`/proc` est monté sans `hidepid`, et le masquage de -`script/execute/execute.py` ne reconnaît que `PASSWORD|PASSWD|SECRET|TOKEN`, -donc ni `OPENAI_API_KEY=` ni `Authorization: Bearer`. +`/proc` est monté sans `hidepid`, et AUCUN masquage n'atteint argv. Celui de +`script/execute/execute.py` couvre `OPENAI_API_KEY=` et `Authorization: +Bearer` dans une TRACE, ce qui est le dernier rempart et non le premier. **L'invite de `claude` part sur l'entrée standard**, jamais en positionnel, pour la même raison. `claude_argv` bâtit donc l'argv SANS l'invite, et diff --git a/script/todo/assistant_menu.py b/script/todo/assistant_menu.py index 1d45b23..e607a77 100644 --- a/script/todo/assistant_menu.py +++ b/script/todo/assistant_menu.py @@ -1342,9 +1342,10 @@ class AssistantMenuMixin: """La clé du coffre, ou une chaîne vide quand il n'en porte aucune. La clé reste en mémoire du processus : /proc expose la ligne de - commande de chaque processus à tout compte de la machine, et un - `redact_secrets` qui ne reconnaît pas « API_KEY » ne la masquerait pas - non plus dans une trace. + commande de chaque processus à tout compte de la machine, et aucun + caviardage n'atteint argv. Le filtre du dépôt masque « API_KEY » et + « Bearer » dans une trace, ce qui est le dernier rempart et non le + premier. """ kp = self.kdbx_manager.get_kdbx() if not kp: diff --git a/test/test_execute.py b/test/test_execute.py index 1794987..6f1dc64 100644 --- a/test/test_execute.py +++ b/test/test_execute.py @@ -6,7 +6,7 @@ import os import unittest from unittest.mock import patch -from script.execute.execute import Execute +from script.execute.execute import Execute, redact_secrets class TestExecuteInit(unittest.TestCase): @@ -169,5 +169,66 @@ class TestExecCommandLive(unittest.TestCase): self.assertEqual(output, []) +class TestRedactSecrets(unittest.TestCase): + """Ce qui doit disparaître d'une commande affichée, et ce qui doit rester. + + Le caviardage est le DERNIER rempart et non le premier : un secret sur + argv est déjà lisible par tout compte de la machine dans + /proc//cmdline, où aucun filtre n'atteint. Ces tests défendent donc + la trace — terminal, journal, sortie CI — et rien d'autre. + + Trois familles portent un secret, et elles ne se ressemblent pas. Une + option se reconnaît par son nom, une variable d'environnement par le + sien, et un jeton d'en-tête n'a NI l'un NI l'autre : il suit le mot + « Bearer ». Un filtre bâti sur les deux premières laisse passer la + troisième, qui est exactement celle qu'une API de modèle emploie. + + Les valeurs sont inventées, comme l'exige la règle du dépôt pour tout + exemple qui illustre un interdit. + """ + + def test_env_api_key_is_redacted(self): + sortie = redact_secrets("OPENAI_API_KEY=sk-inventeXYZ python x.py") + self.assertNotIn("sk-inventeXYZ", sortie) + self.assertIn("OPENAI_API_KEY='***'", sortie) + + def test_env_apikey_without_separator(self): + sortie = redact_secrets("MISTRAL_APIKEY=abc123 ./run") + self.assertNotIn("abc123", sortie) + + def test_bearer_header_is_redacted(self): + sortie = redact_secrets( + "curl -H 'Authorization: Bearer sk-inventeABC' http://h/v1/models" + ) + self.assertNotIn("sk-inventeABC", sortie) + self.assertIn("Authorization: Bearer '***'", sortie) + + def test_basic_header_is_redacted(self): + sortie = redact_secrets("Authorization: Basic dXNlcjpmYXV4") + self.assertNotIn("dXNlcjpmYXV4", sortie) + + def test_header_case_is_ignored(self): + sortie = redact_secrets("authorization: bearer sk-inventeDEF") + self.assertNotIn("sk-inventeDEF", sortie) + + def test_option_password_still_redacted(self): + sortie = redact_secrets("odoo --db_password 'inventeGHI'") + self.assertNotIn("inventeGHI", sortie) + + def test_option_name_survives(self): + """Le nom de l'option reste : la commande doit rester reproductible.""" + sortie = redact_secrets("odoo --db_password 'inventeJKL'") + self.assertIn("--db_password", sortie) + + def test_a_path_is_not_a_secret(self): + """Rien ne disparaît d'une commande qui ne porte aucun secret.""" + commande = "make test_unit_file F=test/test_execute.py" + self.assertEqual(redact_secrets(commande), commande) + + def test_empty_text_survives(self): + self.assertEqual(redact_secrets(""), "") + self.assertIsNone(redact_secrets(None)) + + if __name__ == "__main__": unittest.main() From 7c83dd1fc8395cff1d5ce88ab81cbaaa974a4620 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 9 Sep 2026 04:33:07 -0400 Subject: [PATCH 03/12] [FIX] qemu : lire les baux dnsmasq sans invite de mot de passe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La lecture passait par « sudo sh -c cat » sans jamais demander si le privilège était nécessaire. Elle est appelée une fois par VM pour afficher une liste, et toutes les trois secondes pendant dix minutes par l'attente d'une VM : l'invite root tombait donc en boucle au milieu d'un écran, ce qui entraîne à taper un mot de passe root dans ce qui le demande. La lecture directe passe d'abord et suffit sur une installation standard, ces fichiers d'état étant en 0644 là où le « .conf » voisin est en 0600. Le privilège n'est tenté qu'ensuite, avec « -n », qui échoue au lieu de demander ; un répertoire interdit rend un glob vide, d'où l'essai même sans chemin trouvé. Les deux appelants se replient déjà sur une autre source. Vérifié : 12 tests. --- EN --- The read went through "sudo sh -c cat" without ever asking whether the privilege was needed. It is called once per VM to display a list, and every three seconds for ten minutes while waiting on a VM: the root prompt therefore landed in a loop in the middle of a screen, which trains someone to type a root password into whatever asks for it. The direct read comes first and suffices on a standard install, those status files being 0644 where the neighbouring ".conf" is 0600. The privilege is only tried afterwards, with "-n", which fails instead of asking; a forbidden directory returns an empty glob, hence the attempt even with no path found. Both callers already fall back to another source. Checked: 12 tests. Assisted-by: Claude Opus 5 --- script/todo/qemu_manage.py | 95 +++++++++++++--- test/test_qemu_lease_read.py | 208 +++++++++++++++++++++++++++++++++++ 2 files changed, 287 insertions(+), 16 deletions(-) create mode 100644 test/test_qemu_lease_read.py diff --git a/script/todo/qemu_manage.py b/script/todo/qemu_manage.py index e565838..9deafa5 100644 --- a/script/todo/qemu_manage.py +++ b/script/todo/qemu_manage.py @@ -22,6 +22,78 @@ from script.todo.qemu_privilege import ( from script.todo.todo_i18n import t +# Les fichiers d'état de dnsmasq, un par réseau libvirt. Sur une installation +# standard ils sont en 0644 dans un répertoire en 0755, donc lisibles sans +# privilège — le « .conf » posé à côté est en 0600, et c'est lui qui donne +# l'impression que le répertoire est fermé. +DNSMASQ_STATUS = "/var/lib/libvirt/dnsmasq/*.status" + + +def lease_status_text(*, paths=None, read=None, run=None, euid=None) -> str: + """Le contenu des fichiers d'état de dnsmasq, ou la chaîne vide. + + La lecture DIRECTE passe d'abord, et elle suffit sur une installation + standard, où ces fichiers sont en lecture pour tous. Ce n'est qu'ensuite + que « sudo -n » est tenté, pour l'installation durcie — jamais « sudo » + tout court. L'attente d'une VM appelle ce chemin + toutes les trois secondes pendant dix minutes, et l'affichage d'une liste + l'appelle une fois par VM : un sudo interactif y demande donc un mot de + passe root en boucle, au milieu d'un écran, ce qui entraîne précisément le + réflexe de le taper dans ce qui le demande. + + `needs_sudo()` ne tranche pas ici : il dit si libvirt est joignable, pas + si un fichier de root est lisible. Les deux questions n'ont ni la même + réponse ni la même cause. + + Rend la chaîne vide quand rien n'est lisible — un répertoire interdit rend + un glob VIDE, indistinguable de « aucun réseau », d'où l'essai privilégié + même sans chemin trouvé. Les appelants se replient déjà sur une autre + source. + """ + if paths is None: + paths = glob.glob + if read is None: + + def read(chemin): + with open(chemin, encoding="utf-8") as fh: + return fh.read() + + if run is None: + run = subprocess.run + if euid is None: + euid = os.geteuid + + morceaux = [] + for chemin in sorted(paths(DNSMASQ_STATUS)): + try: + morceaux.append(read(chemin)) + except OSError: + morceaux = [] + break + if morceaux: + return "".join(morceaux) + if euid() == 0: + # Root a déjà tout vu : il n'y a rien à lire, et sudo n'y changerait + # rien. + return "" + try: + res = run( + [ + "sudo", + "-n", + "sh", + "-c", + f"cat {DNSMASQ_STATUS} 2>/dev/null", + ], + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return "" + return res.stdout or "" + + def parse_ssh_blocks(content) -> dict: """{nom: {"hostname": …, "proxyjump": …}} pour CHAQUE nom déclaré. @@ -3022,23 +3094,14 @@ class QemuManageMixin: @staticmethod def _qemu_lease_ip_for_host(name, candidates): """Parmi `candidates`, l'IP dont le bail dnsmasq porte le hostname de la - VM (le bail DÉFINITIF, pas le bail précoce « ubuntu »). None sinon.""" - try: - res = subprocess.run( - [ - "sudo", - "sh", - "-c", - "cat /var/lib/libvirt/dnsmasq/*.status 2>/dev/null", - ], - capture_output=True, - text=True, - timeout=10, - ) - except (OSError, subprocess.SubprocessError): - return None + VM (le bail DÉFINITIF, pas le bail précoce « ubuntu »). None sinon. + + La lecture ne demande JAMAIS de mot de passe : `lease_status_text` lit + en direct puis tente « sudo -n », et rend la chaîne vide plutôt que + d'ouvrir une invite. Les deux appelants se replient sur une autre + source quand ceci rend None.""" # Plusieurs tableaux JSON concaténés : on parse chaque objet {...}. - for obj in re.findall(r"\{[^{}]*\}", res.stdout or ""): + for obj in re.findall(r"\{[^{}]*\}", lease_status_text()): if re.search(rf'"hostname":\s*"{re.escape(name)}"', obj): m = re.search(r'"ip-address":\s*"([\d.]+)"', obj) if m and m.group(1) in candidates: diff --git a/test/test_qemu_lease_read.py b/test/test_qemu_lease_read.py new file mode 100644 index 0000000..789a15b --- /dev/null +++ b/test/test_qemu_lease_read.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""La lecture des baux dnsmasq : jamais une invite de mot de passe. + +Ce que ces tests défendent n'est pas une fonctionnalité, c'est une +ABSENCE. Le chemin lisait les baux par « sudo sh -c cat », sans jamais +demander s'il en avait besoin. Or il est appelé une fois par VM pour +afficher une liste, et toutes les trois secondes pendant dix minutes par +l'attente d'une VM : l'invite de mot de passe root tombait donc au milieu +d'un écran, en boucle, et entraînait à taper un mot de passe root dans ce +qui le demande. C'est l'habitude qui coûte, pas l'appel. + +Trois règles en sortent, et chacune a son test. + +La lecture directe passe d'abord, et elle suffit sur une installation +standard : ces fichiers d'état sont en lecture pour tous, contrairement au +« .conf » posé à côté. Le privilège n'est tenté qu'ensuite, et seulement +avec « -n », qui échoue au lieu de demander. Et un répertoire interdit rend +un glob VIDE, indistinguable de « aucun réseau » — d'où l'essai privilégié +même sans chemin trouvé. + +Les adresses sont INVENTÉES, comme l'exige la règle du dépôt pour tout +exemple qui illustre un interdit : 192.168.199.x ne paraît nulle part +ailleurs. +""" + +import unittest +from unittest import mock + +from script.todo.qemu_manage import DNSMASQ_STATUS, lease_status_text + +BAIL = ( + '{"ip-address":"192.168.199.24","mac-address":"52:54:00:aa:bb:cc",' + '"hostname":"machine-un","expiry-time":1788000000}' +) +BAIL_AUTRE = ( + '{"ip-address":"192.168.199.31","mac-address":"52:54:00:dd:ee:ff",' + '"hostname":"machine-deux","expiry-time":1788000001}' +) + + +class Appels: + """Un exécuteur qui note ce qu'on lui a demandé de lancer.""" + + def __init__(self, stdout="", leve=None): + self.stdout = stdout + self.leve = leve + self.argvs = [] + + def __call__(self, argv, **kwargs): + self.argvs.append(argv) + if self.leve: + raise self.leve + return mock.Mock(stdout=self.stdout, returncode=0) + + +class TestLectureDirecte(unittest.TestCase): + def test_direct_read_wins(self): + run = Appels(stdout="ne doit pas servir") + texte = lease_status_text( + paths=lambda motif: ["/a.status", "/b.status"], + read=lambda chemin: BAIL if chemin == "/a.status" else BAIL_AUTRE, + run=run, + euid=lambda: 1000, + ) + self.assertIn("192.168.199.24", texte) + self.assertIn("192.168.199.31", texte) + self.assertEqual(run.argvs, [], "aucun privilège n'était nécessaire") + + def test_readable_but_empty_does_not_escalate(self): + """Un réseau sans bail rend un fichier VIDE, pas une interdiction. + + Escalader ici lancerait un sudo par VM sur toute machine dont le + réseau libvirt n'a encore servi aucun bail.""" + run = Appels(stdout=BAIL) + texte = lease_status_text( + paths=lambda motif: ["/virbr0.status"], + read=lambda chemin: "", + run=run, + euid=lambda: 1000, + ) + self.assertEqual(texte, "") + self.assertEqual(run.argvs, []) + + def test_paths_are_sorted(self): + """L'ordre de lecture ne dépend pas de celui du système de fichiers.""" + lus = [] + + def read(chemin): + lus.append(chemin) + return "" + + lease_status_text( + paths=lambda motif: ["/z.status", "/a.status"], + read=read, + run=Appels(), + euid=lambda: 1000, + ) + self.assertEqual(lus, ["/a.status", "/z.status"]) + + +class TestReplisPrivilegies(unittest.TestCase): + def test_unreadable_file_escalates(self): + run = Appels(stdout=BAIL) + texte = lease_status_text( + paths=lambda motif: ["/a.status"], + read=mock.Mock(side_effect=PermissionError(13, "refusé")), + run=run, + euid=lambda: 1000, + ) + self.assertIn("192.168.199.24", texte) + self.assertEqual(len(run.argvs), 1) + + def test_empty_glob_escalates(self): + """Un répertoire interdit rend un glob vide, non une erreur.""" + run = Appels(stdout=BAIL) + texte = lease_status_text( + paths=lambda motif: [], + read=lambda chemin: "", + run=run, + euid=lambda: 1000, + ) + self.assertIn("192.168.199.24", texte) + + def test_sudo_is_never_interactive(self): + """« -n » suit « sudo » immédiatement : sudo échoue au lieu de demander. + + C'est la seule assertion de ce fichier qui porte sur la FORME de + l'argv, et elle le fait parce que l'ordre compte : « sudo sh -c … -n » + passerait le drapeau au shell, pas à sudo.""" + run = Appels(stdout="") + lease_status_text( + paths=lambda motif: [], + read=lambda chemin: "", + run=run, + euid=lambda: 1000, + ) + argv = run.argvs[0] + self.assertEqual(argv[0], "sudo") + self.assertEqual(argv[1], "-n") + self.assertIn(DNSMASQ_STATUS, " ".join(argv)) + + def test_root_does_not_escalate(self): + """Root a déjà tout vu : sudo n'y changerait rien.""" + run = Appels(stdout=BAIL) + texte = lease_status_text( + paths=lambda motif: [], + read=lambda chemin: "", + run=run, + euid=lambda: 0, + ) + self.assertEqual(texte, "") + self.assertEqual(run.argvs, []) + + def test_sudo_absent_returns_empty(self): + texte = lease_status_text( + paths=lambda motif: [], + read=lambda chemin: "", + run=Appels(leve=FileNotFoundError(2, "sudo")), + euid=lambda: 1000, + ) + self.assertEqual(texte, "") + + def test_sudo_refusal_returns_empty(self): + """« sudo -n » sans droit rend un code non nul et un stdout vide.""" + texte = lease_status_text( + paths=lambda motif: [], + read=lambda chemin: "", + run=lambda argv, **kw: mock.Mock(stdout="", returncode=1), + euid=lambda: 1000, + ) + self.assertEqual(texte, "") + + +class TestBailParHostname(unittest.TestCase): + """Le parcours qui consomme le texte, pour que le repli reste vrai.""" + + def _chercher(self, texte, nom, candidates): + from script.todo import qemu_manage + + with mock.patch.object( + qemu_manage, "lease_status_text", return_value=texte + ): + return qemu_manage.QemuManageMixin._qemu_lease_ip_for_host( + nom, candidates + ) + + def test_the_lease_naming_the_vm_wins(self): + trouve = self._chercher( + BAIL + BAIL_AUTRE, + "machine-deux", + ["192.168.199.24", "192.168.199.31"], + ) + self.assertEqual(trouve, "192.168.199.31") + + def test_a_lease_outside_the_candidates_is_ignored(self): + """Un bail périmé nomme la VM sans être une candidate joignable.""" + trouve = self._chercher(BAIL, "machine-un", ["192.168.199.99"]) + self.assertIsNone(trouve) + + def test_no_readable_lease_returns_none(self): + """Le cas du privilège refusé : None, et l'appelant se replie.""" + self.assertIsNone(self._chercher("", "machine-un", ["192.168.199.24"])) + + +if __name__ == "__main__": + unittest.main() From 85ac83cc754b67193272ec34468a4b1dffe4a370 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 9 Sep 2026 04:34:46 -0400 Subject: [PATCH 04/12] [FIX] conventions : retirer un nom d'organisation des exemples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un nom d'organisation servait d'exemple dans une docstring et dans quatre tests — nom de base, chemin de compte, préfixe de copie. La règle du dépôt l'interdit partout hors de `private/`, et ces fichiers suivent le dépôt en amont : sur un fork rendu public, l'exemple le devient aussi. Un test le fige pour toujours, et trois des cinq sites servaient justement à démontrer qu'un chemin de compte ou un nom est refusé. Une valeur inventée démontre aussi bien, et celle-ci ne paraît nulle part ailleurs. La docstring perd son exemple : il n'illustrait que « long », et la phrase le dit maintenant. Le détecteur du dépôt ne voit pas cette classe — il n'annonçait rien ici. --- EN --- An organisation's name served as an example in one docstring and in four tests — a database name, an account path, a copy prefix. The repository's rule forbids it anywhere outside `private/`, and these files follow the repository upstream: on a fork made public, the example becomes public too. A test freezes it forever, and three of the five sites existed precisely to demonstrate that an account path or a name is refused. An invented value demonstrates just as well, and this one appears nowhere else. The docstring loses its example: it only illustrated "long", which the sentence now says. The repository's detector does not see this class — it reported nothing here. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 4 ++-- test/test_db_duplicate.py | 2 +- test/test_git_commit_msg.py | 2 +- test/test_monitoring.py | 2 +- test/test_prompt_defaults.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 8e45978..efdefbf 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -4425,8 +4425,8 @@ class TODO( de modèles et de colonnes, et lesquels sont traduits ou uniques. La confirmation redemande le NOM de la base. Une frappe sur « o » - se donne par réflexe ; recopier « sireine_neutralize_upgrade_18 » - oblige à regarder ce qu'on détruit. + se tape par réflexe ; recopier un nom long oblige à regarder ce + qu'on détruit. """ from script.analyse import monitoring diff --git a/test/test_db_duplicate.py b/test/test_db_duplicate.py index ee32a61..6608c5a 100644 --- a/test/test_db_duplicate.py +++ b/test/test_db_duplicate.py @@ -26,7 +26,7 @@ class TestWhatANameMayBe(unittest.TestCase): """Le nom entre dans du SQL par `database_identifier` : il se filtre.""" def test_ordinary_names_pass(self): - for nom in ("sireine", "el_essai", "a", "base-2024", "_interne"): + for nom in ("garance", "el_essai", "a", "base-2024", "_interne"): self.assertTrue(dup.nom_valide(nom), nom) def test_a_name_that_could_carry_sql_is_refused(self): diff --git a/test/test_git_commit_msg.py b/test/test_git_commit_msg.py index 3bc9036..af5743e 100644 --- a/test/test_git_commit_msg.py +++ b/test/test_git_commit_msg.py @@ -214,7 +214,7 @@ class TestLeCorps(unittest.TestCase): def test_un_chemin_de_compte(self): problemes = check( - _message("Le venv vit dans /home/sireine/git/erplibre/.") + _message("Le venv vit dans /home/garance/git/erplibre/.") ) self.assertEqual(1, len(problemes)) self.assertIn("chemin de compte", problemes[0]) diff --git a/test/test_monitoring.py b/test/test_monitoring.py index 9b858aa..9e0181e 100644 --- a/test/test_monitoring.py +++ b/test/test_monitoring.py @@ -667,7 +667,7 @@ class TestTheVerdictsSection(unittest.TestCase): ) def test_a_plain_name_is_its_own_lineage(self): - self.assertEqual("copy_sireine3", residue.famille("copy_sireine3")) + self.assertEqual("copy_garance3", residue.famille("copy_garance3")) def test_another_migration_verdicts_are_not_shown(self): # Deux migrations partagent le fichier. Attribuer l'échec de diff --git a/test/test_prompt_defaults.py b/test/test_prompt_defaults.py index 14e96a8..88cfc97 100644 --- a/test/test_prompt_defaults.py +++ b/test/test_prompt_defaults.py @@ -257,7 +257,7 @@ class TestTheDatabaseNameTheFileSuggests(unittest.TestCase): return database_name_from_file(chemin, **kw) def test_the_zip_extension_goes_away(self): - self.assertEqual("sireine3", self.nom("image_db/sireine3.zip")) + self.assertEqual("garance3", self.nom("image_db/garance3.zip")) def test_an_uppercase_extension_goes_away_too(self): self.assertEqual("client", self.nom("image_db/CLIENT.ZIP")) From 2df3be2df87b1ccae6c7df84c12c9494f3b3ec15 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 9 Sep 2026 04:39:01 -0400 Subject: [PATCH 05/12] =?UTF-8?q?[FIX]=20ssh=20config=20:=20une=20seule=20?= =?UTF-8?q?d=C3=A9cision=20sur=20ce=20qu'est=20un=20nom=20de=20machine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois lecteurs de ~/.ssh/config tranchaient la même question autrement : l'un comparait le mot-clé avec la casse, un autre exigeait un espace là où une tabulation est légale, un troisième laissait passer le motif nié `!nom`. Un alias déclaré « host » en minuscules était donc vu par l'un et invisible aux deux autres, ce qui se lit comme une panne intermittente de la découverte. La décision passe dans un module qui ne lit aucun fichier. Il rend None sur ce qui n'est pas une déclaration et une liste — possiblement vide — sur ce qui en est une : « Host * » ne nomme aucune machine mais reste une déclaration, sinon ses directives se rattachent au bloc précédent. Vérifié : 19 tests, et le menu reconnaît toujours le serveur du réseau par sa source SSH. --- EN --- Three readers of ~/.ssh/config settled the same question differently: one compared the keyword case-sensitively, another required a space where a tab is legal, a third let the negated pattern `!name` through. An alias declared with a lowercase "host" was therefore seen by one and invisible to the other two, which reads as intermittent discovery failure. The decision moves into a module that reads no file. It returns None for what is not a declaration and a list — possibly empty — for what is: "Host *" names no machine yet remains a declaration, otherwise its directives attach to the previous block. Checked: 19 tests, and the menu still recognises the server on the network through its SSH source. Assisted-by: Claude Opus 5 --- script/todo/qemu_manage.py | 14 ++-- script/todo/ssh_config.py | 51 ++++++++++++ script/todo/todo.py | 36 ++++----- test/test_ssh_config_names.py | 144 ++++++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 25 deletions(-) create mode 100644 script/todo/ssh_config.py create mode 100644 test/test_ssh_config_names.py diff --git a/script/todo/qemu_manage.py b/script/todo/qemu_manage.py index 9deafa5..84428c1 100644 --- a/script/todo/qemu_manage.py +++ b/script/todo/qemu_manage.py @@ -12,7 +12,7 @@ import shutil import subprocess import time -from script.todo import todo_install +from script.todo import ssh_config, todo_install from script.todo.qemu_privilege import ( LIBVIRT_URI as URI, sudo_prefix, @@ -98,15 +98,15 @@ def parse_ssh_blocks(content) -> dict: """{nom: {"hostname": …, "proxyjump": …}} pour CHAQUE nom déclaré. Une ligne « Host » peut en porter plusieurs : ils partagent alors le même - corps, donc la même entrée. Les motifs (« * », « ? ») sont écartés — ce - sont des règles, pas des machines.""" + corps, donc la même entrée. Ce qui compte comme un nom de machine est + tranché par `ssh_config.declared_names`, partagé avec les deux lecteurs + de todo.py.""" blocs, courant = {}, [] for ligne in (content or "").splitlines(): - if re.match(r"^[ \t]*Host[ \t]+", ligne): + declares = ssh_config.declared_names(ligne) + if declares is not None: corps = {} - courant = [ - n for n in ligne.split()[1:] if "*" not in n and "?" not in n - ] + courant = declares for nom in courant: blocs[nom] = corps continue diff --git a/script/todo/ssh_config.py b/script/todo/ssh_config.py new file mode 100644 index 0000000..f5ecc52 --- /dev/null +++ b/script/todo/ssh_config.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ce qu'est un nom de machine dans ~/.ssh/config, décidé en un seul endroit. + +Trois lecteurs de ce fichier vivent dans ce dépôt — l'énumération des alias, +le menu de montage qui veut aussi l'adresse, et l'inventaire des entrées de +VM — et ils divergeaient sur la MÊME question. L'un comparait le mot-clé avec +la casse, un autre exigeait un espace là où une tabulation est légale, un +troisième laissait passer le motif nié. Un alias déclaré `host exo` était donc +vu par l'un et invisible aux autres, ce qui se lit comme une panne +intermittente de la découverte. + +Le module ne lit aucun fichier et ne suit pas `Include` : il tranche une +ligne, rien de plus. Un alias déclaré dans un fichier inclus reste donc +invisible aux trois lecteurs, alors même que `ssh -G` le résoudrait — la +source est incomplète sans être fausse. +""" +from __future__ import annotations + +import re + +# Le mot-clé, suivi d'au moins un blanc. ssh_config ne distingue pas la casse +# de ses mots-clés, et sépare par espace OU tabulation. Le blanc est exigé +# sans quoi `HostName` serait lu comme une déclaration d'hôte. +DECLARATION = re.compile(r"^[ \t]*host[ \t]+", re.IGNORECASE) + + +def declared_names(line) -> list | None: + """Les noms de machine d'une ligne « Host », ou None si ce n'en est pas une. + + Rend None pour toute autre ligne, et une liste — possiblement VIDE — + quand la ligne est une déclaration. Distinguer les deux compte : `Host *` + EST une déclaration qui ne nomme aucune machine, et un lecteur qui + accumule un bloc doit clore le précédent malgré tout. Rendre `[]` dans + les deux cas rattachait les directives d'un bloc générique au bloc + précédent. + + Trois formes ne désignent aucune machine et sortent : les jokers `*` et + `?`, qui décrivent une règle appliquée à plusieurs hôtes, et le motif NIÉ + `!nom`, qui RETIRE un nom de l'ensemble que la ligne vient de décrire. + Retenir un motif nié rend une cible dont le nom commence par `!`, que ssh + ne résoudra jamais. + """ + if not DECLARATION.match(line or ""): + return None + return [ + nom + for nom in line.split()[1:] + if "*" not in nom and "?" not in nom and not nom.startswith("!") + ] diff --git a/script/todo/todo.py b/script/todo/todo.py index efdefbf..1cd7042 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -26,7 +26,7 @@ sys.path.append(new_path) from script.config import config_file from script.execute import execute -from script.todo import dev_tools, todo_install, todo_prefs +from script.todo import dev_tools, ssh_config, todo_install, todo_prefs from script.todo.assistant_menu import AssistantMenuMixin from script.todo.database_manager import DatabaseManager from script.todo.kdbx_manager import KdbxManager @@ -1741,20 +1741,23 @@ class TODO( def _ssh_config_hosts(): """Noms d'hôtes déclarés dans ~/.ssh/config, dans l'ordre du fichier. - Une ligne « Host » peut porter plusieurs noms : on les rend tous. Les - motifs (`*`, `?`) sont écartés — ce sont des règles, pas des machines - auxquelles se connecter.""" + Une ligne « Host » peut porter plusieurs noms : on les rend tous. + Ce qui compte comme un nom de machine est tranché par + `ssh_config.declared_names`, en un seul endroit pour les trois + lecteurs de ce fichier — la casse du mot-clé, la tabulation qui + sépare et le motif nié s'y décidaient autrement dans chacun. + + La lecture ne suit PAS `Include` : un alias déclaré dans un fichier + inclus reste invisible ici, alors même que `ssh -G` le résoudrait. La + source est donc incomplète sans être fausse.""" path = os.path.expanduser("~/.ssh/config") names = [] try: with open(path, encoding="utf-8") as fh: for line in fh: - if not re.match(r"^[ \t]*Host[ \t]+", line): - continue - for name in line.split()[1:]: - if "*" in name or "?" in name or name in names: - continue - names.append(name) + for name in ssh_config.declared_names(line) or (): + if name not in names: + names.append(name) except OSError: pass return names @@ -2004,8 +2007,8 @@ class TODO( « Host a b » déclare DEUX alias pour la même machine — c'est ce que todo.py écrit lui-même quand une VM porte plusieurs noms. Les prendre pour un seul nom donnait un alias « a b », que sshfs ne peut pas - monter. Les motifs génériques (« * », « web-? ») sont écartés : ils ne - désignent aucune machine. + monter. Ce qui compte comme un nom de machine est tranché par + `ssh_config.declared_names`, partagé avec les deux autres lecteurs. """ hosts = [] noms = [] @@ -2022,13 +2025,10 @@ class TODO( return [] for ligne in lignes: ligne = ligne.strip() - if ligne.lower().startswith("host "): + declares = ssh_config.declared_names(ligne) + if declares is not None: clore() - noms = [ - m - for m in ligne.split()[1:] - if "*" not in m and "?" not in m and not m.startswith("!") - ] + noms = declares info = {} elif noms: paire = ligne.split(None, 1) diff --git a/test/test_ssh_config_names.py b/test/test_ssh_config_names.py new file mode 100644 index 0000000..0ebfeb1 --- /dev/null +++ b/test/test_ssh_config_names.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ce qu'est un nom de machine dans ~/.ssh/config, et pourquoi c'est partagé. + +Trois lecteurs de ce fichier vivent dans ce dépôt, et ils tranchaient la même +question autrement : l'un comparait le mot-clé avec la casse, un autre +exigeait un espace là où une tabulation est légale, un troisième laissait +passer le motif nié. Une découverte voyait donc un alias que le menu de +montage ne voyait pas, ce qui se lit comme une panne intermittente et non +comme trois filtres différents. + +Ces tests portent sur la décision elle-même, puis sur le fait que les trois +lecteurs la partagent — cette dernière assertion est ce qui empêche qu'un +quatrième filtre se réinstalle en douce. + +Les alias sont inventés : `ssh_config` ne résout rien et ne touche aucun +fichier, donc aucun nom réel n'est nécessaire ici. +""" + +import unittest + +from script.todo import ssh_config as sc + + +class TestCeQuiEstUneDeclaration(unittest.TestCase): + def test_a_plain_host_line(self): + self.assertEqual(sc.declared_names("Host alpha"), ["alpha"]) + + def test_lowercase_keyword(self): + """ssh_config ne distingue pas la casse de ses mots-clés.""" + self.assertEqual(sc.declared_names("host alpha"), ["alpha"]) + + def test_uppercase_keyword(self): + self.assertEqual(sc.declared_names("HOST alpha"), ["alpha"]) + + def test_tab_separator(self): + """Une tabulation sépare aussi légalement qu'un espace.""" + self.assertEqual(sc.declared_names("Host\talpha"), ["alpha"]) + + def test_indented_declaration(self): + self.assertEqual(sc.declared_names(" Host alpha"), ["alpha"]) + + def test_several_names_on_one_line(self): + self.assertEqual( + sc.declared_names("Host alpha beta gamma"), + ["alpha", "beta", "gamma"], + ) + + def test_hostname_is_not_a_declaration(self): + """Le blanc exigé derrière le mot-clé est ce qui sépare les deux.""" + self.assertIsNone(sc.declared_names(" HostName 10.83.4.19")) + + def test_another_directive_is_not_a_declaration(self): + self.assertIsNone(sc.declared_names(" User quelquun")) + + def test_an_empty_line_is_not_a_declaration(self): + self.assertIsNone(sc.declared_names("")) + self.assertIsNone(sc.declared_names(None)) + + +class TestCeQuiNeDesigneAucuneMachine(unittest.TestCase): + def test_a_wildcard_is_a_rule(self): + self.assertEqual(sc.declared_names("Host *"), []) + + def test_a_partial_wildcard_is_a_rule(self): + self.assertEqual(sc.declared_names("Host web-*"), []) + + def test_a_single_char_wildcard_is_a_rule(self): + self.assertEqual(sc.declared_names("Host web-?"), []) + + def test_a_negated_pattern_is_removed(self): + """`!nom` RETIRE un nom : le retenir donne une cible en « ! ».""" + self.assertEqual(sc.declared_names("Host alpha !beta"), ["alpha"]) + + def test_only_a_negation_declares_nothing(self): + self.assertEqual(sc.declared_names("Host !beta"), []) + + def test_a_declaration_naming_nothing_is_still_a_declaration(self): + """None et [] ne veulent pas dire la même chose. + + `Host *` EST une déclaration : le lecteur qui accumule un bloc doit + clore le précédent, sinon les directives du bloc générique se + rattachent au bloc d'avant.""" + self.assertIsNotNone(sc.declared_names("Host *")) + self.assertIsNone(sc.declared_names("Compression yes")) + + +class TestLesTroisLecteursPartagentLaDecision(unittest.TestCase): + """Les assertions qui empêchent un quatrième filtre de se réinstaller. + + Elles comptent les APPELS à la décision partagée, lus dans l'arbre + syntaxique : un lecteur qui recompile son propre motif cesse d'appeler, + et le compte tombe. Compter dans le texte accuserait le bon code, une + docstring qui nomme la fonction n'étant pas un appel.""" + + def _source(self, chemin): + from pathlib import Path + + racine = Path(__file__).resolve().parents[1] + return (racine / chemin).read_text(encoding="utf-8") + + def _appels(self, chemin): + """Le nombre d'APPELS à la décision partagée, lus dans l'arbre. + + Compté sur l'arbre et non dans le texte : une docstring qui NOMME la + fonction n'est pas un appel, et une assertion sur le texte compterait + les deux.""" + import ast + + arbre = ast.parse(self._source(chemin)) + return sum( + 1 + for n in ast.walk(arbre) + if isinstance(n, ast.Call) + and isinstance(n.func, ast.Attribute) + and n.func.attr == "declared_names" + ) + + def test_qemu_manage_delegates(self): + self.assertEqual(self._appels("script/todo/qemu_manage.py"), 1) + + def test_todo_delegates_twice(self): + """Les DEUX lecteurs de todo.py, pas seulement celui qu'on a corrigé.""" + self.assertEqual(self._appels("script/todo/todo.py"), 2) + + def test_parse_ssh_blocks_sees_a_lowercase_alias(self): + """Le bout de bout : la forme qui échappait aux trois.""" + from script.todo.qemu_manage import parse_ssh_blocks + + blocs = parse_ssh_blocks("host alpha\n\tHostName 10.83.4.19\n") + self.assertIn("alpha", blocs) + self.assertEqual(blocs["alpha"]["hostname"], "10.83.4.19") + + def test_parse_ssh_blocks_drops_a_negation(self): + from script.todo.qemu_manage import parse_ssh_blocks + + blocs = parse_ssh_blocks("Host alpha !beta\n\tUser quelquun\n") + self.assertIn("alpha", blocs) + self.assertNotIn("!beta", blocs) + + +if __name__ == "__main__": + unittest.main() From 314ab134d7702863bd7867bdebb3e487723f5cdb Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 9 Sep 2026 04:42:39 -0400 Subject: [PATCH 06/12] =?UTF-8?q?[FIX]=20todo=20:=20onze=20menus=20sans=20?= =?UTF-8?q?miette,=20et=20un=20contr=C3=B4le=20qui=20les=20voit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onze sous-menus n'avaient pas d'étiquette de fil d'Ariane. Leur segment manquait à l'en-tête, et la télémétrie les traitait comme des COMMANDES : ils apparaissaient en feuille, sous leur nom de méthode brut. Le contrôle qui devait l'attraper cherchait dans la table de répartition du menu Exécution, donc il ne voyait que les sous-menus atteints depuis là — un menu ouvert depuis ailleurs, ou défini dans un mixin, n'était jamais examiné. Il cherche maintenant par ce que le code APPELLE, dans tout le paquet, et trois exemptions périmées ont disparu avec lui. Cinq écrans restent exemptés et disent pourquoi : ce sont des actions qui posent une question. --- EN --- Eleven submenus had no breadcrumb label. Their segment was missing from the header, and telemetry treated them as COMMANDS: they showed up as leaves, under their raw method name. The check meant to catch this looked in the Execute menu's dispatch table, so it only saw submenus reached from there — a menu opened from elsewhere, or defined in a mixin, was never examined. It now searches by what the code CALLS, across the whole package, and three stale exemptions went with it. Five screens stay exempt and say why: they are actions that ask a question. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 11 ++++++ test/test_todo_menu.py | 80 +++++++++++++++++++++++++++++++++++------- 2 files changed, 78 insertions(+), 13 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 1cd7042..8d0b131 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -575,6 +575,9 @@ class TODO( "prompt_execute": "Execute", "prompt_assistant": "Assistant", "prompt_assistant_llm": "LLM", + "_llm_servers": "Servers", + "_llm_search": "Search", + "_llm_search_remote": "Over SSH", "prompt_install": "Install", "prompt_execute_function": "Automation", "prompt_execute_code": "Code", @@ -584,7 +587,10 @@ class TODO( "prompt_execute_doc": "Doc", "prompt_execute_git": "Git", "prompt_execute_git_local_server": "Git local server", + "_prompt_git_server_actions": "Actions", "prompt_execute_gpt_code": "GPT code", + "_prompt_claude_configs": "Claude configs", + "prompt_execute_claude_plugins": "Plugins", "prompt_claude_sessions": "Claude Code", "prompt_execute_process": "Process", "prompt_execute_instance": "Run", @@ -594,6 +600,11 @@ class TODO( "prompt_execute_deploy_ssh": "SSH", "prompt_execute_qemu": "QEMU/KVM", "prompt_execute_proxmox": "Proxmox VE", + "prompt_execute_vpn": "VPN", + "prompt_execute_network": "Network", + "prompt_execute_security": "Security", + "prompt_execute_test": "Test", + "prompt_execute_longtest": "Long test", "prompt_configuration": "Configuration", } diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py index 79e0f54..4e4855a 100644 --- a/test/test_todo_menu.py +++ b/test/test_todo_menu.py @@ -513,15 +513,28 @@ class TestMenuLabels(unittest.TestCase): Sans elle, `_menu_header` n'affiche pas le segment et `todo_telemetry.build_code_tree` traite le menu comme une COMMANDE : - il apparaît en feuille, sous son nom de méthode brut. Trois menus en - souffrent déjà — la liste est figée ici pour que le nombre ne grandisse - pas, pas pour bénir ce qu'elle contient. + il apparaît en feuille, sous son nom de méthode brut. + + Les menus se trouvent par ce qu'ils APPELLENT — `fill_help_info` ou + `_menu_header` — dans tout le paquet, et non par la table de répartition + du menu Exécution. Chercher là ne voyait que les sous-menus atteints + depuis cette table : un menu ouvert depuis ailleurs, ou défini dans un + mixin, n'était jamais examiné, et c'est ainsi que le sous-menu VPN a + passé le contrôle sans étiquette. + + Cinq méthodes sont exemptées, et pour la même raison : ce sont des + ACTIONS qui posent une question — un choix de méthode d'installation, un + « aller plus loin » après un rapport — et non des écrans où l'on + navigue. Leur donner un segment mettrait une miette sur une invite + passagère. """ - KNOWN_MISSING = { - "prompt_execute_test", - "prompt_execute_network", - "prompt_execute_security", + ECRANS_EXEMPTES = { + "_analyse_follow_up", + "rtk_install", + "generate_config_from_preconfiguration", + "debug_ide", + "execute_odoo_upgrade", } def setUp(self): @@ -553,19 +566,60 @@ class TestMenuLabels(unittest.TestCase): def test_analyse_menu_has_a_breadcrumb_label(self): self.assertIn("prompt_execute_analyse", self.labels) + @staticmethod + def _menus_du_paquet(): + """Toute méthode qui dessine un menu, dans tout script/todo/*.py. + + Une méthode dessine un menu quand elle appelle `fill_help_info` ou + `_menu_header` : c'est par là que passe l'en-tête, donc c'est là que + l'étiquette manque ou non. Les deux fonctions elles-mêmes sortent.""" + trouves = set() + for chemin in sorted(TODO_DIR.glob("*.py")): + arbre = ast.parse(chemin.read_text(encoding="utf-8")) + for noeud in ast.walk(arbre): + if not isinstance(noeud, ast.FunctionDef): + continue + appels = { + c.func.attr + for c in ast.walk(noeud) + if isinstance(c, ast.Call) + and isinstance(c.func, ast.Attribute) + } + if not {"fill_help_info", "_menu_header"} & appels: + continue + if noeud.name in ("fill_help_info", "_menu_header"): + continue + trouves.add(noeud.name) + return trouves + + def test_the_package_menus_were_found(self): + """Le détecteur voit bien des menus : sinon tout passerait.""" + menus = self._menus_du_paquet() + self.assertIn("prompt_execute_qemu", menus) + self.assertIn("prompt_execute_vpn", menus) + self.assertGreater(len(menus), 20) + def test_no_new_menu_forgets_its_label(self): - submenus = { - method - for method in self.dispatched - if method.startswith("prompt_execute_") - } - missing = submenus - self.labels - self.KNOWN_MISSING + missing = self._menus_du_paquet() - self.labels - self.ECRANS_EXEMPTES self.assertEqual( missing, set(), f"menus sans étiquette dans _MENU_LABELS : {sorted(missing)}", ) + def test_the_vpn_submenu_leaves_a_crumb(self): + """Le cas nommé : il était le seul menu invisible au contrôle.""" + self.assertIn("prompt_execute_vpn", self.labels) + + def test_no_stale_exemption(self): + """Une exemption qui ne nomme plus un menu est à retirer.""" + fantomes = self.ECRANS_EXEMPTES - self._menus_du_paquet() + self.assertEqual(fantomes, set()) + + def test_an_exemption_is_never_also_labelled(self): + """Exempter ET étiqueter dirait deux choses opposées du même écran.""" + self.assertEqual(self.ECRANS_EXEMPTES & self.labels, set()) + if __name__ == "__main__": unittest.main() From d335293d854f7deb261c6f6275c453ce25004785 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 9 Sep 2026 04:44:19 -0400 Subject: [PATCH 07/12] =?UTF-8?q?[FIX]=20i18n=20:=20retirer=20trois=20cl?= =?UTF-8?q?=C3=A9s=20d=C3=A9clar=C3=A9es=20deux=20fois,=20et=20l'interdire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois clés étaient déclarées deux fois dans un littéral de trois mille entrées. Rien n'avertit et rien ne lève : la seconde gagne, et le prix s'est déjà payé en étiquette de menu, la traduction lue n'étant pas celle qu'on venait d'écrire au premier endroit. Les trois portaient la même traduction des deux côtés, donc aucun écran ne change ; c'est le piège qui part. Le contrôle passe de « pas plus de trois » à « aucune », et il vit désormais avec les tests d'internationalisation plutôt qu'avec ceux d'un menu. Il lit l'ARBRE : une fois le dictionnaire construit, le doublon a déjà disparu et il n'y a plus rien à demander. --- EN --- Three keys were declared twice in a literal of three thousand entries. Nothing warns and nothing raises: the second wins, and the price was already paid on a menu label, the translation read not being the one just written at the first place. All three carried the same translation on both sides, so no screen changes; what goes is the trap. The check moves from "no more than three" to "none at all", and it now lives with the internationalisation tests rather than with a menu's. It reads the TREE: once the dictionary is built the duplicate is already gone, and there is nothing left to ask. Assisted-by: Claude Opus 5 --- script/todo/todo_i18n.py | 15 ++-------- test/test_assistant_menu.py | 26 ---------------- test/test_todo_i18n.py | 60 +++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 38 deletions(-) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index ccc1d47..406d2fb 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1255,6 +1255,9 @@ TRANSLATIONS = { "fr": "Ajouter un marketplace", "en": "Add a marketplace", }, + # Sert la section « Maintenance » des deux menus qui en portent une : + # la traduction est la même, et une clé répétée écrase la précédente en + # silence. "Maintenance": { "fr": "Maintenance", "en": "Maintenance", @@ -1638,10 +1641,6 @@ TRANSLATIONS = { "fr": "Interface", "en": "Interface", }, - "Maintenance": { - "fr": "Maintenance", - "en": "Maintenance", - }, "Language / Langue": { "fr": "🌐 Langue / Language", "en": "🌐 Language / Langue", @@ -5779,10 +5778,6 @@ TRANSLATIONS = { "fr": "Fuseau inconnu, on garde", "en": "Unknown timezone, keeping", }, - "none": { - "fr": "aucune", - "en": "none", - }, "No SSH key found. Set a password instead? (Y/n): ": { "fr": "Aucune clé SSH trouvée. Définir un mot de passe ? (O/n) : ", "en": "No SSH key found. Set a password instead? (Y/n): ", @@ -7596,10 +7591,6 @@ TRANSLATIONS = { "fr": "secondes.", "en": "seconds.", }, - "pass": { - "fr": "passe", - "en": "pass", - }, "The cleanup was still running after": { "fr": "Le nettoyage tournait encore après", "en": "The cleanup was still running after", diff --git a/test/test_assistant_menu.py b/test/test_assistant_menu.py index 337287e..b8b356c 100644 --- a/test/test_assistant_menu.py +++ b/test/test_assistant_menu.py @@ -161,32 +161,6 @@ class ClesDeTraduction(unittest.TestCase): self.assertIn(nom, COMMANDS) self.assertIn(COMMANDS[nom], TRANSLATIONS) - def test_les_doublons_de_translations_restent_les_trois_connus(self): - """Une clé répétée écrase la précédente en silence, et l'écrasement - s'est déjà payé d'une mauvaise étiquette de menu principal. Trois - doublons préexistent ; ce test refuse le quatrième sans exiger de - réparer les trois, qui sont hors du sujet de ce câblage.""" - chemin = os.path.join(RACINE, "script", "todo", "todo_i18n.py") - with open(chemin) as fichier: - arbre = ast.parse(fichier.read()) - litteral = None - for noeud in ast.walk(arbre): - if isinstance(noeud, ast.Assign) and any( - isinstance(c, ast.Name) and c.id == "TRANSLATIONS" - for c in noeud.targets - ): - litteral = noeud.value - self.assertIsInstance(litteral, ast.Dict) - noms = [ - c.value - for c in litteral.keys - if isinstance(c, ast.Constant) and isinstance(c.value, str) - ] - self.assertTrue(noms) - compte = collections.Counter(noms) - doublons = sorted(k for k, n in compte.items() if n > 1) - self.assertEqual(doublons, ["Maintenance", "none", "pass"]) - class JournalDuTransport(unittest.TestCase): """La conversation ne doit pas être coupée par des lignes de journal. diff --git a/test/test_todo_i18n.py b/test/test_todo_i18n.py index 00b428d..db29c9f 100644 --- a/test/test_todo_i18n.py +++ b/test/test_todo_i18n.py @@ -2,6 +2,8 @@ # © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +import ast +import collections import os import tempfile import unittest @@ -232,5 +234,63 @@ class TestLangIsConfigured(unittest.TestCase): self.assertFalse(result) +class TestAucuneCleRepetee(unittest.TestCase): + """Une clé répétée écrase la précédente EN SILENCE. + + Le dictionnaire est un littéral Python de plusieurs milliers d'entrées : + rien n'avertit, rien ne lève, et la deuxième définition gagne. Le prix + s'est déjà payé une fois en étiquette de menu principal — la traduction + lue n'était pas celle qu'on venait d'écrire, et le fichier montrait la + bonne à qui la cherchait. + + Le contrôle lit l'ARBRE et non le dictionnaire construit : une fois + construit, le doublon a déjà disparu, et il n'y a plus rien à voir. C'est + la seule façon de poser la question. + """ + + def _cles(self): + chemin = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "script", + "todo", + "todo_i18n.py", + ) + with open(chemin, encoding="utf-8") as fichier: + arbre = ast.parse(fichier.read()) + litteral = None + for noeud in ast.walk(arbre): + if isinstance(noeud, ast.Assign) and any( + isinstance(c, ast.Name) and c.id == "TRANSLATIONS" + for c in noeud.targets + ): + litteral = noeud.value + self.assertIsInstance(litteral, ast.Dict) + return [ + c.value + for c in litteral.keys + if isinstance(c, ast.Constant) and isinstance(c.value, str) + ] + + def test_the_literal_was_actually_read(self): + """Sinon un dictionnaire vide passerait le test suivant.""" + self.assertGreater(len(self._cles()), 2000) + + def test_no_key_is_declared_twice(self): + compte = collections.Counter(self._cles()) + doublons = sorted(k for k, n in compte.items() if n > 1) + self.assertEqual( + doublons, + [], + "clés déclarées deux fois : la seconde écrase la première", + ) + + def test_every_key_survives_the_build(self): + """Le compte du littéral et celui du dictionnaire s'accordent. + + C'est la même vérité dite autrement, et elle tombe d'elle-même le + jour où une clé se répète.""" + self.assertEqual(len(self._cles()), len(todo_i18n.TRANSLATIONS)) + + if __name__ == "__main__": unittest.main() From ea6391a7814a2f1272082ba815c7bcc868ec5f82 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 9 Sep 2026 05:29:28 -0400 Subject: [PATCH 08/12] =?UTF-8?q?[FIX]=20asyncio=20:=20rendre=20--max=5Fpr?= =?UTF-8?q?ocess=20lan=C3=A7able=20sur=20Python=203.10=20et=20plus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le drapeau `--max_process` de deux scripts passe par ce pool, et il ne tournait plus du tout. Deux retraits d'API le traversaient : `loop=` a quitté `asyncio.wait` en 3.10, où le passer lève un TypeError, et `asyncio.get_event_loop()` lève hors d'une loop en marche depuis 3.14, où il ne faisait qu'avertir — donc la classe n'était même plus instanciable, alors que l'aide annonce toujours l'option. La loop n'est plus créée à l'instanciation mais à l'exécution, et `close` ne ferme que celle que la classe a ouverte : une loop reçue en argument appartient à l'appelant, qui compte encore dessus. Vérifié : 7 tests sur de vraies coroutines, et les deux appels d'origine lèvent toujours à part. --- EN --- The `--max_process` flag of two scripts goes through this pool, and it no longer ran at all. Two API removals crossed it: `loop=` left `asyncio.wait` in 3.10, where passing it raises a TypeError, and `asyncio.get_event_loop()` raises outside a running loop since 3.14, where it merely warned before — so the class was not even constructible, while the help still advertises the option. The loop is no longer created at construction but at run time, and `close` only closes the one the class opened: a loop received as an argument belongs to the caller, who still counts on it. Checked: 7 tests on real coroutines, and both original calls still raise on their own. Assisted-by: Claude Opus 5 --- script/lib_asyncio.py | 30 +++++++-- test/test_lib_asyncio_pool.py | 111 ++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 test/test_lib_asyncio_pool.py diff --git a/script/lib_asyncio.py b/script/lib_asyncio.py index f5e4efc..1b623ea 100644 --- a/script/lib_asyncio.py +++ b/script/lib_asyncio.py @@ -155,15 +155,30 @@ class AsyncioPool: """ @param loop: asyncio loop @param concurrency: Maximum number of concurrently running tasks + + La loop n'est PAS créée ici. `asyncio.get_event_loop()` lève hors + d'une loop en marche depuis Python 3.14 — il ne faisait qu'avertir + avant — donc la construire à l'instanciation rendait la classe + impossible à instancier, et le drapeau `--max_process` inutilisable. + Elle est donc créée à l'exécution, et fermée par `close`, qui ne + ferme que ce que la classe a ouvert. """ - self._loop = loop or asyncio.get_event_loop() + self._loop = loop + self._loop_est_notre = False self._concurrency = concurrency self._coros = deque([]) # All coroutines queued for execution self._futures = [] # All currently running coroutines self._lst_result = [] def close(self): - self._loop.close() + """Ferme la loop, si c'est celle que la classe a créée. + + Une loop reçue en argument appartient à l'appelant : la fermer sous + lui casserait tout ce qu'il compte encore y faire tourner.""" + if self._loop is not None and self._loop_est_notre: + self._loop.close() + self._loop = None + self._loop_est_notre = False def add_coro(self, coro): """ @@ -173,6 +188,10 @@ class AsyncioPool: self.print_status() def run_until_complete(self): + if self._loop is None: + self._loop = asyncio.new_event_loop() + self._loop_est_notre = True + asyncio.set_event_loop(self._loop) self._loop.run_until_complete(self._wait_for_futures()) return self._lst_result @@ -187,16 +206,19 @@ class AsyncioPool: num_to_start = min(num_to_start, len(self._coros)) for _ in range(num_to_start): coro = self._coros.popleft() - future = asyncio.ensure_future(coro, loop=self._loop) + # Sans « loop= » : l'appel a lieu DEPUIS la loop en marche, qui + # est donc celle que ensure_future prend d'elle-même. + future = asyncio.ensure_future(coro) self._futures.append(future) self.print_status() async def _wait_for_futures(self): while len(self._coros) > 0 or len(self._futures) > 0: self._start_futures() + # « loop= » a été RETIRÉ de asyncio.wait en Python 3.10 : le + # passer lève un TypeError, et c'est la loop en marche qui sert. futures_completed, futures_pending = await asyncio.wait( self._futures, - loop=self._loop, return_when=asyncio.FIRST_COMPLETED, ) diff --git a/test/test_lib_asyncio_pool.py b/test/test_lib_asyncio_pool.py new file mode 100644 index 0000000..e847827 --- /dev/null +++ b/test/test_lib_asyncio_pool.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le pool de coroutines, et les deux retraits d'asyncio qui le tuaient. + +Le drapeau `--max_process` de `run_parallel_test.py` et de +`show_evolution_module.py` passe par cette classe, et elle ne pouvait plus +tourner du tout : `asyncio.get_event_loop()` LÈVE hors d'une loop en marche +depuis Python 3.14, ce qui rendait l'instanciation impossible, et le mot-clé +`loop=` a été RETIRÉ d'`asyncio.wait` en Python 3.10, ce qui lève un +TypeError. Deux échecs à des étages différents, tous deux sur le chemin d'un +drapeau que l'aide annonce encore. + +Ces tests exercent le pool pour de vrai — de vraies coroutines, une vraie +loop, un vrai résultat — parce que c'est la seule façon de prouver qu'un +retrait d'API ne le traverse plus. Ils n'affirment rien sur l'ordre des +résultats : le pool rend ce qui finit, dans l'ordre où cela finit. +""" + +import asyncio +import io +import unittest +from contextlib import redirect_stdout + +from script.lib_asyncio import AsyncioPool + + +async def rendre(valeur, delai=0): + if delai: + await asyncio.sleep(delai) + return valeur + + +class TestLePoolTourne(unittest.TestCase): + """Le pool s'instancie, tourne et se ferme sans loop fournie.""" + + def _lancer(self, pool): + """Exécute en silence : le pool imprime son état à chaque ajout.""" + with redirect_stdout(io.StringIO()): + return pool.run_until_complete() + + def tearDown(self): + asyncio.set_event_loop(None) + + def test_it_can_be_built_without_a_loop(self): + """Le cas qui levait : construire hors de toute loop en marche.""" + pool = AsyncioPool(2) + self.assertIsNone(pool._loop) + + def test_it_runs_more_coros_than_its_concurrency(self): + pool = AsyncioPool(2) + with redirect_stdout(io.StringIO()): + for valeur in range(5): + pool.add_coro(rendre(valeur)) + resultats = self._lancer(pool) + pool.close() + self.assertEqual(sorted(resultats), [0, 1, 2, 3, 4]) + + def test_a_single_coro(self): + pool = AsyncioPool(4) + with redirect_stdout(io.StringIO()): + pool.add_coro(rendre("seule")) + resultats = self._lancer(pool) + pool.close() + self.assertEqual(resultats, ["seule"]) + + def test_staggered_coros_all_come_back(self): + """Des durées inégales : c'est là que FIRST_COMPLETED est exercé.""" + pool = AsyncioPool(2) + with redirect_stdout(io.StringIO()): + for valeur, delai in ((1, 0.03), (2, 0.01), (3, 0.02), (4, 0)): + pool.add_coro(rendre(valeur, delai)) + resultats = self._lancer(pool) + pool.close() + self.assertEqual(sorted(resultats), [1, 2, 3, 4]) + + +class TestLaFermeture(unittest.TestCase): + """`close` ne ferme que ce que la classe a ouvert.""" + + def tearDown(self): + asyncio.set_event_loop(None) + + def test_close_before_any_run_is_harmless(self): + AsyncioPool(2).close() + + def test_close_closes_the_loop_it_created(self): + pool = AsyncioPool(1) + with redirect_stdout(io.StringIO()): + pool.add_coro(rendre(1)) + pool.run_until_complete() + boucle = pool._loop + pool.close() + self.assertTrue(boucle.is_closed()) + + def test_a_borrowed_loop_is_left_open(self): + """Une loop reçue appartient à l'appelant, qui compte encore dessus.""" + boucle = asyncio.new_event_loop() + try: + pool = AsyncioPool(1, loop=boucle) + with redirect_stdout(io.StringIO()): + pool.add_coro(rendre(1)) + pool.run_until_complete() + pool.close() + self.assertFalse(boucle.is_closed()) + finally: + boucle.close() + + +if __name__ == "__main__": + unittest.main() From 19410e5b73b3e1b3a53b5b08808a8db2a4b90a20 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 9 Sep 2026 05:32:52 -0400 Subject: [PATCH 09/12] =?UTF-8?q?[FIX]=20todo=20:=20une=20invite=20n'?= =?UTF-8?q?=C3=A9crit=20plus=20son=20deux-points=20deux=20fois?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `click.prompt` ajoute son propre « : » à l'étiquette qu'on lui donne, et quinze étiquettes le portaient déjà. Le menu le plus vu du logiciel demandait donc « Commande :: », et sept invites de déploiement à distance affichaient un deux-points suivi d'un autre. Le suffixe est désormais explicite partout où l'étiquette ponctue elle-même, et sa valeur se déduit de l'étiquette : rien quand elle porte déjà l'espace qui suit, un espace quand le deux-points est nu. Un contrôle lit l'arbre et résout les DEUX langues, le français mettant une espace avant le deux-points là où l'anglais n'en met pas — chercher dans le texte du code ne verrait que la clé anglaise. --- EN --- `click.prompt` adds its own ":" to the label it is given, and fifteen labels already carried one. The most-seen menu of the software therefore asked "Command:: ", and seven remote-deployment prompts showed a colon followed by another. The suffix is now explicit wherever the label punctuates itself, and its value follows from the label: nothing when it already carries the trailing space, a space when the colon is bare. A check reads the tree and resolves BOTH languages, French putting a space before the colon where English does not — searching the code text would only see the English key. Assisted-by: Claude Opus 5 --- script/todo/assistant_menu.py | 6 +- script/todo/todo.py | 41 ++++++---- test/test_prompt_suffix.py | 141 ++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 15 deletions(-) create mode 100644 test/test_prompt_suffix.py diff --git a/script/todo/assistant_menu.py b/script/todo/assistant_menu.py index e607a77..0852065 100644 --- a/script/todo/assistant_menu.py +++ b/script/todo/assistant_menu.py @@ -349,7 +349,8 @@ class AssistantMenuMixin: if not choisis: return frappe = click.prompt( - t("Type the server name in full to delete it:") + t("Type the server name in full to delete it:"), + prompt_suffix=" ", ).strip() except (KeyboardInterrupt, click.exceptions.Abort): print() @@ -997,7 +998,8 @@ class AssistantMenuMixin: if choix == "2": try: frappe = click.prompt( - t("Type the pid of the holder to write into it:") + t("Type the pid of the holder to write into it:"), + prompt_suffix=" ", ).strip() except (KeyboardInterrupt, click.exceptions.Abort): print() diff --git a/script/todo/todo.py b/script/todo/todo.py index 8d0b131..b32d254 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -2292,22 +2292,31 @@ class TODO( def _get_ssh_params(self): """Prompt for SSH connection parameters. Returns dict or None on cancel.""" host = click.prompt( - t("Remote host (user@hostname or hostname): ") + t("Remote host (user@hostname or hostname): "), prompt_suffix="" ).strip() if not host: print(t("SSH host is required!")) return None user = ( - click.prompt(t("SSH user (default: erplibre): ")).strip() + click.prompt( + t("SSH user (default: erplibre): "), prompt_suffix="" + ).strip() or "erplibre" ) - port = click.prompt(t("SSH port (default: 22): ")).strip() or "22" + port = ( + click.prompt( + t("SSH port (default: 22): "), prompt_suffix="" + ).strip() + or "22" + ) key = click.prompt( - t("SSH key path (default: ~/.ssh/id_rsa, empty for none): ") + t("SSH key path (default: ~/.ssh/id_rsa, empty for none): "), + prompt_suffix="", ).strip() path = ( click.prompt( - t("Remote path (default: ~/erplibre_deploy_2): ") + t("Remote path (default: ~/erplibre_deploy_2): "), + prompt_suffix="", ).strip() or "~/erplibre_deploy_2" ) @@ -2415,7 +2424,9 @@ class TODO( params = self._get_ssh_params() if not params: return - target = click.prompt(t("Make target to run remotely: ")).strip() + target = click.prompt( + t("Make target to run remotely: "), prompt_suffix="" + ).strip() if not target: print(t("SSH host is required!")) return @@ -2441,11 +2452,15 @@ class TODO( params = self._get_ssh_params() if not params: return - domain = click.prompt(t("Domain name (e.g.: example.com): ")).strip() + domain = click.prompt( + t("Domain name (e.g.: example.com): "), prompt_suffix="" + ).strip() if not domain: print(t("SSH host is required!")) return - email = click.prompt(t("Admin email for SSL certificate: ")).strip() + email = click.prompt( + t("Admin email for SSL certificate: "), prompt_suffix="" + ).strip() cmd = self._build_ssh_make_cmd( "ssh_install_nginx", params, @@ -4097,7 +4112,7 @@ class TODO( print(f"[1] {t('A database')}") print(f"[2] {t('A backup .zip, without restoring it')}") print(f"[0] {t('Back')}") - answer = click.prompt(t("Command:")) + answer = click.prompt(t("Command:"), prompt_suffix=" ") print() if answer == "1": database = self._analyse_select_database() @@ -4469,7 +4484,7 @@ class TODO( print(f"[2] {t('Whitelist: only the models I name')}") print(f"[3] {t('Blacklist: every model except those I name')}") print(f"[0] {t('Back')}") - answer = click.prompt(t("Command:")) + answer = click.prompt(t("Command:"), prompt_suffix=" ") print() mode = {"1": "hybrid", "2": "whitelist", "3": "blacklist"}.get(answer) if not mode: @@ -4512,7 +4527,7 @@ class TODO( print() print(f"[1] {t('A development copy (restored, neutralised)')}") print(f"[2] {t('An instance in service')}") - answer = click.prompt(t("Command:")) + answer = click.prompt(t("Command:"), prompt_suffix=" ") print() return ( check_instance_state.LIVE @@ -4539,7 +4554,7 @@ class TODO( print(f"[3] {t('A remote backup (https + master password)')}") print(f"[4] {t('A live remote instance')}") print(f"[0] {t('Back')}") - answer = click.prompt(t("Command:")) + answer = click.prompt(t("Command:"), prompt_suffix=" ") print() if answer == "1": database = self.db_manager.select_database() @@ -4583,7 +4598,7 @@ class TODO( print() print(f"[1] {t('An API key')}") print(f"[2] {t('A password')}") - genre = click.prompt(t("Command:")) + genre = click.prompt(t("Command:"), prompt_suffix=" ") secret = getpass.getpass( t("API key: ") if genre == "1" else t("Password: ") ) diff --git a/test/test_prompt_suffix.py b/test/test_prompt_suffix.py new file mode 100644 index 0000000..d1ebf41 --- /dev/null +++ b/test/test_prompt_suffix.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le deux-points d'une invite est écrit une fois, pas deux. + +`click.prompt` ajoute son propre suffixe — « : » suivi d'un espace — à +l'étiquette qu'on lui donne. Une étiquette qui porte déjà son deux-points +sortait donc en « Commande :: », et ce menu-là est le plus vu du logiciel. + +Le contrôle lit l'ARBRE de tout `script/**/*.py` et RÉSOUT l'étiquette dans +les deux langues, parce que la ponctuation n'est pas la même : le français +met une espace avant le deux-points, l'anglais non, et une seule des deux +traductions peut porter la marque. Chercher dans le texte du code ne verrait +que la clé anglaise. + +Deux suffixes explicites sont acceptés, et le choix se déduit de l'étiquette +elle-même : `" "` quand elle finit par un deux-points nu, `""` quand elle +porte déjà l'espace qui suit. Toute autre valeur est une décision à écrire +ici avec sa raison. + +La couverture est PARTIELLE et le reste : une étiquette calculée — variable, +f-string, concaténation — n'est pas lisible dans l'arbre, et le contrôle +l'ignore plutôt que de l'approximer. Il voit les étiquettes littérales, qui +sont celles où le deux-points s'écrit à la main. +""" + +import ast +import pathlib +import unittest + +from script.todo.todo_i18n import TRANSLATIONS + +RACINE = pathlib.Path(__file__).resolve().parents[1] + + +def _etiquette(noeud): + """La clé de l'étiquette d'un `click.prompt`, ou None. + + Reconnaît `t("…")` et la chaîne nue. Une étiquette calculée — variable, + f-string, concaténation — rend None : ce contrôle ne devine pas ce qu'il + ne peut pas lire, et le dire est plus honnête que de l'approximer.""" + if not noeud.args: + return None + premier = noeud.args[0] + if ( + isinstance(premier, ast.Call) + and getattr(premier.func, "id", "") == "t" + and premier.args + and isinstance(premier.args[0], ast.Constant) + and isinstance(premier.args[0].value, str) + ): + return premier.args[0].value + if isinstance(premier, ast.Constant) and isinstance(premier.value, str): + return premier.value + return None + + +def _invites(): + """[(fichier:ligne, clé, suffixe explicite ou None)] de tout le paquet.""" + trouves = [] + for chemin in sorted(RACINE.glob("script/**/*.py")): + try: + arbre = ast.parse(chemin.read_text(encoding="utf-8")) + except SyntaxError: + continue + for noeud in ast.walk(arbre): + if not isinstance(noeud, ast.Call): + continue + if getattr(noeud.func, "attr", "") != "prompt": + continue + cle = _etiquette(noeud) + if cle is None: + continue + suffixe = None + for mot in noeud.keywords: + if mot.arg == "prompt_suffix" and isinstance( + mot.value, ast.Constant + ): + suffixe = mot.value.value + ou = f"{chemin.relative_to(RACINE)}:{noeud.lineno}" + trouves.append((ou, cle, suffixe)) + return trouves + + +def _finit_par_deux_points(cle): + """Vrai si l'étiquette finit par un deux-points dans UNE des langues.""" + for langue in ("fr", "en"): + rendu = TRANSLATIONS.get(cle, {}).get(langue, cle) + if rendu.rstrip().endswith(":"): + return True + return False + + +class TestLesInvitesFurentTrouvees(unittest.TestCase): + """Sans ceci, une recherche cassée rendrait tous les tests verts.""" + + def test_the_search_finds_prompts(self): + self.assertGreater(len(_invites()), 20) + + def test_the_main_menu_prompt_is_among_them(self): + cles = {cle for _, cle, _ in _invites()} + self.assertIn("Command:", cles) + + +class TestAucunDeuxPointsDouble(unittest.TestCase): + def test_every_colon_label_passes_its_suffix(self): + fautives = [ + f"{ou} — {cle!r}" + for ou, cle, suffixe in _invites() + if _finit_par_deux_points(cle) and suffixe is None + ] + self.assertEqual( + fautives, + [], + "click ajoute « : » : ces invites en afficheraient deux", + ) + + def test_the_suffix_matches_the_label(self): + """L'espace est fourni une fois : par l'étiquette ou par le suffixe.""" + mauvais = [] + for ou, cle, suffixe in _invites(): + if suffixe is None: + continue + francais = TRANSLATIONS.get(cle, {}).get("fr", cle) + attendu = "" if francais.endswith(": ") else " " + if suffixe != attendu: + mauvais.append(f"{ou} — {suffixe!r} au lieu de {attendu!r}") + self.assertEqual(mauvais, []) + + def test_a_label_without_a_colon_leaves_click_alone(self): + """Le suffixe par défaut est ce qui ponctue les autres invites.""" + inutiles = [ + f"{ou} — {cle!r}" + for ou, cle, suffixe in _invites() + if suffixe is not None and not _finit_par_deux_points(cle) + ] + self.assertEqual(inutiles, []) + + +if __name__ == "__main__": + unittest.main() From c0b24925bffe005e6239d98c4f1f391b464dd713 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 9 Sep 2026 05:39:59 -0400 Subject: [PATCH 10/12] =?UTF-8?q?[ADD]=20hygi=C3=A8ne=20:=20signaler=20un?= =?UTF-8?q?=20nom=20de=20machine=20pleinement=20qualifi=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'outil voyait les adresses, les courriels et les chemins de compte, mais pas les NOMS — la classe que la règle interdit au même titre, et celle qu'un assistant qui parcourt le réseau multiplie. Le rapport annonçait « 0 identifiant » sur un fichier qui en portait un. Un signal à relire, jamais une trouvaille : un nom d'hôte NU ne se distingue mécaniquement ni d'un mot ordinaire ni du nom d'un logiciel, donc seule la forme qualifiée se reconnaît, contre une liste FERMÉE de suffixes. En sortent l'en-tête de copyright, les domaines de la RFC 2606, et toute adresse portée par une URL. Vérifié : 54 tests, et 21 signaux sur le dépôt, tous des miroirs. --- EN --- The tool saw addresses, e-mails and account paths, but not NAMES — the class the rule forbids just as much, and the one an assistant sweeping the network multiplies. The report announced "0 identifying" on a file that carried one. A signal to re-read, never a finding: a BARE host name is mechanically indistinguishable from an ordinary word or a piece of software, so only the qualified form is recognised, against a CLOSED list of suffixes. Out of it go the copyright header, the RFC 2606 domains, and any address carried by a URL. Checked: 54 tests, and 21 signals across the repository, all mirrors. Assisted-by: Claude Opus 5 --- .claude/rules/04-code-conventions.md | 7 +- script/analyse/check_comment_hygiene.py | 89 +++++++++++++++++++++- test/test_check_comment_hygiene.py | 98 +++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 2 deletions(-) diff --git a/.claude/rules/04-code-conventions.md b/.claude/rules/04-code-conventions.md index a5943ba..071cc63 100644 --- a/.claude/rules/04-code-conventions.md +++ b/.claude/rules/04-code-conventions.md @@ -60,7 +60,12 @@ python3 script/analyse/check_comment_hygiene.py --staged 🔴 `identifiant` est une trouvaille, à retirer. 🟡 `récit` est un signal à relire : l'outil ne sait pas si la phrase énonce un fait durable ou raconte -une journée, et ne tranche pas à votre place. +une journée, et ne tranche pas à votre place. 🟡 `nom` en est un autre, et sa +limite est plus dure : un nom d'hôte NU ne se distingue mécaniquement ni d'un +mot ordinaire ni du nom d'un logiciel, donc l'outil ne voit que la forme +pleinement qualifiée. Un miroir de paquets nommé dans un commentaire y +répond, et se confirme d'un coup d'œil ; l'absence de signal ne prouve rien +sur les noms. Trois exemples pris dans ce dépôt, leurs noms propres masqués — une règle qui interdit de nommer ne se cite pas elle-même en clair. diff --git a/script/analyse/check_comment_hygiene.py b/script/analyse/check_comment_hygiene.py index 65b449a..a2e8f8d 100755 --- a/script/analyse/check_comment_hygiene.py +++ b/script/analyse/check_comment_hygiene.py @@ -8,7 +8,7 @@ La convention est dans `.claude/rules/04-code-conventions.md` : un commentaire dit COMMENT le code marche, il ne porte aucune donnée identifiante et il ne raconte pas l'enquête. Cet outil en vérifie la part mécanique. -Deux familles, de sûreté très différente : +Trois familles, de sûreté très différente : - `identifiant` — adresse IP, courriel, chemin de compte. Une correspondance est une trouvaille : ces formes n'ont aucune raison d'être @@ -17,6 +17,12 @@ Deux familles, de sûreté très différente : absolue, première personne. Une correspondance est un SIGNAL À RELIRE : la même phrase peut énoncer un fait durable. L'outil ne trie pas à la place du lecteur. +- `nom` — un nom de machine pleinement qualifié. Aussi un signal à relire, + et pour une raison plus dure : un nom d'hôte NU ne se distingue + mécaniquement ni d'un mot ordinaire ni du nom d'un logiciel, donc cette + famille ne voit que la forme qualifiée. Les miroirs de paquets nommés dans + un commentaire y répondent, et c'est le prix d'un signal mécanique — le + lecteur confirme en deux secondes, là où la classe entière était invisible. Il lit les commentaires `#` et, en Python, les docstrings de module, de classe et de fonction. Le reste du code ne l'intéresse pas. @@ -116,6 +122,64 @@ MOTIFS_RECIT = ( ("personne", PERSONNE), ) +# Le nom de machine est la SEULE classe interdite qu'aucun motif ne tranche : +# un nom d'hôte ne se distingue mécaniquement ni d'un mot pointé ordinaire, ni +# du nom d'un logiciel. Seule sa forme pleinement qualifiée se reconnaît, et +# encore : « chemin.home » et « logging.info » ont la même forme. D'où un +# signal à relire et non une trouvaille. +# +# Le suffixe est comparé à une liste FERMÉE. Sans elle, tout attribut pointé +# du code correspondrait. Les suffixes de service — local, lan, internal — +# y sont exprès : c'est sous eux qu'une machine du parc se nomme. +SUFFIXES_DHOTE = ( + "com", + "ca", + "net", + "org", + "io", + "dev", + "fr", + "be", + "ch", + "eu", + "us", + "uk", + "biz", + "cloud", + "app", + "tech", + "quebec", + "local", + "lan", + "internal", + "intra", + "corp", +) + +# Le point qui SUIT décide de deux choses opposées : suivi d'un caractère de +# nom, il prolonge l'adresse et la correspondance n'est qu'un préfixe ; seul, +# c'est le point d'une phrase, et l'adresse s'arrête là. Les confondre rendait +# invisible toute adresse en fin de phrase. +NOM_DHOTE = re.compile( + r"(? Date: Wed, 9 Sep 2026 05:40:12 -0400 Subject: [PATCH 11/12] =?UTF-8?q?[REM]=20requirement=20:=20retirer=20sshco?= =?UTF-8?q?nf,=20d=C3=A9clar=C3=A9=20et=20jamais=20import=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le paquet était déclaré, donc installé dans chaque environnement, et aucun fichier ne l'importe. Il est aussi le mauvais outil pour ce dépôt : la configuration SSH s'y lit soit par `ssh -G`, qui connaît les `Include`, les `Match` et l'héritage des jokers, soit par le lecteur de lignes du paquet, et une bibliothèque tierce n'apporte ni l'un ni l'autre. Un test de la découverte affirme déjà qu'il n'est pas importé là où il tenterait : cette assertion garde tout son sens, elle n'a plus de paquet derrière elle. --- EN --- The package was declared, so installed into every environment, and no file imports it. It is also the wrong tool for this repository: SSH configuration is read here either through `ssh -G`, which knows about `Include`, `Match` and wildcard inheritance, or through the package's own line reader, and a third-party library brings neither. A discovery test already asserts it is not imported where it would be tempting: that assertion keeps all its meaning, it simply no longer has a package behind it. Assisted-by: Claude Opus 5 --- requirement/erplibre_require-ments.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirement/erplibre_require-ments.txt b/requirement/erplibre_require-ments.txt index 81505cd..742caf8 100644 --- a/requirement/erplibre_require-ments.txt +++ b/requirement/erplibre_require-ments.txt @@ -40,7 +40,6 @@ lxml python-dotenv python-dateutil unidecode -sshconf cloudflare psutil mmg From 661f7a28c898e0514ed41ce6942e397a303a8f37 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 9 Sep 2026 07:38:36 -0400 Subject: [PATCH 12/12] [UPD] changelog : les six correctifs, le retrait et le caviardage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'entrée « non publié » ne portait que la fonctionnalité. Un lecteur qui décide d'une mise à jour n'y voyait ni le mot de passe root qu'on ne lui demande plus, ni le drapeau qui repart sur Python 3.14, ni la clé d'API désormais caviardée dans une trace. Trois sections dans l'ordre du fichier — six correctifs, un retrait de dépendance, une entrée de sécurité — et une puce de plus sous « Ajouté » pour le signal neuf de l'outil d'hygiène. Les deux langues portent le même nombre de puces dans le même ordre, vérifié par comptage : les deux listes se lisent côte à côte, et une puce d'un seul côté laisse l'autre moitié fausse. --- EN --- The "unreleased" entry carried only the feature. A reader deciding on an upgrade saw neither the root password no longer asked of them, nor the flag that runs again on Python 3.14, nor the API key now redacted from a trace. Three sections in the file's own order — six fixes, one dependency removal, one security entry — and one more bullet under "Added" for the hygiene tool's new signal. Both languages carry the same number of bullets in the same order, verified by counting: the two lists are read side by side, and a bullet on one side only leaves the other half wrong. Assisted-by: Claude Opus 5 --- CHANGELOG.base.md | 48 +++++++++++++++++++++++++++++++++++++++++++++++ CHANGELOG.fr.md | 18 ++++++++++++++++++ CHANGELOG.md | 18 ++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index ab67bfa..20b409c 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -45,6 +45,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - A gpt catalogue in `script/todo/assistant/gpt/`: one Markdown file per tool, whose declared requirements are matched against what the server announces. An unknown never greys a tool out — only a requirement contradicted by a field actually read does, with the figure that refuses it - A declared READ-ONLY context per tool, files and allowlisted commands, shown and confirmed before the first send, capped in size and duration, and scanned for identifying data. The scan's honest limit is stated: it sees addresses, e-mails and account paths, not names - `Execute › GPT code › Claude Code` — list the machine's sessions, ask one a question with read-only tools, or resume one in its own terminal. A copy is branched by default, since two writers on one session lose a branch +- `check_comment_hygiene.py` signals a fully qualified machine name in a comment, as a re-read signal and never a finding: a BARE host name is mechanically indistinguishable from an ordinary word, so the absence of a signal proves nothing about names. The copyright header, the RFC 2606 domains and any address carried by a URL are left alone @@ -62,6 +63,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Un catalogue d'outils gpt dans `script/todo/assistant/gpt/` : un fichier Markdown par outil, dont les exigences déclarées sont confrontées à ce que le serveur annonce. L'inconnu ne grise jamais un outil — seule une exigence contredite par un champ réellement lu le fait, avec le chiffre qui la refuse - Un contexte LECTURE SEULE déclaré par outil, fichiers et commandes autorisées, montré et confirmé avant le premier envoi, borné en taille et en durée, et balayé à la recherche de données identifiantes. La limite du balayage est dite : il voit les adresses, les courriels et les chemins de compte, pas les noms - `Exécution › GPT code › Claude Code` — lister les sessions de la machine, en interroger une avec des outils en lecture seule, ou la reprendre dans son propre terminal. Une copie est branchée par défaut, deux écritures sur une même session perdant une branche +- `check_comment_hygiene.py` signale un nom de machine pleinement qualifié dans un commentaire, en signal à relire et jamais en trouvaille : un nom d'hôte NU ne se distingue mécaniquement pas d'un mot ordinaire, donc l'absence de signal ne prouve rien sur les noms. L'en-tête de copyright, les domaines de la RFC 2606 et toute adresse portée par une URL sont laissés tranquilles ## Changed @@ -75,6 +77,52 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `Assistant › [1]` n'envoie plus chaque question à une seule API distante sur un modèle figé : elle interroge le serveur configuré, et ne retombe sur le distant que lorsqu'aucun serveur local ne répond + +## Fixed + +## Corrigé + + +- Reading the dnsmasq leases no longer opens a root password prompt: the files are read directly, which suffices on a standard install where they are 0644, and only then is `sudo -n` tried, which fails instead of asking. Displaying a VM list called that path once per VM, and waiting on a VM called it every three seconds for ten minutes +- `--max_process` runs again on Python 3.10 and later: `loop=` left `asyncio.wait` in 3.10 and `asyncio.get_event_loop()` raises outside a running loop since 3.14, so the pool was not even constructible while the help still advertised the flag +- A prompt no longer writes its colon twice — the most-seen menu of the software asked « Command:: », and seven remote-deployment prompts showed a colon followed by another +- Eleven submenus now leave their segment in the breadcrumb, and navigation telemetry stops filing them as commands under their raw method name +- The three readers of `~/.ssh/config` agree on what a machine name is: an alias declared with a lowercase `host` is seen, a tab separates as legally as a space, and a negated `!name` pattern is no longer taken for a machine to connect to +- Three translation keys declared twice are gone, and a check refuses the next one: a repeated key silently overwrites the previous, which had already cost a menu label + + + +- La lecture des baux dnsmasq n'ouvre plus d'invite de mot de passe root : les fichiers sont lus en direct, ce qui suffit sur une installation standard où ils sont en 0644, et `sudo -n` n'est tenté qu'ensuite, qui échoue au lieu de demander. L'affichage d'une liste de VM appelait ce chemin une fois par VM, et l'attente d'une VM toutes les trois secondes pendant dix minutes +- `--max_process` repart sur Python 3.10 et plus : `loop=` a quitté `asyncio.wait` en 3.10 et `asyncio.get_event_loop()` lève hors d'une loop en marche depuis 3.14, si bien que le pool n'était même plus instanciable alors que l'aide annonçait toujours l'option +- Une invite n'écrit plus son deux-points deux fois — le menu le plus vu du logiciel demandait « Commande :: », et sept invites de déploiement à distance affichaient un deux-points suivi d'un autre +- Onze sous-menus laissent désormais leur segment dans le fil d'Ariane, et la télémétrie de navigation cesse de les classer comme des commandes sous leur nom de méthode brut +- Les trois lecteurs de `~/.ssh/config` s'accordent sur ce qu'est un nom de machine : un alias déclaré par un `host` en minuscules est vu, une tabulation sépare aussi légalement qu'un espace, et un motif nié `!nom` n'est plus pris pour une machine à joindre +- Trois clés de traduction déclarées deux fois ont disparu, et un contrôle refuse la suivante : une clé répétée écrase la précédente en silence, ce qui avait déjà coûté une étiquette de menu + + +## Removed + +## Retiré + + +- The `sshconf` dependency, declared and installed everywhere and imported nowhere + + + +- La dépendance `sshconf`, déclarée et installée partout et importée nulle part + + +## Security + +## Sécurité + + +- An API key and a bearer token are redacted too before a command is displayed, logged or reprinted: `OPENAI_API_KEY=` went out in the clear, and a header token escaped by construction, carrying neither an option name nor a variable name + + + +- Une clé d'API et un jeton Bearer sont caviardés eux aussi avant qu'une commande soit affichée, journalisée ou réimprimée : `OPENAI_API_KEY=` partait en clair, et un jeton d'en-tête échappait par construction, ne portant ni nom d'option ni nom de variable + ## [1.8.0] - 2026-09-04 diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index cc925a8..20f62e2 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -25,11 +25,29 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Un catalogue d'outils gpt dans `script/todo/assistant/gpt/` : un fichier Markdown par outil, dont les exigences déclarées sont confrontées à ce que le serveur annonce. L'inconnu ne grise jamais un outil — seule une exigence contredite par un champ réellement lu le fait, avec le chiffre qui la refuse - Un contexte LECTURE SEULE déclaré par outil, fichiers et commandes autorisées, montré et confirmé avant le premier envoi, borné en taille et en durée, et balayé à la recherche de données identifiantes. La limite du balayage est dite : il voit les adresses, les courriels et les chemins de compte, pas les noms - `Exécution › GPT code › Claude Code` — lister les sessions de la machine, en interroger une avec des outils en lecture seule, ou la reprendre dans son propre terminal. Une copie est branchée par défaut, deux écritures sur une même session perdant une branche +- `check_comment_hygiene.py` signale un nom de machine pleinement qualifié dans un commentaire, en signal à relire et jamais en trouvaille : un nom d'hôte NU ne se distingue mécaniquement pas d'un mot ordinaire, donc l'absence de signal ne prouve rien sur les noms. L'en-tête de copyright, les domaines de la RFC 2606 et toute adresse portée par une URL sont laissés tranquilles ## Modifié - `Assistant › [1]` n'envoie plus chaque question à une seule API distante sur un modèle figé : elle interroge le serveur configuré, et ne retombe sur le distant que lorsqu'aucun serveur local ne répond +## Corrigé + +- La lecture des baux dnsmasq n'ouvre plus d'invite de mot de passe root : les fichiers sont lus en direct, ce qui suffit sur une installation standard où ils sont en 0644, et `sudo -n` n'est tenté qu'ensuite, qui échoue au lieu de demander. L'affichage d'une liste de VM appelait ce chemin une fois par VM, et l'attente d'une VM toutes les trois secondes pendant dix minutes +- `--max_process` repart sur Python 3.10 et plus : `loop=` a quitté `asyncio.wait` en 3.10 et `asyncio.get_event_loop()` lève hors d'une loop en marche depuis 3.14, si bien que le pool n'était même plus instanciable alors que l'aide annonçait toujours l'option +- Une invite n'écrit plus son deux-points deux fois — le menu le plus vu du logiciel demandait « Commande :: », et sept invites de déploiement à distance affichaient un deux-points suivi d'un autre +- Onze sous-menus laissent désormais leur segment dans le fil d'Ariane, et la télémétrie de navigation cesse de les classer comme des commandes sous leur nom de méthode brut +- Les trois lecteurs de `~/.ssh/config` s'accordent sur ce qu'est un nom de machine : un alias déclaré par un `host` en minuscules est vu, une tabulation sépare aussi légalement qu'un espace, et un motif nié `!nom` n'est plus pris pour une machine à joindre +- Trois clés de traduction déclarées deux fois ont disparu, et un contrôle refuse la suivante : une clé répétée écrase la précédente en silence, ce qui avait déjà coûté une étiquette de menu + +## Retiré + +- La dépendance `sshconf`, déclarée et installée partout et importée nulle part + +## Sécurité + +- Une clé d'API et un jeton Bearer sont caviardés eux aussi avant qu'une commande soit affichée, journalisée ou réimprimée : `OPENAI_API_KEY=` partait en clair, et un jeton d'en-tête échappait par construction, ne portant ni nom d'option ni nom de variable + ## [1.8.0] - 2026-09-04 diff --git a/CHANGELOG.md b/CHANGELOG.md index 859ca50..c825f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,11 +25,29 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - A gpt catalogue in `script/todo/assistant/gpt/`: one Markdown file per tool, whose declared requirements are matched against what the server announces. An unknown never greys a tool out — only a requirement contradicted by a field actually read does, with the figure that refuses it - A declared READ-ONLY context per tool, files and allowlisted commands, shown and confirmed before the first send, capped in size and duration, and scanned for identifying data. The scan's honest limit is stated: it sees addresses, e-mails and account paths, not names - `Execute › GPT code › Claude Code` — list the machine's sessions, ask one a question with read-only tools, or resume one in its own terminal. A copy is branched by default, since two writers on one session lose a branch +- `check_comment_hygiene.py` signals a fully qualified machine name in a comment, as a re-read signal and never a finding: a BARE host name is mechanically indistinguishable from an ordinary word, so the absence of a signal proves nothing about names. The copyright header, the RFC 2606 domains and any address carried by a URL are left alone ## Changed - `Assistant › [1]` no longer sends every question to a single remote API on a fixed model: it asks whichever server is configured, and falls back to the remote one only when no local server answers +## Fixed + +- Reading the dnsmasq leases no longer opens a root password prompt: the files are read directly, which suffices on a standard install where they are 0644, and only then is `sudo -n` tried, which fails instead of asking. Displaying a VM list called that path once per VM, and waiting on a VM called it every three seconds for ten minutes +- `--max_process` runs again on Python 3.10 and later: `loop=` left `asyncio.wait` in 3.10 and `asyncio.get_event_loop()` raises outside a running loop since 3.14, so the pool was not even constructible while the help still advertised the flag +- A prompt no longer writes its colon twice — the most-seen menu of the software asked « Command:: », and seven remote-deployment prompts showed a colon followed by another +- Eleven submenus now leave their segment in the breadcrumb, and navigation telemetry stops filing them as commands under their raw method name +- The three readers of `~/.ssh/config` agree on what a machine name is: an alias declared with a lowercase `host` is seen, a tab separates as legally as a space, and a negated `!name` pattern is no longer taken for a machine to connect to +- Three translation keys declared twice are gone, and a check refuses the next one: a repeated key silently overwrites the previous, which had already cost a menu label + +## Removed + +- The `sshconf` dependency, declared and installed everywhere and imported nowhere + +## Security + +- An API key and a bearer token are redacted too before a command is displayed, logged or reprinted: `OPENAI_API_KEY=` went out in the clear, and a header token escaped by construction, carrying neither an option name nor a variable name + ## [1.8.0] - 2026-09-04