Model stack Chapitre 17bis / 42

Clés, forfaits et sessions : le profil d'authentification par agent

Une clé au jeton, un forfait exposé derrière une clé, une session attachée au poste : trois régimes de credentials rangés par mécanique, déclarés par profil dans le roster — variables, route, hôtes —, résolus à zéro token et injectés par le port, coffre .env fermé : chaque nœud n'emporte que ce que son profil nomme.

Depuis le chapitre 15, votre usine suppose une clé : celle de la passerelle, dans .env, et tous les moteurs du roster derrière. Or vous payez peut-être déjà un forfait mensuel (GLM Coding Plan chez Z.ai, Kimi For Coding, ChatGPT, Claude) et un forfait ne passe pas par la passerelle : il a sa propre clé et son propre point d’entrée, ou une session liée à votre poste. Le roster du chapitre 10 n’a aucun endroit pour le dire, et le manque se paie tard : la gate déterministe du module 6 sera verte, et c’est le premier scout payant qui rendra « Not logged in ». À la fin de ce chapitre, chaque agent de votre roster référencera un profil d’authentification, ce qu’il a le droit d’emporter et par où, le roster refusera de se charger si une variable manque, en la nommant, avant le moindre jeton, et le port n’injectera dans chaque nœud que les variables de son profil. Trois pièces : roster.py (qui remplace la version du chapitre 10), harness.py (qui remplace celle du chapitre 15) et adw_scout.py (qui remplace celle du chapitre 11). Votre factory.config.yaml ne change pas d’une ligne tant que vous restez sur la passerelle, et les chapitres qui redonneront ces trois fichiers plus tard garderont le profil : une pièce posée ne se casse pas.

Trois régimes de credentials derrière un même port

L’idée en une phrase

Un credential se range par sa mécanique, jamais par sa marque : une clé au jeton (une variable, révocable, plafonnable), un forfait exposé derrière une clé (une clé liée à l’abonnement, un point d’entrée propre au fournisseur, un profil ordinaire hors passerelle), ou une session attachée au poste (un trousseau, un rafraîchissement, des quotas par fenêtre, rien à provisionner). Les trois passent par le même port (chapitre 7), et c’est le code, le roster puis le port, qui décide ce que chaque nœud emporte, et par quelle route.

Points clés

  • La clé au jeton est le régime de l’usine. Une variable (OPENROUTER_API_KEY sur la passerelle, DEEPSEEK_API_KEY ou OPENAI_API_KEY en direct chez un fournisseur), une facture au jeton, et deux propriétés que rien d’autre n’offre : elle se révoque et se plafonne par run, ce que le chapitre 23 exploitera avec des clés jetables, datées et plafonnées. Tout ce que l’usine lance sans humain devrait tourner sur ce régime.
  • Un forfait exposé derrière une clé est un profil ordinaire, hors passerelle. Le GLM Coding Plan de Z.ai, vérifié ce jour dans pi 0.85, est un fournisseur natif zai : une variable, ZAI_API_KEY, un point d’entrée api.z.ai, des routes zai/glm-5.3, zai/glm-5.3-flash. Kimi For Coding, de même : kimi-coding/k3 derrière KIMI_API_KEY sur api.kimi.com. Ce que le forfait change, ce n’est pas l’injection (une variable, comme la passerelle), c’est la route, qui ne passe plus par OpenRouter, et la facture : un quota par fenêtre de temps à la place d’un prix au jeton, avec des conditions d’utilisation à lire chez le fournisseur.
  • La route n’est pas dans le nom du modèle. deepseek/deepseek-v4-flash-0731 est un identifiant du registre OpenRouter, que le port préfixe en openrouter/…, et deepseek/… est aussi un fournisseur natif de pi, joint avec sa propre clé. Même chaîne, deux routes, deux factures. C’est le profil qui tranche (route: gateway ou route: direct), jamais une devinette sur le préfixe.
  • Une session OAuth est attachée au poste, pas au run. claude login (Claude Pro/Max) ou /login dans pi (ChatGPT, Claude, Kimi Code, Copilot, xAI…) stockent un jeton qui se rafraîchit : trousseau ou ~/.claude/.credentials.json côté Claude Code, ~/.pi/agent/auth.json côté pi. Rien à injecter, rien à provisionner, rien à plafonner par run. Une nuance vérifiée ce jour dans la doc de pi : une session Claude Pro/Max utilisée par un harnais tiers est facturée au jeton, hors quota du forfait. Les documentations de ces outils renvoient elles-mêmes à une clé d’API pour l’automatisation.
  • Côté Claude Code, les trois régimes ont chacun leur variable, et un ordre. Vérifié ce jour dans la documentation d’authentification de Claude Code : ANTHROPIC_API_KEY (une clé de la Console, régime clé), CLAUDE_CODE_OAUTH_TOKEN (un jeton d’un an frappé par claude setup-token sur votre abonnement Pro, Max, Team ou Enterprise, le régime forfait exposé derrière une clé, prévu par la doc pour « les pipelines CI et les scripts »), et la session de claude login (régime session : trousseau macOS ou ~/.claude/.credentials.json, que CLAUDE_CONFIG_DIR sait déplacer). La préséance est fixe : ANTHROPIC_AUTH_TOKEN (un jeton porteur, pour une passerelle, le chapitre 15), puis ANTHROPIC_API_KEY, puis apiKeyHelper, puis CLAUDE_CODE_OAUTH_TOKEN, puis la session. Et en mode -p, une clé présente est toujours utilisée, sans question.
  • Ce qu’on ne peut pas plafonner se paie autrement. Une clé jetable borne un run à quelques dollars. Un forfait borne un mois entier, partagé avec votre travail interactif : un best-of-N qui s’emballe vide la fenêtre de tout le monde. Un forfait n’est jamais une façon de contourner une facturation, c’est un choix de régime, avec ses quotas et ses conditions.

Exemple concret

Un adw_sdlc sur Plume, cinq phases agent, une soixantaine de centimes sur le roster éco du chapitre 17 : la facture du jour, au jeton, sur une clé que le chapitre 23 saura plafonner à un dollar. Le même run avec un builder sur le GLM Coding Plan (zai/glm-5.3, route directe) : zéro centime de plus sur la carte, mais une part de votre quota de la fenêtre en cours, invisible dans factory.db, qui compte des jetons au prix du catalogue de pi (1,40 $ et 4,40 $ le million au moment d’écrire, les mêmes que par la passerelle), pas des quotas. Lancez trois runs de suite et votre session interactive de l’après-midi attendra la fenêtre suivante. Et le scénario qui motive ce chapitre : vous montez une boîte au module 6, la gate à sec est verte (outils, HEAD, .env non vide, la porte laisse passer la passerelle), puis le premier build rend une erreur d’authentification, parce que le builder compte sur une clé de forfait que .env n’a pas, ou sur une session de votre trousseau, qui n’a jamais voyagé. Avec un profil déclaré, le roster refuse de se charger avant la boîte : variable ZAI_API_KEY absente de .env et de l'environnement, en une fraction de seconde, zéro jeton, et l’union des hôtes des profils dit à la porte ce qu’elle doit ouvrir, api.z.ai, que la porte du chapitre 22 ne connaît pas encore.

Trois régimes, une mécanique chacun

RégimeCe qu’on injecteRouteRévocable / plafonnable par runProvisionnable (ch. 23)Où ça casse sans profil
Clé au jeton — passerelle (OPENROUTER_API_KEY)une variablegateway : le port préfixe openrouter/oui / oui (clé jetable)ouila clé manque : « no api key » au premier appel
Clé au jeton — fournisseur natif (DEEPSEEK_API_KEY, OPENAI_API_KEY…)une variabledirect : deepseek/…, openai/… tels quelsoui / selon le fournisseurnonle port préfixe openrouter/ : mauvaise route, mauvaise facture
Forfait exposé derrière une clé (GLM Coding Plan, Kimi For Coding)une variable (ZAI_API_KEY, KIMI_API_KEY)direct : zai/glm-5.3, kimi-coding/k3oui / non — le quota est mensuel, par fenêtrenonidem, plus un quota consommé sans trace
Session attachée au poste (Claude Pro/Max, ChatGPT, Kimi Code)rien — un fichier de sessiondirectnon / nonnonla session n’a pas voyagé : « Not logged in » dans la boîte
Claude Code — clé (ANTHROPIC_API_KEY)une variabledirect (API Anthropic)oui / selon la Consolenonune clé oubliée dans l’environnement prend le pas sur l’abonnement
Claude Code — forfait (CLAUDE_CODE_OAUTH_TOKEN, claude setup-token)une variable, valable un andirectnon / non — le quota de l’abonnementnonignoré en --bare ; le jeton expiré rend « Login expired »
Claude Code — session (claude login, CLAUDE_CONFIG_DIR)rien — un dossier de configurationdirectnon / nonnonle dossier n’a pas voyagé : « Not logged in » dans la boîte

