Les plugins ne touchent plus jamais à votre token


Vous installez un plugin vocal. Il a besoin de votre connexion ChatGPT pour fabriquer un secret de session temps réel. Tout fonctionne pendant quelques jours. Puis, un matin, Codex vous a déconnecté. Vous vous reconnectez, et le lendemain cela recommence. Vous accusez le réseau, le navigateur, peut-être votre compte — tout sauf le plugin, qui après tout n’a fait qu’« utiliser » votre connexion. Ce qui s’est réellement passé : le plugin et Hermes voulaient tous deux rafraîchir le même identifiant, et les refresh tokens Codex sont à usage unique. Celui qui le dépense en second tient un token mort, et toute la famille de tokens est révoquée avec lui.

Ce n’est pas un plugin malveillant. C’est le chemin naturel pour un plugin qui a besoin d’une connexion, et la documentation officielle cite ce même plugin vocal en exemple. La PR #136117 (fusionnée dans main le 10 octobre 2026 et livrée dans v0.21.7) a choisi une troisième voie : laisser le cœur faire la requête. Le plugin ne lit plus, ne rafraîchit plus et n’écrit plus l’identifiant — il ne reçoit que la réponse.

Comment la panne survient : un token à usage unique, deux dépensiers

La mécanique mérite d’être détaillée, car la responsabilité est sans ambiguïté une fois qu’on l’a vue :

  • Les refresh tokens Codex sont invalidés par leur usage, si bien que le rafraîchissement doit être sérialisé. Hermes le sait depuis un moment et rafraîchit sous verrou — l’ensemble de règles qui couvre cette classe de providers s’appelle _SINGLE_USE_REFRESH_PROVIDERS.
  • Un plugin qui a besoin de la connexion ChatGPT avait tendance à embarquer son propre petit lecteur pour ~/.codex/auth.json : si le token semblait proche de l’expiration, POST vers auth.openai.com/oauth/token avec l’id client de la CLI Codex, puis réécriture des nouveaux tokens.
  • Ces deux implémentations de rafraîchissement ne partagent aucun verrou. Quand elles rafraîchissent en même temps, le même refresh token est dépensé deux fois. Le serveur y voit un abus et invalide toute la famille de tokens.

Le résultat est le symptôme qui coûte des heures à diagnostiquer : rien n’a changé dans votre configuration, personne n’a mal tapé de mot de passe, et vous êtes simplement déconnecté. Et cela revient après vous être reconnecté, parce que le second rafraîchisseur du plugin est toujours là.

La nouvelle forme : le cœur fait la requête

Le nouveau module public hermes_cli/plugin_provider_requests.py propose credentialed_provider_request(). Il est délibérément sobre — une fonction importable de la même forme que hermes_cli.urllib_security.open_credentialed_url — si bien qu’il fonctionne aussi bien depuis du code register(ctx) que depuis des plugins de tableau de bord (dashboard/plugin_api.py).

Deux étapes pour l’auteur d’un plugin. D’abord, déclarer la connexion dans plugin.yaml :

name: live-voice
requires_auth:
  - openai-codex

Puis confier la requête au cœur et cesser de toucher au fichier d’identifiants :

from hermes_cli.plugin_provider_requests import ProviderNotSignedIn, credentialed_provider_request

def mint_client_secret(session: dict) -> dict:
    try:
        resp = credentialed_provider_request(
            "openai-codex", "POST", "https://api.openai.com/v1/realtime/client_secrets",
            json={"session": session}, timeout=15)
    except ProviderNotSignedIn as exc:
        return {"ok": False, "error": str(exc)}  # le message nomme la commande de connexion
    if resp.status != 200:
        return {"ok": False, "error": f"client secret failed ({resp.status}): {resp.text[:300]}"}
    return {"ok": True, **resp.json()}

Ce qui revient est un ProviderResponse (status, headers, body, plus .text et .json()) — sans aucun token dedans. Le code à supprimer, c’est votre lecteur et votre rafraîchisseur bricolés à la main : côté plugin, l’étape de rafraîchissement n’existe plus.

Quatre règles strictes