Config — trois régimes dans .env, des valeurs seulement

Le profil nommera ces variables, et .env en porte les valeurs, jamais le contraire. Un régime session n’a rien à mettre dans .env (la session vit dans le trousseau du poste) sauf, côté Claude Code, le dossier de configuration où la lire quand elle a été copiée : c’est le profil qui dira, au module 6, quel fichier monter, et CLAUDE_CONFIG_DIR qui dira à Claude Code d’y lire.

# .env — les valeurs, jamais commitees (ch. 1). Le roster ne connait que les NOMS.
# regime "key", route gateway : la passerelle, une cle, tous les moteurs (ch. 15)
OPENROUTER_API_KEY=sk-or-...
# regime "plan", route direct : la cle du forfait, chez son fournisseur (ici Z.ai)
ZAI_API_KEY=...
# regime "plan", cote Claude Code : le jeton d'un an de votre abonnement (claude setup-token)
CLAUDE_CODE_OAUTH_TOKEN=...
# regime "key", cote Claude Code : une cle de la Console — elle prendrait le pas sur l'abonnement
ANTHROPIC_API_KEY=...
# regime "session" : rien ici — `/login` dans pi ou `claude login`, sur le poste ; pour la
# deplacer, CLAUDE_CONFIG_DIR (Claude Code) pointe vers le dossier monte
CLAUDE_CONFIG_DIR=/chemin/absolu/vers/plume-factory/adws/adw_data/auth/claude

Piège courant : « un forfait, c’est de l’inférence gratuite pour l’usine » est inexact. Un forfait est un quota partagé : chaque phase y puise ce que votre session interactive n’aura plus, la facture ne se lit dans aucune trace, et les conditions d’utilisation du fournisseur disent ce qu’un usage automatisé a le droit d’en faire. Et « une session vaut une clé » ne tient pas davantage : elle ne se révoque pas par run, ne se plafonne pas, et, sur un harnais tiers, peut être facturée au jeton sans que le forfait y soit pour rien.


Le profil d’authentification dans le roster

L’idée en une phrase

Un bloc auth: du roster déclare, par profil, le régime, la route (gateway ou direct), les noms des variables à injecter (env : des noms, jamais des valeurs), les hôtes que la porte du module 6 devra ouvrir et l’éventuel fichier de session à monter (mount). Chaque agent référence un profil (auth:, gateway par défaut), le roster le résout et le valide à zéro token, et le port route le modèle selon le profil et injecte ses variables dans le nœud à la place du .env global du chapitre 15, avec la même préséance (.env, puis l’environnement réel) et le coffre fermé pour le reste.

Points clés

  • Le profil décrit, il n’invente pas. env liste des noms en majuscules, et une valeur glissée dans le YAML (OPENROUTER_API_KEY=sk-…) est refusée au chargement. Les valeurs viennent de .env puis de l’environnement réel, la préséance du chapitre 15, que le roster applique lui-même pour voir ce que le port verra, et rien de plus.
  • Quatre refus, tous avant le premier jeton. Un profil référencé qui n’existe pas (avec la liste des profils déclarés). Une variable nommée par un profil utilisé et absente (avec son nom : variable ZAI_API_KEY absente…). Une route ou un régime inconnus. Un mount qui sort du dépôt (chemin absolu, ~, ou remontée par ..), parce qu’une session voyage sous un chemin relatif, jamais depuis votre HOME. roster.load() échoue, l’ADW s’arrête, rien n’a été lancé.
  • La route suit le profil, le modèle suit la route. Sous route: gateway, le roster parle le registre OpenRouter et le port préfixe (z-ai/glm-5.3openrouter/z-ai/glm-5.3). Sous route: direct, l’identifiant est déjà une route de pi et part tel quel (zai/glm-5.3), --model zai/glm-5.3, sans préfixe. Côté Claude Code, la route est toujours directe : l’adaptateur ne garde que l’identifiant, et c’est l’environnement qui dit à qui parler.
  • Le coffre .env se ferme sous profil. Quand la requête porte un profil, le port retire du nœud chaque variable que .env avait fournie et n’y dépose que celles du profil. Ce qui vient de l’environnement réel reste à vous, et ce que le port ou l’ADW pose lui-même pour la phase (le budget de réflexion de Claude Code, par exemple) passe toujours. Un builder sur le forfait Z.ai et un reviewer sur la passerelle coexistent dans le même roster sans que la clé de l’un n’atteigne le nœud de l’autre. Un agent Claude Code sur votre abonnement ne verra jamais l’ANTHROPIC_API_KEY que .env garde pour un autre, celle qui, par préséance, l’aurait fait facturer à la Console. Sans profil (hello_factory, chapitre 3), l’héritage du chapitre 15 s’applique tel quel.
  • Les hôtes préparent la porte, et mount est une exception qui le dit. Roster.hosts() est l’union des hôtes des profils utilisés, openrouter.ai seul pour un roster d’avant ce chapitre. Au chapitre 22, la porte de la boîte dérivera sa liste d’hôtes de cette union plutôt que d’une constante : un erratum d’une ligne dans sandbox_lifecycle.py. Un régime session nomme le fichier à monter, relatif au dépôt et sous data_dir de préférence, donc jamais commité, et porte son avertissement : ni plafonnable ni révocable par run, à réserver au poste. Pour le hors-site, la voie normale reste celle du chapitre 23.

Exemple concret

Un roster à deux routes : le builder sur le GLM Coding Plan (profil glm-plan, route directe, zai/glm-5.3), le reviewer sur la passerelle (profil gateway). Au lancement d’un build, le port construit l’environnement du nœud builder : tout l’environnement réel, moins tout ce que .env a fourni, plus ZAI_API_KEY. La clé de la passerelle n’y est pas, ni rien de ce que vous mettrez plus tard dans .env (au chapitre 23, un jeton de provisioning), et la ligne de commande porte --model zai/glm-5.3, sans préfixe. Le nœud reviewer, lui, reçoit OPENROUTER_API_KEY et --model openrouter/google/gemini-3.7-flash. Retirez maintenant ZAI_API_KEY de .env : uv run adws/adw_build.py … s’arrête en une fraction de seconde sur agent 'builder' : profil 'glm-plan' : variable ZAI_API_KEY absente…, zéro jeton, au lieu d’une phase de plan payée (une dizaine de centimes sur le roster par défaut) avant un build qui rend une erreur d’authentification. Coût du profil à l’usage : rien, une lecture de YAML et un dictionnaire de plus par phase.

Où vit quoi

Ce qui compteOù il vitQui le lit
Le régime, la route, les noms de variables, les hôtes, le montagele bloc auth: du roster (commité)roster.load(), à zéro token
Les valeurs des variables.env (jamais commité), puis l’environnement réelle roster pour valider, le port pour injecter
Une session OAuthle trousseau du poste, ou, à monter, un chemin sous data_dirle harnais lui-même
La route du modèleAuthProfile.route, transmise au port par la requête (direct)_route, dans le port
Ce que le nœud reçoitl’environnement du sous-processus, coffre ferménode_environment, dans le port
Ce que la boîte peut joindreRoster.hosts()la porte du module 6, au chapitre 22

Config — le bloc auth: et sa référence par agent

À coller en tête de factory.config.yaml le jour où vous voulez un autre profil que la passerelle. Sans ce bloc, le profil intégré gateway s’applique à tous : votre roster d’aujourd’hui tourne tel quel. Les routes et identifiants sont ceux vérifiés au moment d’écrire dans le catalogue de pi (pi --list-models zai) et dans la documentation de Claude Code. Le profil claude-session montre la forme d’un régime session : ne le référencez que pour un agent lancé depuis votre poste, et préférez-lui claude-plan dès qu’un run tourne sans vous.

# Les profils d'authentification (17bis) : le regime, la route, les NOMS des
# variables, les hotes que la porte devra ouvrir, l'eventuelle session a monter.
auth:
  gateway:                       # la passerelle : une cle, tous les moteurs (ch. 15)
    regime: key
    route: gateway               # le port prefixe openrouter/ — ids du registre OpenRouter
    env: [OPENROUTER_API_KEY]
    hosts: [openrouter.ai]
    note: revocable et plafonnable par run — le regime de l'usine sans humain
  glm-plan:                      # un forfait EXPOSE derriere une cle : un profil ordinaire
    regime: plan
    route: direct                # le fournisseur natif zai de pi — ids zai/glm-5.3, zai/glm-5.3-flash
    env: [ZAI_API_KEY]
    hosts: [api.z.ai]
    note: quota mensuel par fenetre, partage avec votre travail interactif
  claude-plan:                   # l'abonnement Claude, expose derriere un jeton d'un an (claude setup-token)
    regime: plan
    route: direct                # l'API Anthropic, par Claude Code — l'adaptateur ne garde que l'id
    env: [CLAUDE_CODE_OAUTH_TOKEN]
    hosts: [api.anthropic.com]
    note: Pro, Max, Team ou Enterprise — quota de l'abonnement ; ignore en --bare
  claude-session:                # la session de `claude login`, copiee pour voyager
    regime: session
    route: direct
    env: [CLAUDE_CONFIG_DIR]     # ou Claude Code lit .credentials.json — le dossier monte
    mount: adws/adw_data/auth/claude   # sous data_dir, jamais commite ; jamais votre HOME
    hosts: [api.anthropic.com]
    note: ni plafonnable ni revocable par run — poste seulement ; hors-site = ch. 23

agents:
  - name: builder
    purpose: Implementer le plan, exactement ; declarer chaque fichier modifie.
    model: zai/glm-5.3           # une ROUTE de pi, pas un id du registre OpenRouter
    auth: glm-plan
    thinking: high
    tools: [read, bash, grep, find, ls, edit, write]
  - name: reviewer
    purpose: Confirmer que ce qui est construit est ce qui etait demande ; ne rien changer.
    harness: claude
    model: anthropic/claude-opus-5   # l'adaptateur claude ne garde que l'id : claude-opus-5
    auth: claude-plan
    thinking: high
    writes: []

Commande — la gate du roster, puis un scout sous profil

La gate du roster lit vos profils, calcule l’union des hôtes et rejoue à sec les refus. Le scout a deux boutons d’essai de plus, pour essayer un profil sans toucher au roster : --auth, et --model, parce qu’un profil à route directe attend une route de son fournisseur, pas l’id de la passerelle que le scout porte par défaut. Les profils servent les deux harnais par le même port : le scout, sur pi, s’essaie par --auth et --model, et le profil Claude Code se lit dans le roster, sur le reviewer de la Config ci-dessus. Un adw_build le lancera avec son jeton d’abonnement dans l’environnement, et aucune clé de la Console à côté.

# la gate du roster — zero jeton
uv run --with pyyaml python -m adws.adw_modules.roster
# un scout sur le forfait Z.ai, route directe — si vous en avez un (sinon : le defaut, gateway)
uv run adws/adw_scout.py "Ou vivent les tests de Plume ?" --auth glm-plan --model zai/glm-5.3-flash

Piège courant : « deepseek/deepseek-v4-flash-0731 dans le roster, c’est DeepSeek, pi saura où l’envoyer » est inexact. C’est un identifiant OpenRouter que le port préfixe, et deepseek/… est aussi un fournisseur natif de pi avec sa propre clé : sans profil, vous ne savez pas laquelle des deux factures vous lisez. La route est dans le profil. Et « mount: ~/.claude et ma session voyagera » est refusé au chargement : une session ne quitte le poste que copiée, sous un chemin du dépôt, en connaissance de cause.


Fil rouge — la pièce posée aujourd’hui

Sur le plan de l’usine, la pièce du jour vit dans la zone moteurs, à côté des quatre rosters et de la clé du chapitre 15, et touche le squelette ADW en deux points : le roster (chapitre 10), qui gagne le bloc auth: et sa validation, et le port harnais (chapitre 7, adaptateurs du chapitre 15), qui apprend la route directe et ferme le coffre. La couture ne bouge pas : l’agent propose, et il ne sait même pas sous quel régime ni par quelle route il tourne. Le code dispose du profil, de sa résolution, du refus, de la route et de l’environnement exact du nœud. Déterministe : le bloc auth:, la résolution des noms, les quatre refus, la route, l’union des hôtes, le coffre. Délégué : rien de nouveau. Coût d’usage : zéro jeton. Ce que la pièce fait économiser, c’est un run qui aurait payé sa phase de plan avant de découvrir, dans la boîte, qu’une clé de forfait ou une session n’avait pas voyagé, et, plus tard, la liste d’hôtes que la porte n’aura plus à deviner. Le préflight du chapitre 4 ne connaît pas les routes. Celui de l’annexe (chapitre A10) vérifiera, profil par profil, que chaque route existe dans le registre de pi avant tout run.


Travaux pratiques — la pièce du jour

Trois fichiers à poser dans plume-factory, qui devient, chapitre après chapitre, votre usine logicielle agentique : le roster qui déclare et valide les profils, le port qui route et ferme le coffre, la fabrique de phase qui passe le profil au port. Votre factory.config.yaml ne change pas : le profil intégré gateway reste le défaut, et le chapitre se lit et se teste à sec sans aucun abonnement.

Pièce — adws/adw_modules/roster.py

Cette version remplace celle du chapitre 10. Tout ce qui existait reste : mêmes dataclasses, même fusion, mêmes validations, même gate. S’ajoutent AuthProfile (régime, route, noms, hôtes, montage, et sa résolution credentials()), le profil intégré gateway, le champ auth de AgentSpec (le profil résolu, pas son nom), le bloc auth: lu et validé par _auth, Roster.profile(), Roster.credentials(), Roster.hosts(), la lecture de .env par le roster lui-même, et la validation des variables des profils utilisés, le tout à zéro token, dans la gate du module.

"""roster — la feuille de distribution de l'usine : qui tourne, avec quels moyens.

Les ADW nomment des agents, jamais des modeles. Ce module charge
factory.config.yaml, fusionne chaque agent sur les defauts (cle par cle),
et valide TOUT avant le moindre lancement : une erreur de roster coute
zero token — c'est le but.

Depuis le chapitre 17bis, le roster porte aussi les PROFILS D'AUTHENTIFICATION
(bloc `auth:`) : par profil, le regime (cle, forfait, session), la route
(passerelle ou fournisseur direct), les NOMS des variables a injecter dans le
harnais — jamais leurs valeurs, qui vivent dans .env ou dans l'environnement
reel —, les hotes que la porte du module 6 devra ouvrir, et l'eventuel
fichier de session a monter dans une boite. Chaque agent reference un profil
(`auth:`, `gateway` par defaut). Tout est valide a zero token : profil
inconnu, variable absente (nommee dans le message), route ou regime inconnus,
montage hors du depot — avant le moindre lancement. Sans bloc `auth:`, le
profil integre `gateway` s'applique : un roster d'avant ce chapitre tourne
tel quel.
"""
from __future__ import annotations

import os
from dataclasses import dataclass
from pathlib import Path, PurePosixPath, PureWindowsPath

import yaml

DEFAULT_PATH = Path("adws/adw_config/factory.config.yaml")

# Les deux dialectes du port (ch. 7), l'echelle de reflexion, et les sept
# outils integres de pi — l'adaptateur Claude Code traduira (ch. 11).
HARNESSES = ("pi", "claude")
THINKING = ("off", "minimal", "low", "medium", "high", "xhigh", "max")
KNOWN_TOOLS = ("read", "bash", "edit", "write", "grep", "find", "ls")


# Les trois regimes de credentials (17bis), ranges par MECANIQUE, jamais par
# marque : une cle au jeton (une variable, revocable, plafonnable) ; un
# forfait EXPOSE derriere une cle (la cle de l'abonnement, un point d'entree
# propre au fournisseur : un profil ordinaire) ; une session attachee au
# poste (rien a injecter, un fichier de session a monter — ni plafonnable ni
# revocable par run).
REGIMES = ("key", "plan", "session")
DEFAULT_AUTH_NAME = "gateway"

# Par ou passe le modele : `gateway` = la passerelle du ch. 15 (le port prefixe
# la route, l'identifiant du roster est celui du registre OpenRouter) ;
# `direct` = un fournisseur natif du harnais, l'identifiant du roster est la
# route telle quelle (zai/glm-5.3, kimi-coding/k3). La route n'est pas dans le
# nom du modele — `deepseek/...` est a la fois un auteur OpenRouter et un
# fournisseur natif de pi — elle est dans le profil.
ROUTES = ("gateway", "direct")


class RosterError(ValueError):
    """Un roster invalide — le motif exact, avant tout lancement."""