Ce n’est pas un proxy polyvalent. Il porte quatre limites, chacune répondant à un incident réel :

  • Plugins déclarés uniquement. L’appelant est identifié comme le répertoire de plugin trouvé sur la pile d’appels ; le cœur lit le plugin.yaml de ce plugin. Un plugin qui ne liste pas le provider sous requires_auth — ou du code qui n’appartient à aucun plugin installé — se voit renvoyer un PermissionError. Confier le helper nu à run_in_executor n’aide pas non plus : il n’y a aucune trame de plugin sur la pile, donc l’appel est refusé. Appelez-le depuis votre propre fonction.
  • Origines fixes uniquement. Pour openai-codex, le token n’est envoyé qu’à https://chatgpt.com et https://api.openai.com. Toute autre URL est refusée avant le moindre échange réseau. Un profil dont l’identifiant Codex passe par une passerelle personnalisée est refusé aussi — cette clé appartient à la passerelle, pas à ce chemin.
  • Aucune redirection. follow_redirects=False. Une réponse 3xx revient inchangée, si bien qu’un en-tête Authorization ne peut jamais être dévié vers un troisième hôte.
  • Identifiants limités au profil. Il résout via resolve_codex_runtime_credentials(), le même chemin que le chat : rafraîchissement sous verrou, portée de profil, conscient des pools. Sans connexion, vous obtenez ProviderNotSignedIn, dont le message nomme la commande dont l’utilisateur a besoin (hermes auth add openai-codex, ou la connexion Codex dans l’application de bureau).

Ce que cela change pour la personne qui installe des plugins

Une bonne partie de la valeur tient ici à la visibilité au moment de l’installation :

  • requires_auth apparaît dans l’invite d’installation et dans hermes plugins show <name>.
  • Plus utile encore, il participe au flux de consentement quand un pin de catalogue est mis à jour. Si la nouvelle version ajoute des outils, des hooks, des dépendances Python, des capacités d’hôte, une connexion à un provider (requires_auth) ou une moitié d’interface Desktop que la version installée n’avait pas, la CLI montre le delta et demande o/N avant que quoi que ce soit ne bouge ; les boutons de mise à jour du Desktop et du tableau de bord affichent la même confirmation. Refuser — ou une session non interactive sans personne pour répondre — laisse le plugin sur son ancien pin.

Autrement dit, « ce plugin a discrètement élargi son propre accès » est désormais une question que le chemin de mise à jour pose à voix haute.

Ce que ce n’est pas : ce n’est pas un bac à sable

La documentation officielle est sans détour là-dessus, et cela mérite d’être répété mot pour mot aux utilisateurs : c’est du consentement et de la visibilité, pas un bac à sable. Le code d’un plugin s’exécute dans le même processus, si bien qu’il peut toujours lire n’importe quel fichier que vous pouvez lire. Le processus de revue rejette les plugins qui le font, mais c’est une contrainte de revue, pas une application au niveau du noyau.

Les seules garanties fortes sont les quatre règles ci-dessus : attribution, origine, redirections et chemin de résolution des identifiants. Lisez cela comme « un plugin ne peut plus se faire passer pour le cœur lorsqu’il utilise votre connexion », et non comme « le plugin a été mis sous contrôle ».

Les panneaux d’usage et la déconnexion passent aussi par le cœur

Deux pièges que la documentation écarte explicitement :

  • N’appelez pas vous-même les endpoints d’usage. Utilisez agent.account_usage.fetch_account_usage("openai-codex", read_only=True). Il renvoie un instantané (formule, fenêtres de session et hebdomadaires avec used_percent et reset_at) et ne rafraîchit ni ne fait tourner un identifiant, jamais, une bonne fois pour toutes.
  • La déconnexion appartient à Hermes, pas à un plugin. Orientez les utilisateurs vers hermes auth logout openai-codex plutôt que de supprimer des fichiers d’identifiants — les supprimer, c’est précisément là où toute cette histoire a commencé.

Que faire maintenant

  • Auteurs de plugins : si votre plugin lit auth.json ou rafraîchit lui-même un token Codex, supprimez ce code, appelez plutôt credentialed_provider_request() et ajoutez requires_auth à plugin.yaml. Tous ceux qui l’utilisent cessent d’être déconnectés par cette course.
  • Tous les autres : la capacité est dans la v0.21.7 (étiquetée le 11 octobre). Pour voir si un plugin installé l’a adoptée, hermes plugins show <name> affiche le requires_auth déclaré. Pour le modèle de sécurité alentour, voyez notre guide du catalogue de plugins et l’article sur la chaîne d’approvisionnement CI du catalogue.

La version courte

  • « Les plugins ne devraient pas détenir votre token » n’est pas du purisme ; c’est la conclusion d’un bug concret : un refresh token à usage unique dépensé par deux rafraîchisseurs, et un utilisateur déconnecté sans comprendre pourquoi.
  • Le correctif n’a pas consisté à confier aux plugins une clé plus petite. Il a consisté à leur retirer la clé : le cœur fait la requête et ajoute l’en-tête Authorization, et le plugin ne voit que la réponse.
  • Les quatre règles — le déclarer, des origines fixes, aucune redirection, des identifiants limités au profil — font passer cela de la convention au mécanisme.
  • La visibilité compte tout autant : requires_auth apparaît à l’installation, à l’inspection et dans la confirmation de mise à jour, si bien qu’un plugin ne peut plus ajouter une connexion discrètement.
  • Et gardez la frontière en tête : du consentement et de la visibilité, pas un bac à sable.