@dataclass(frozen=True)
class AuthProfile:
    """Un profil d'authentification : ce qu'un agent a le droit d'emporter, et par ou.

    Le profil DECRIT, il n'invente pas : `env` liste des NOMS de variables,
    jamais des valeurs ; les valeurs vivent dans .env (ou l'environnement
    reel, qui gagne toujours — la preseance du ch. 15). `hosts` est ce que la
    porte du module 6 ouvrira pour ce profil : un profil sans hosts n'ouvre
    rien. `mount` est un chemin RELATIF au depot, sous data_dir de preference
    (jamais commite) : une session copiee la pour voyager dans une boite.
    """
    name: str
    regime: str                        # key | plan | session
    route: str = "gateway"             # gateway | direct — par ou passe le modele
    env: tuple[str, ...] = ()          # des noms, jamais des valeurs
    hosts: tuple[str, ...] = ()        # ce que la porte devra ouvrir (ch. 22)
    mount: str | None = None           # session a monter, relative au depot
    note: str = ""                     # une phrase pour le lecteur du roster

    @property
    def direct(self) -> bool:
        """Vrai si le modele part tel quel vers un fournisseur natif, sans passerelle."""
        return self.route == "direct"

    def credentials(self, env: dict[str, str] | None = None) -> dict[str, str]:
        """Les variables du profil, resolues — ou le NOM de celle qui manque.

        C'est le dictionnaire que le port injectera dans le noeud, a la
        place du .env global : un noeud n'emporte que ce que son profil nomme.
        """
        source = os.environ if env is None else env
        missing = [name for name in self.env if not source.get(name)]
        if missing:
            raise RosterError(f"profil {self.name!r} : variable {missing[0]} absente de .env "
                              "et de l'environnement — le harnais rendrait "
                              "« Not logged in » au premier jeton paye")
        return {name: source[name] for name in self.env}


# Le profil integre : la passerelle, une cle, tous les moteurs (ch. 15). Un
# roster sans bloc `auth:` tourne dessus ; un bloc `auth:` peut le redefinir.
GATEWAY_PROFILE = AuthProfile(name=DEFAULT_AUTH_NAME, regime="key", route="gateway",
                              env=("OPENROUTER_API_KEY",), hosts=("openrouter.ai",),
                              note="la passerelle : une cle, revocable et plafonnable par run, tous les moteurs")


@dataclass(frozen=True)
class AgentSpec:
    """Un agent du roster, defauts fusionnes : pret a etre lance tel quel."""
    name: str
    purpose: str
    harness: str
    model: str
    thinking: str
    tools: tuple[str, ...]
    writes: tuple[str, ...] | None   # None = libre · () = lecture seule · (...) = ces chemins
    auth: AuthProfile = GATEWAY_PROFILE  # le profil d'authentification, resolu (17bis)


@dataclass(frozen=True)
class Roster:
    """Le roster charge et valide, plus les regles communes a tous les agents."""
    agents: dict[str, AgentSpec]
    protected_files: tuple[str, ...]
    data_dir: str
    auth: dict[str, AuthProfile] | None = None   # None = le seul profil integre (17bis)

    def profile(self, name: str) -> AuthProfile:
        """Le profil par son nom — ou le refus, avec la liste des profils connus."""
        return _profile(self.auth, name)

    def credentials(self, agent: AgentSpec, env: dict[str, str] | None = None) -> dict[str, str]:
        """Ce que le port injectera dans le noeud de cet agent : son profil, resolu."""
        return agent.auth.credentials(env)

    def hosts(self) -> tuple[str, ...]:
        """L'union des hotes des profils UTILISES : ce que la porte du module 6 ouvrira."""
        return tuple(sorted({host for spec in self.agents.values() for host in spec.auth.hosts}))


def _profile(profiles: dict[str, AuthProfile] | None, name: str) -> AuthProfile:
    known = profiles or {DEFAULT_AUTH_NAME: GATEWAY_PROFILE}
    try:
        return known[name]
    except KeyError:
        raise RosterError(f"profil d'authentification inconnu {name!r} — "
                          f"declares : {sorted(known)}") from None


def load(path: str | Path = DEFAULT_PATH, env: dict[str, str] | None = None) -> Roster:
    """Charge, fusionne, valide — dans cet ordre, et tout ou rien.

    env : l'environnement dans lequel les profils sont resolus. None = .env
    puis l'environnement reel (la preseance du ch. 15) ; un dictionnaire
    explicite sert aux gates a sec.
    """
    path = Path(path)
    raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
    defaults = raw.get("defaults") or {}
    entries = raw.get("agents") or []
    if not entries:
        raise RosterError(f"{path} : aucun agent declare")

    profiles = _auth(raw, path)          # les profils d'abord : les agents les referencent
    agents: dict[str, AgentSpec] = {}
    for entry in entries:
        spec = _merge(defaults, entry, profiles)
        if spec.name in agents:
            raise RosterError(f"agent {spec.name!r} declare deux fois")
        agents[spec.name] = spec

    roster = Roster(
        agents=agents,
        protected_files=tuple(defaults.get("protected_files") or ()),
        data_dir=str(defaults.get("data_dir", "adws/adw_data")),
        auth=profiles,
    )
    for spec in agents.values():
        _validate(spec)
    # Les profils : chaque agent en reference un qui existe, et chaque
    # variable qu'il nomme est la — AVANT le premier jeton, jamais apres.
    if env is None:
        load_env()
    for spec in agents.values():
        try:
            roster.credentials(spec, env)
        except RosterError as error:
            raise RosterError(f"agent {spec.name!r} : {error}") from None
    return roster


def load_env(path: str | Path = ".env") -> None:
    """Le contrat du ch. 15 : .env dans l'environnement, sans jamais l'ecraser.

    Le port fait de meme au chargement ; le roster le refait ici pour que
    sa validation voie ce que le port verra — et rien de plus.
    """
    env_file = Path(path)
    if not env_file.is_file():
        return
    for line in env_file.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, _, value = line.partition("=")
        os.environ.setdefault(key.strip(), value.strip().strip("'\""))


def _auth(raw: dict, path: Path) -> dict[str, AuthProfile] | None:
    """Le bloc `auth:` : un profil par nom, valide a zero token. Absent = gateway seul."""
    block = raw.get("auth")
    if block is None:
        return None
    if not isinstance(block, dict) or not block:
        raise RosterError(f"{path} : auth doit etre un bloc — un profil par nom")
    profiles: dict[str, AuthProfile] = {}
    for name, body in block.items():
        body = body or {}
        if not isinstance(body, dict):
            raise RosterError(f"{path} : profil {name!r} doit etre un bloc (regime, route, env, hosts, mount)")
        regime = str(body.get("regime", "key"))
        if regime not in REGIMES:
            raise RosterError(f"profil {name!r} : regime {regime!r} inconnu — {list(REGIMES)}")
        route = str(body.get("route", "gateway"))
        if route not in ROUTES:
            raise RosterError(f"profil {name!r} : route {route!r} inconnue — {list(ROUTES)}")
        names = tuple(str(v) for v in body.get("env") or ())
        for variable in names:
            if "=" in variable or not variable.isidentifier() or variable != variable.upper():
                raise RosterError(f"profil {name!r} : env attend des NOMS de variables "
                                  f"(MAJUSCULES, jamais de valeur) — recu {variable!r}")
        if regime in ("key", "plan") and not names:
            raise RosterError(f"profil {name!r} : un regime {regime!r} nomme au moins une variable")
        mount = body.get("mount")
        if mount is not None:
            mount = str(mount)
            # Relatif au depot, quel que soit l'OS : ni chemin absolu (POSIX ou
            # Windows), ni HOME, ni remontee par `..`.
            if (PurePosixPath(mount).is_absolute() or PureWindowsPath(mount).is_absolute()
                    or mount.startswith("~") or ".." in Path(mount).parts):
                raise RosterError(f"profil {name!r} : mount {mount!r} sort du depot — une session "
                                  "voyage sous un chemin relatif (data_dir de preference), jamais "
                                  "depuis votre HOME")
        if regime == "session" and mount is None:
            raise RosterError(f"profil {name!r} : un regime 'session' nomme le fichier a monter (mount)")
        profiles[str(name)] = AuthProfile(
            name=str(name), regime=regime, route=route, env=names,
            hosts=tuple(str(h) for h in body.get("hosts") or ()),
            mount=mount, note=str(body.get("note", "")),
        )
    return profiles


def _merge(defaults: dict, entry: dict, profiles: dict[str, AuthProfile] | None = None) -> AgentSpec:
    """L'agent par-dessus les defauts, cle par cle : il ne dit que ce qui differe."""
    writes = entry.get("writes", None)      # cle absente = None = libre dans le repo
    name = str(entry.get("name", ""))
    try:
        profile = _profile(profiles, str(entry.get("auth", defaults.get("auth", DEFAULT_AUTH_NAME))))
    except RosterError as error:
        raise RosterError(f"agent {name!r} : {error}") from None
    return AgentSpec(
        name=name,
        purpose=str(entry.get("purpose", "")),
        harness=str(entry.get("harness", defaults.get("harness", "pi"))),
        model=str(entry.get("model", defaults.get("model", ""))),
        thinking=str(entry.get("thinking", defaults.get("thinking", "medium"))),
        tools=tuple(entry.get("tools", defaults.get("tools") or ())),
        writes=None if writes is None else tuple(writes),
        auth=profile,
    )


def _validate(spec: AgentSpec) -> None:
    """Chaque miss echoue AVANT le lancement — jamais pendant, jamais en facture."""
    if not spec.name:
        raise RosterError("un agent sans nom n'est pas adressable")
    if not spec.purpose:
        raise RosterError(f"agent {spec.name!r} : purpose manquant — un agent, un role")
    if spec.harness not in HARNESSES:
        raise RosterError(f"agent {spec.name!r} : harnais inconnu {spec.harness!r} "
                          f"— disponibles : {list(HARNESSES)}")
    if "/" not in spec.model:
        raise RosterError(
            f"agent {spec.name!r} : modele {spec.model!r} — toujours provider/id : "
            "un motif nu devient ambigu des que deux fournisseurs portent le meme modele")
    if spec.thinking not in THINKING:
        raise RosterError(f"agent {spec.name!r} : thinking {spec.thinking!r} "
                          f"hors echelle {list(THINKING)}")
    if not spec.tools:
        raise RosterError(f"agent {spec.name!r} : aucun outil — "
                          "un agent sans outils ne peut rien proposer")
    unknown = [tool for tool in spec.tools if tool not in KNOWN_TOOLS]
    if unknown:
        raise RosterError(f"agent {spec.name!r} : outils inconnus {unknown} "
                          f"— integres : {list(KNOWN_TOOLS)}")


if __name__ == "__main__":
    # La gate du module : le roster se charge, se fusionne et se valide.
    # Lancer depuis la racine : uv run --with pyyaml python -m adws.adw_modules.roster
    loaded = load()
    for agent_name in sorted(loaded.agents):
        spec = loaded.agents[agent_name]
        print(f"{spec.name:9} {spec.harness:7} {spec.model:34} "
              f"thinking={spec.thinking:7} writes={spec.writes}")
        profile = spec.auth
        print(f"{'':9} auth    : {profile.name} ({profile.regime}, route {profile.route}) — "
              f"{', '.join(profile.env) or 'aucune variable'}"
              + (f" ; mount {profile.mount}" if profile.mount else ""))
    print("proteges :", ", ".join(loaded.protected_files))
    print("hotes    :", ", ".join(loaded.hosts()) or "(aucun — la porte n'ouvrira rien)")

    # La gate a sec des profils (17bis) : un roster jetable, un environnement
    # explicite — profil inconnu refuse, variable absente NOMMEE, route ou
    # regime inconnus, montage hors du depot refuse, union des hotes calculee.
    # Zero token, aucun fichier .env lu.
    import tempfile

    HEAD = "defaults: { tools: [read] }\n"
    AUTH = ("auth:\n"
            "  gateway: { regime: key, env: [OPENROUTER_API_KEY], hosts: [openrouter.ai] }\n"
            "  glm-plan: { regime: plan, route: direct, env: [ZAI_API_KEY], hosts: [api.z.ai] }\n"
            "  claude-plan: { regime: plan, route: direct, env: [CLAUDE_CODE_OAUTH_TOKEN], hosts: [api.anthropic.com] }\n"
            "  claude-session: { regime: session, route: direct, env: [CLAUDE_CONFIG_DIR], mount: adws/adw_data/auth/claude, hosts: [api.anthropic.com] }\n")
    AGENTS = ("agents:\n"
              "  - { name: scout, purpose: reperer, model: a/b }\n"
              "  - { name: builder, purpose: construire, model: zai/glm-5.3, auth: glm-plan }\n"
              "  - { name: reviewer, purpose: juger, model: a/b, harness: claude, auth: claude-plan }\n"
              "  - { name: documenter, purpose: rediger, model: a/b, harness: claude, auth: claude-session }\n")
    full = {"OPENROUTER_API_KEY": "sk-or-x", "ZAI_API_KEY": "zai-x", "CLAUDE_CODE_OAUTH_TOKEN": "oat-x",
            "CLAUDE_CONFIG_DIR": "/usine/adws/adw_data/auth/claude"}

    def roster_file(text: str) -> Path:
        handle = tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False, encoding="utf-8")
        handle.write(text)
        handle.close()
        return Path(handle.name)

    # 1. Trois regimes, resolus : la cle, le forfait derriere une cle, la session sans variable.
    trio = load(roster_file(HEAD + AUTH + AGENTS), env=full)
    assert trio.credentials(trio.agents["scout"], full) == {"OPENROUTER_API_KEY": "sk-or-x"}
    assert trio.credentials(trio.agents["builder"], full) == {"ZAI_API_KEY": "zai-x"}
    assert trio.credentials(trio.agents["reviewer"], full) == {"CLAUDE_CODE_OAUTH_TOKEN": "oat-x"}
    assert trio.credentials(trio.agents["documenter"], full) == {"CLAUDE_CONFIG_DIR": "/usine/adws/adw_data/auth/claude"}
    assert not trio.agents["scout"].auth.direct and trio.agents["builder"].auth.direct
    assert trio.hosts() == ("api.anthropic.com", "api.z.ai", "openrouter.ai"), trio.hosts()
    assert trio.profile("claude-session").mount == "adws/adw_data/auth/claude"
    # 2. Sans bloc auth : gateway seul, un roster d'avant ce chapitre tourne tel quel.
    plain = load(roster_file(HEAD + "agents:\n  - { name: scout, purpose: reperer, model: a/b }\n"),
                 env={"OPENROUTER_API_KEY": "sk-or-x"})
    assert plain.auth is None and plain.hosts() == ("openrouter.ai",)
    # 3. Les refus, chacun avec son motif — avant tout lancement.
    for text, env, expected in [
        (HEAD + AUTH + "agents:\n  - { name: scout, purpose: reperer, model: a/b, auth: codex }\n",
         full, "profil d'authentification inconnu 'codex'"),
        (HEAD + AUTH + AGENTS, {"OPENROUTER_API_KEY": "sk-or-x"}, "variable ZAI_API_KEY absente"),
        (HEAD + "auth:\n  side: { regime: key, route: sideways, env: [X] }\n"
         + "agents:\n  - { name: scout, purpose: reperer, model: a/b, auth: side }\n", full, "route 'sideways' inconnue"),
        (HEAD + "auth:\n  home: { regime: session, mount: /Users/vous/.claude, hosts: [api.anthropic.com] }\n"
         + "agents:\n  - { name: scout, purpose: reperer, model: a/b, auth: home }\n", full, "sort du depot"),
        (HEAD + "auth:\n  up: { regime: session, mount: ../ailleurs/session.json }\n"
         + "agents:\n  - { name: scout, purpose: reperer, model: a/b, auth: up }\n", full, "sort du depot"),
        (HEAD + "auth:\n  leak: { regime: key, env: [OPENROUTER_API_KEY=sk-or-x] }\n"
         + "agents:\n  - { name: scout, purpose: reperer, model: a/b, auth: leak }\n", full, "jamais de valeur"),
        (HEAD + "auth:\n  odd: { regime: cookie, env: [X] }\n"
         + "agents:\n  - { name: scout, purpose: reperer, model: a/b, auth: odd }\n", full, "regime 'cookie' inconnu"),
    ]:
        try:
            load(roster_file(text), env=env)
            raise AssertionError(f"aurait du refuser : {expected}")
        except RosterError as error:
            assert expected in str(error), (expected, str(error))
    print("profils  : 3 regimes resolus (passerelle, direct, pi et claude), gateway seul sans bloc auth, union des "
          "hotes ; refus — profil inconnu, variable absente nommee, route inconnue, mount hors depot (x2), "
          "valeur dans env, regime inconnu")

Pièce — adws/adw_modules/harness.py

Cette version remplace celle du chapitre 15. Tout ce qui existait reste : les deux adaptateurs, la route par la passerelle, le chargement de .env. S’ajoutent le coffre (ENV_FILE_KEYS, les clés que .env a fournies et que l’environnement réel ne portait pas), les champs auth (le profil résolu, noms et valeurs, jamais dans le prompt) et direct (la route hors passerelle) de HarnessRequest, le paramètre direct de _route (l’identifiant part tel quel), la fonction pure node_environment, qui construit l’environnement du nœud (tout, sans profil, le coffre fermé et le profil seul, avec), et une gate à sec, que le module n’avait pas encore : elle rejoue les deux routes et les deux coffres.

"""harness — le port de l'usine vers ses agents.

Une frontiere, deux adaptateurs. Aucun script de l'usine n'invoque `pi` ou
`claude` directement : tout passe par run(). Une HarnessRequest entre, un
HarnessResult sort — quel que soit le harnais derriere la porte.

Version chapitre 15 : le port charge .env (les harnais ne le font pas
d'eux-memes) et l'adaptateur pi route chaque modele par la passerelle —
le roster parle le langage du registre, l'adaptateur ajoute le prefixe.

Version chapitre 17bis : le profil d'authentification par agent. Une
requete peut porter `auth`, les variables de credentials que le roster a
resolues pour cet agent (noms ET valeurs, jamais dans le prompt), et
`direct`, la route hors passerelle d'un profil de forfait ou d'API native.
Quand elle porte un profil, .env devient un COFFRE : le noeud ne recoit
plus tout ce que .env avait charge, seulement ce que son profil nomme — a
la place du .env global du ch. 15, meme preseance (.env puis environnement
reel). Sans `auth`, l'heritage du ch. 15 s'applique tel quel.
"""
from __future__ import annotations

import json
import os
import shutil
import subprocess
import uuid
from dataclasses import dataclass
from pathlib import Path

# Les sessions pi vivent dans adw_data/ — couvert par le .gitignore du ch. 1.
SESSION_DIR = Path("adws/adw_data/sessions")

# La route par defaut de l'usine : le fournisseur passerelle integre de pi.
# Une seule cle (OPENROUTER_API_KEY) sert tous les moteurs du roster.
# Passer par les API directes des fournisseurs : GATEWAY = "" — et a vous
# de fournir une cle par fournisseur dans l'environnement.
GATEWAY = "openrouter"

# Le dialecte Claude Code : outils avec majuscules, et pas d'outil ls ni find
# dedies — Bash et Glob les couvrent. L'adaptateur absorbe l'asymetrie.
CLAUDE_TOOLS = {"read": "Read", "bash": "Bash", "edit": "Edit", "write": "Write",
                "grep": "Grep", "find": "Glob", "ls": "Bash"}

# L'echelle de reflexion de pi, traduite en budget de tokens pour Claude Code
# (variable d'environnement MAX_THINKING_TOKENS).
THINKING_TOKENS = {"off": 0, "minimal": 1024, "low": 4096, "medium": 8192,
                   "high": 16384, "xhigh": 24576, "max": 32000}


# Les cles que .env a fournies — et que l'environnement reel ne portait pas.
# C'est le COFFRE (17bis) : ce que le port retire du noeud quand la requete
# porte un profil, pour n'y remettre que ce que le profil nomme.
ENV_FILE_KEYS: set[str] = set()


def _load_env(path: str | Path = ".env") -> None:
    """Charge .env dans l'environnement du process — une fois, au chargement.

    Ni pi ni claude ne lisent .env d'eux-memes : sans ce chargement, la cle
    de la passerelle n'atteindrait jamais les agents. Une variable deja
    presente dans l'environnement reel gagne toujours — un export de session
    ou un secret de CI ne sont jamais ecrases — et n'entre pas dans le coffre :
    elle est a vous, pas a .env.
    """
    env_file = Path(path)
    if not env_file.is_file():
        return
    for line in env_file.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, _, value = line.partition("=")
        key = key.strip()
        if key not in os.environ:
            os.environ[key] = value.strip().strip("'\"")
            ENV_FILE_KEYS.add(key)


_load_env()


def node_environment(base: dict[str, str], extra_env: dict[str, str] | None,
                     auth: dict[str, str] | None, vault: set[str]) -> dict[str, str]:
    """L'environnement d'un noeud — pure, donc testable a sec.

    Sans profil (auth=None) : l'heritage du ch. 15, tout .env compris. Avec
    profil : .env est un coffre — chaque cle qu'il a fournie est retiree,
    puis le profil depose les siennes, avec leurs valeurs. Ce que le port ou
    l'ADW pose lui-meme pour la phase passe toujours.
    """
    environment = dict(base)
    if auth is not None:
        for key in vault:
            environment.pop(key, None)
        environment.update(auth)
    if extra_env:
        environment.update(extra_env)
    return environment


class HarnessError(RuntimeError):
    """Le harnais n'a pas rendu de reponse exploitable."""


@dataclass(frozen=True)
class HarnessRequest:
    """Ce que l'usine a le droit de demander a un agent — rien de plus."""
    prompt: str
    session_id: str | None = None    # None = nouvelle session
    cwd: str = "."
    timeout: int = 600               # un agent muet ne bloque pas l'usine
    model: str | None = None         # l'id du REGISTRE, tel quel dans le roster
    thinking: str | None = None      # off..max — None = defaut du harnais
    tools: tuple[str, ...] = ()      # allowlist du roster — () = outils par defaut
    auth: dict[str, str] | None = None # le profil resolu (17bis) — None = l'heritage du ch. 15
    direct: bool = False             # route directe (17bis) : le modele est deja une route pi


@dataclass(frozen=True)
class HarnessResult:
    """Ce qu'un agent rend a l'usine — quel que soit le harnais."""
    text: str          # la derniere reponse de l'agent
    session_id: str    # de quoi poursuivre la MEME session
    cost_usd: float    # 0.0 si le harnais ne rapporte pas le cout
    returncode: int


def run(harness: str, request: HarnessRequest) -> HarnessResult:
    """L'unique porte d'entree vers les agents : choisit l'adaptateur, normalise."""
    try:
        adapter = ADAPTERS[harness]
    except KeyError:
        raise HarnessError(
            f"harnais inconnu {harness!r} — disponibles : {sorted(ADAPTERS)}"
        ) from None
    return adapter(request)


def _route(model: str, direct: bool = False) -> str:
    """L'id du registre devient une route pi : prefixe du fournisseur passerelle.

    Le roster parle le langage du registre (z-ai/glm-5.3) — la meme chaine
    que verifie la jauge du ch. 14. Le prefixe est un detail de dialecte :
    il vit ici, jamais dans le YAML ni dans vos scripts. Sous un profil a
    route directe (17bis), l'identifiant est deja une route pi (zai/glm-5.3,
    kimi-coding/k3) et part tel quel — la route est dans le profil, pas dans
    le nom : deepseek/... est un auteur OpenRouter ET un fournisseur natif.
    """
    if direct or not GATEWAY or model.startswith(GATEWAY + "/"):
        return model
    return f"{GATEWAY}/{model}"


def _spawn(cmd: list[str], request: HarnessRequest,
           extra_env: dict[str, str] | None = None,
           stdin_text: str | None = None) -> subprocess.CompletedProcess[str]:
    # Resoudre l'executable via le PATH : sous Windows, les harnais sont des
    # shims (pi.cmd, claude.cmd) que CreateProcess ne trouve pas par leur nom
    # court — shutil.which respecte PATHEXT et regle les deux mondes d'un coup.
    executable = shutil.which(cmd[0])
    if executable is None:
        raise HarnessError(f"{cmd[0]!r} introuvable dans le PATH — "
                           "le harnais est-il installe ?")
    environment = node_environment(dict(os.environ), extra_env, request.auth, ENV_FILE_KEYS)
    # Deux modes d'entree, jamais d'entre-deux :
    # - stdin_text=None : le prompt voyage dans argv, et stdin est ferme
    #   (DEVNULL) — un enfant qui herite de notre stdin peut attendre
    #   indefiniment une entree qui ne viendra jamais : echec silencieux,
    #   0 % CPU, aucune sortie.
    # - stdin_text : le prompt voyage par stdin, puis le tube est referme.
    #   Indispensable quand le harnais est un shim .cmd Windows : cmd.exe
    #   tronque un argument a la premiere nouvelle ligne, et les asks de
    #   l'usine (brief + mission + contrat) sont multi-lignes.
    io = ({"input": stdin_text} if stdin_text is not None
          else {"stdin": subprocess.DEVNULL})
    # Encodage explicite : les harnais emettent de l'UTF-8, mais text=True
    # seul decode avec la locale — cp1252 sous Windows, qui mutile tirets
    # et accents. Vaut pour la sortie ET pour le prompt ecrit sur stdin.
    try:
        return subprocess.run([executable, *cmd[1:]], **io,
                              capture_output=True, text=True,
                              encoding="utf-8", errors="replace",
                              env=environment,
                              timeout=request.timeout, cwd=request.cwd)
    except subprocess.TimeoutExpired:
        # Le timeout aussi sort par la porte normalisee : une seule exception.
        raise HarnessError(f"harnais muet apres {request.timeout} s : {cmd[0]}") from None


def _text_of(message: dict) -> str:
    """Concatene les blocs de texte d'un message pi."""
    return "".join(part.get("text", "") for part in message.get("content", []) or []
                   if isinstance(part, dict) and part.get("type") == "text")


def _run_pi(request: HarnessRequest) -> HarnessResult:
    # pi : c'est VOUS qui nommez la session. Meme id + meme dossier = meme
    # contexte — que la session existe deja ou non.
    session_id = request.session_id or str(uuid.uuid4())
    SESSION_DIR.mkdir(parents=True, exist_ok=True)
    cmd = ["pi", "-p", "--mode", "json",
           "--session-id", session_id, "--session-dir", str(SESSION_DIR)]
    # Le profil du roster, traduit dans le dialecte pi — et depuis le
    # chapitre 15, route par la passerelle : une cle, tous les moteurs.
    if request.model:
        cmd += ["--model", _route(request.model, request.direct)]
    if request.thinking:
        cmd += ["--thinking", request.thinking]
    if request.tools:
        cmd += ["--tools", ",".join(request.tools)]
    cmd.append(request.prompt)
    proc = _spawn(cmd, request)

    # Le flux JSONL : seuls les message_end de l'assistant font foi. Le dernier
    # texte gagne (l'agent a pu parler entre deux outils) — mais chaque tour a
    # coute, donc le cout s'additionne au lieu de se remplacer.
    text, cost = "", 0.0
    for line in proc.stdout.splitlines():
        try:
            event = json.loads(line)
        except json.JSONDecodeError:
            continue
        if event.get("type") != "message_end":
            continue
        message = event.get("message", {})
        if message.get("role") != "assistant":
            continue
        text = _text_of(message) or text
        usage = message.get("usage", {}) or {}
        cost += (usage.get("cost", {}) or {}).get("total", 0.0) or 0.0

    if proc.returncode != 0 and not text:
        raise HarnessError(f"pi a rendu {proc.returncode} : {proc.stderr.strip()[-400:]}")
    return HarnessResult(text=text, session_id=session_id,
                         cost_usd=cost, returncode=proc.returncode)


def _claude_evidence(stdout: str, stderr: str) -> str:
    """Le motif d'un echec claude — pure. Le JSON de stdout d'abord, stderr ensuite.

    En --output-format json, claude ecrit son erreur dans l'objet de stdout
    (result, error) et n'envoie sur stderr que des avertissements (« Ignoring
    N permissions.allow entries… ») : lire stderr d'abord masquerait la vraie
    cause — une cle absente, un depot non approuve, un modele inconnu.
    """
    try:
        payload = json.loads(stdout)
        for key in ("result", "error", "message"):
            if isinstance(payload, dict) and payload.get(key):
                return str(payload[key]).strip()[-400:]
    except (json.JSONDecodeError, TypeError):
        pass
    lines = [line for line in stderr.strip().splitlines() if not line.startswith("Ignoring ")]
    return ("\n".join(lines).strip() or stderr.strip() or stdout.strip())[-400:]


def _run_claude(request: HarnessRequest) -> HarnessResult:
    # Claude Code : c'est LUI qui nomme la session. On la poursuit en rendant
    # son session_id via --resume.
    #
    # Regime par defaut : natif Anthropic — sa propre authentification, des
    # modeles Anthropic. Le pointer sur la passerelle est possible (trois
    # variables : ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, et
    # ANTHROPIC_API_KEY explicitement vide), mais la compatibilite n'est
    # garantie que sur les modeles Anthropic : l'adaptateur polyglotte de
    # l'usine reste pi, et ce choix-la appartient a votre environnement,
    # pas a cet adaptateur.
    cmd = ["claude", "-p", "--output-format", "json"]
    if request.model:
        # Le dialecte claude ignore le fournisseur : provider/id -> id.
        cmd += ["--model", request.model.split("/", 1)[-1]]
    if request.tools:
        # Traduire puis dedoublonner en gardant l'ordre : bash et ls donnent
        # tous deux Bash, inutile de le declarer deux fois.
        allowed = list(dict.fromkeys(
            CLAUDE_TOOLS[tool] for tool in request.tools if tool in CLAUDE_TOOLS))
        cmd += ["--allowedTools", ",".join(allowed)]
    if request.session_id:
        cmd += ["--resume", request.session_id]
    # L'echelle de reflexion devient un budget de tokens — l'asymetrie reste
    # dans l'adaptateur, le roster n'en sait rien.
    extra_env = ({"MAX_THINKING_TOKENS": str(THINKING_TOKENS[request.thinking])}
                 if request.thinking in THINKING_TOKENS else None)
    # Le prompt part par stdin, PAS dans argv : c'est un mode documente de
    # claude -p, et le seul qui survive aux shims .cmd de Windows.
    proc = _spawn(cmd, request, extra_env, stdin_text=request.prompt)

    if proc.returncode != 0:
        # Le JSON de stdout d'abord, stderr ensuite : les avertissements de
        # claude (« Ignoring … ») ne doivent pas masquer la vraie cause.
        evidence = _claude_evidence(proc.stdout, proc.stderr)
        raise HarnessError(f"claude a rendu {proc.returncode} : {evidence}")
    try:
        payload = json.loads(proc.stdout)
    except json.JSONDecodeError:
        raise HarnessError("claude n'a pas rendu l'objet JSON attendu "
                           "(--output-format json)") from None
    return HarnessResult(text=str(payload.get("result", "")),
                         session_id=str(payload.get("session_id", "")),
                         cost_usd=float(payload.get("total_cost_usd") or 0.0),
                         returncode=proc.returncode)


# Le registre des adaptateurs. Un harnais de plus = une fonction + une ligne.
ADAPTERS = {"pi": _run_pi, "claude": _run_claude}


if __name__ == "__main__":
    # La gate du module — zero token, sans pi ni claude : la route et le coffre.
    # Lancer depuis la racine : uv run python -m adws.adw_modules.harness
    request = HarnessRequest(prompt="ping", model="z-ai/glm-5.3", tools=("read", "bash"))
    assert _route(request.model, request.direct) == "openrouter/z-ai/glm-5.3"
    direct = HarnessRequest(prompt="ping", model="zai/glm-5.3", direct=True)
    assert _route(direct.model, direct.direct) == "zai/glm-5.3"

    # La route directe (17bis) : un profil hors passerelle envoie l'identifiant tel quel.
    assert _route("deepseek/deepseek-v4-flash-0731") == "openrouter/deepseek/deepseek-v4-flash-0731"
    assert _route("deepseek/deepseek-v4-flash-0731", direct=True) == "deepseek/deepseek-v4-flash-0731"
    assert _route("zai/glm-5.3", direct=True) == "zai/glm-5.3"

    # Le coffre (17bis) : sans profil, tout .env passe ; avec profil, seul le profil passe —
    # et ce que le port ou l'ADW pose lui-meme pour la phase passe toujours.
    base = {"PATH": "/usr/bin", "OPENROUTER_API_KEY": "sk-or-env", "ZAI_API_KEY": "zai-env",
            "TERM": "xterm"}
    vault = {"OPENROUTER_API_KEY", "ZAI_API_KEY"}
    legacy = node_environment(base, {"MAX_THINKING_TOKENS": "8192"}, None, vault)
    assert legacy["OPENROUTER_API_KEY"] == "sk-or-env" and legacy["ZAI_API_KEY"] == "zai-env"
    node = node_environment(base, {"MAX_THINKING_TOKENS": "8192"}, {"OPENROUTER_API_KEY": "sk-or-env"}, vault)
    assert node["OPENROUTER_API_KEY"] == "sk-or-env" and "ZAI_API_KEY" not in node
    assert node["PATH"] == "/usr/bin" and node["TERM"] == "xterm" and node["MAX_THINKING_TOKENS"] == "8192"
    session = node_environment(base, None, {}, vault)     # regime session : rien a injecter, coffre ferme
    assert "OPENROUTER_API_KEY" not in session and "ZAI_API_KEY" not in session
    exported = node_environment(base, None, {}, set())    # une variable de l'environnement reel reste a vous
    assert exported["OPENROUTER_API_KEY"] == "sk-or-env"

    print("harness OK — route par la passerelle et route directe ; coffre .env ferme sous profil, "
          "ouvert sans ; ce que le port pose pour la phase passe toujours")

Pièce — adws/adw_scout.py

Cette version remplace celle du chapitre 11. Le scout ne change pas. C’est la fabrique agent_action, que adw_plan.py et adw_build.py importent, qui passe désormais au port le profil résolu de l’agent et sa route (auth=agent.auth.credentials(), direct=agent.auth.direct). Vos ADW de plan et de build en héritent sans changer d’une ligne. Deux boutons d’essai s’ajoutent : --auth <profil> et --model <route>, pour essayer un profil déclaré sans toucher au roster. Un profil inconnu ou une variable absente s’arrêtent avant tout jeton, avec leur motif.

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml"]
# ///
"""adw_scout — la reconnaissance en lecture seule.

Usage :
    uv run adws/adw_scout.py "Ou vivent les tests de Plume ?" [--config ...] [--retries 2]
    uv run adws/adw_scout.py "..." --auth glm-plan --model zai/glm-5.3   # un autre profil, pour CE run

Le premier ADW sous roster : le scout est declare dans factory.config.yaml,
le port traduit son profil en drapeaux, et l'etat des lieux verifie qu'il
n'a rien change. Sa sortie est une ScoutEnvelope : des findings types.

Version 17bis : la fabrique agent_action passe au port le PROFIL
D'AUTHENTIFICATION de l'agent, resolu par le roster (noms et valeurs des
variables — jamais dans le prompt, jamais dans le YAML) et sa route. Le port
ferme alors le coffre .env : le noeud ne recoit que ce que son profil nomme.
Deux boutons d'essai — --auth <profil> et --model <route> — pour essayer un
profil declare sans toucher au roster. adw_plan.py et adw_build.py heritent
du profil de leur agent sans changer d'une ligne.
"""
import argparse
import json
import sys
import uuid
from dataclasses import asdict, dataclass, field, replace
from pathlib import Path

from adw_modules import envelopes, harness, permissions, roster
from adw_modules.envelopes import Envelope, EnvelopeError
from adw_modules.permissions import PermissionBreach
from adw_modules.runner import PhaseFailure, PhaseSpec, Run


@dataclass(frozen=True)
class ScoutEnvelope(Envelope):
    """Ce que le scout doit au code : des trouvailles nommees, ou rien."""
    findings: list = field(default_factory=list, metadata={
        "ask": "liste d'objets {'file': chemin exact, 'note': pourquoi il compte}, [] sinon"})

    def check(self) -> None:
        super().check()
        for finding in self.findings:
            if not isinstance(finding, dict) or not finding.get("file"):
                raise EnvelopeError(
                    "chaque finding doit etre un objet avec au moins un champ 'file'")


SCOUT_BRIEF = """Tu es le scout de l'usine : trouve ou vivent les choses, ne change RIEN.
- Lecture seule : cherche, lis, lance des commandes de lecture — n'ecris jamais dans le repo.
- Cite des chemins exacts, avec un indice de ligne quand c'est utile.
- Ecris tes trouvailles en markdown dans {report} pour l'agent qui te suivra,
  et declare ce fichier dans 'artifacts'.
- Ne rien trouver est un resultat valide : dis-le simplement."""


def agent_action(phase_name, agent, make_ask, expected):
    """Fabrique l'action d'une phase agent sous roster.

    L'AgentSpec fournit le harnais, le modele, le thinking et les outils ;
    le port les traduit en drapeaux. La demande part a la tentative 1, la
    correction motivee ensuite — dans la MEME session, comme au chapitre 9.
    """
    last_motif = "enveloppe invalide"

    def action(run: Run, attempt: int):
        nonlocal last_motif
        ask = make_ask(run) if attempt == 0 else envelopes.correction(last_motif, expected)
        request = harness.HarnessRequest(
            prompt=ask, session_id=run.sessions.get(phase_name),
            model=agent.model, thinking=agent.thinking, tools=agent.tools,
            # Le profil, resolu ICI, cote runner : le noeud n'emporte que ses
            # variables, le coffre .env reste ferme pour lui (17bis).
            auth=agent.auth.credentials(), direct=agent.auth.direct)
        try:
            result = harness.run(agent.harness, request)
        except harness.HarnessError as error:
            raise PhaseFailure(str(error)) from None
        # Memoriser la session AVANT de valider : un retry doit la poursuivre.
        run.sessions[phase_name] = result.session_id
        run.cost_usd += result.cost_usd
        try:
            return envelopes.parse(result.text, expected)
        except EnvelopeError as error:
            last_motif = str(error)
            raise PhaseFailure(f"enveloppe invalide : {error}") from None
    return action


def constat(phase_name, factory):
    """Phase code : le constat d'entree — et le dossier de run du runtime."""
    def action(run: Run, attempt: int):
        Path(factory.data_dir, "runs", run.adw_id).mkdir(parents=True, exist_ok=True)
        return permissions.snapshot(".")
    return action


def perimetre(phase_name, agent, factory):
    """Phase code : l'etat des lieux de sortie — la breche tue le run."""
    def action(run: Run, attempt: int):
        try:
            return permissions.enforce(".", agent, factory,
                                       run.results[f"constat_{phase_name}"])
        except PermissionBreach as breach:
            # Une breche n'est pas une gate : l'ecriture a deja eu lieu, on ne
            # re-prompte pas — la phase code meurt, et le run avec elle.
            raise PhaseFailure(str(breach)) from None
    return action


def report_path(factory, run: Run) -> str:
    return f"{factory.data_dir}/runs/{run.adw_id}/scout_findings.md"


def scout_ask(prompt, factory):
    """La mission du scout : le brief, votre demande, le contrat — dans cet ordre."""
    def make_ask(run: Run) -> str:
        return (SCOUT_BRIEF.format(report=report_path(factory, run))
                + f"\n\n### mission\n\n{prompt}\n\n"
                + envelopes.contract(ScoutEnvelope))
    return make_ask


def dispose(run: Run, attempt: int) -> ScoutEnvelope:
    """Phase code : le code dispose — verdict deterministe, zero token."""
    envelope: ScoutEnvelope = run.results["scout"]
    if envelope.status != "success":
        raise PhaseFailure(f"le scout declare lui-meme un echec : {envelope.summary!r}")
    print(json.dumps(asdict(envelope), indent=2, ensure_ascii=False))
    return envelope


def main() -> int:
    parser = argparse.ArgumentParser(
        description="La reconnaissance en lecture seule, sous roster.")
    parser.add_argument("prompt", help="ce que le scout doit trouver")
    parser.add_argument("--config", default=str(roster.DEFAULT_PATH))
    parser.add_argument("--retries", type=int, default=2)
    parser.add_argument("--auth", default=None,
                        help="un autre profil d'authentification du roster pour CE run — "
                             "le bouton d'essai des profils (17bis)")
    parser.add_argument("--model", default=None,
                        help="le modele pour CE run — un profil a route directe attend une route "
                             "de son fournisseur (zai/glm-5.3), pas un id de la passerelle (17bis)")
    args = parser.parse_args()

    factory = roster.load(args.config)          # zero token : tout echec est gratuit
    agent = factory.agents["scout"]
    if args.auth is not None:
        # Un profil declare dans le roster, resolu avant tout jeton : un profil
        # inconnu ou une variable absente s'arretent ici, avec leur motif.
        profile = factory.profile(args.auth)
        profile.credentials()
        agent = replace(agent, auth=profile)
    if args.model is not None:
        agent = replace(agent, model=args.model)
    run = Run(adw_id=uuid.uuid4().hex[:8])
    return run.execute([
        PhaseSpec(name="constat_scout", kind="code", action=constat("scout", factory)),
        PhaseSpec(name="scout", kind="agent",
                  action=agent_action("scout", agent, scout_ask(args.prompt, factory),
                                      ScoutEnvelope),
                  retries=args.retries),
        PhaseSpec(name="perimetre_scout", kind="code",
                  action=perimetre("scout", agent, factory)),
        PhaseSpec(name="dispose", kind="code", action=dispose),
    ])


if __name__ == "__main__":
    sys.exit(main())

La gate du TP

Depuis la racine de plume-factory. Une commande par ligne, identiques dans bash et PowerShell. Les deux premières ne coûtent rien, la troisième lance un scout ordinaire (profil gateway, harnais pi) pour prouver que rien n’a bougé pour un roster d’avant ce chapitre.

uv run --with pyyaml python -m adws.adw_modules.roster
uv run python -m adws.adw_modules.harness
uv run adws/adw_scout.py "Liste les fichiers de tests de apps/plume et ce que chacun verifie."

Résultat attendu : le roster affiche, pour chaque agent, sa ligne auth : gateway (key, route gateway) — OPENROUTER_API_KEY, puis hotes : openrouter.ai et la ligne profils : 3 regimes resolus (passerelle, direct, pi et claude), gateway seul sans bloc auth, union des hotes ; refus — profil inconnu, variable absente nommee, route inconnue, mount hors depot (x2), valeur dans env, regime inconnu. Le port imprime harness OK — route par la passerelle et route directe ; coffre .env ferme sous profil, ouvert sans ; ce que le port pose pour la phase passe toujours, et le scout rend son enveloppe verte comme au chapitre 11, pour moins d’un centime, 30 à 60 s sur le modèle léger. Pour voir le refus en vrai, sans rien casser : renommez la ligne de .env en OPENROUTER_API_KEY_OFF=… et relancez le scout, qui rend agent 'scout' : profil 'gateway' : variable OPENROUTER_API_KEY absente de .env et de l'environnement en une fraction de seconde, zéro jeton, puis remettez la ligne. Si vous avez un forfait Z.ai : collez le bloc auth: de la fiche en tête de votre roster, ajoutez ZAI_API_KEY à .env, puis uv run adws/adw_scout.py "Ou vivent les tests de Plume ?" --auth glm-plan --model zai/glm-5.3-flash. Zéro centime sur la carte, une part de quota, et dans la trace un assistant dont le modèle est zai/glm-5.3-flash, sans préfixe openrouter/. Variante éco : elle est déjà là, rien dans ce chapitre n’invoque un modèle frontier.


Quiz — teste tes connaissances
Model stack 7 questions Objectif : 5/7 minimum
0/7
bonnes reponses
Objectif non atteint (minimum 5/7 requis).
Remonte relire la fiche memo en pretant attention aux points manques, puis cliquer sur « Recommencer » pour retenter.