Reprise de session — prompt d’amorçage
À quoi sert ce fichier. Le donner tel quel, en entier, à une nouvelle session d’agent (Claude Code ou autre) pour qu’elle reprenne le pilotage exactement là où la précédente s’est arrêtée, sans redécouvrir ce qui a déjà coûté cher à établir.
Il se périme. Il décrit un état daté. La première instruction qu’il donne est de le vérifier contre le dépôt réel. Le mettre à jour à la fin de chaque campagne — c’est le seul entretien qu’il demande.
Dernière mise à jour : 2026-08-08 (nuit),
main@60ca8fca, aucune PR ouverte.
Le prompt
Reprends le pilotage du projet Air là où on s’est arrêté.
Commence par vérifier l’état réel avant toute action — ce document est daté et peut avoir vieilli :
cd ~/Code/air
git log --oneline -5 main && git status --short
gh pr list --state open
python3 scripts/generer-registre-adrs.py --check && python3 scripts/verifier-journal.py
Lis ensuite, dans cet ordre : docs/etat-avancement.md (l’état vivant), docs/INDEX.md (la carte),
et CLAUDE.md (les règles de code, qui priment sur tes habitudes).
État attendu
main @ 60ca8fca, aucune PR ouverte, arbre propre.
Cinq chantiers viennent d’être clos :
-
La demi-fermeture ne ferme plus le canal (#643) — le dernier en date, et le plus instructif. Le « tunnel inverse concurrent » qui perdait des connexions n’était pas une course : les trois pompes serveur émettaient
CHANNEL_EOFetCHANNEL_CLOSEdès qu’une lecture rendait0, c’est-à-dire sur une simple demi-fermeture. RFC 4254 §5.3 réserve le second à la fin du canal. Le client, lui, appliquait la bonne règle depuis toujours. -
Isolation de deux suites
*_root(#644) —authorize_rootetfrom_restriction_rootécrivaient le même chemin fixe et se marchaient dessus en parallèle. Le verrou de fixture devient exigible par le type. -
Épinglage des clés d’hôte nues — le chantier que la version précédente de ce document recommandait en premier.
air-sshn’épinglait rien : sans magasin de CA d’hôtes — le cas nominal — il acceptait n’importe quel serveur. Deux magasins désormais (administré prioritaire, personnel écrit à la première vue), clé changée ou révoquée = refus sans appel, hôte jamais vu = question sur un terminal ou refus. Clôture consignée dans ADR-118 §A6. Le socle existait déjà en entier (air-keystore::hosts, le schéma, le programmeair-keystore known-hosts) : ce n’était qu’un câblage manquant. -
Campagne « config client » — volet client d’ADR-118 :
ForwardAgentpar hôte +-a(#633), keepalive clientServerAliveInterval(#636),ProxyJump(#638), plus l’amendement de clôture d’ADR-118 (#639, FR+EN) qui consigne trois refus argumentés, un report et deux corrections de fait. -
Santé de la CI — trois échecs root-seulement qui rendaient
test-coveragerouge sur toutes les PR (#634), et le plancher de couverture de branches remplacé par une non-régression versionnée (#635). -
Pile
air-launchd+ gate — lanceur et superviseur de services, temps 1 (#631), et le gatecheck-controleursqui rend vérifiable la doctrine d’architecture d’ADR-149 (#640).
Mode de travail
Le superviseur humain (BDFL) travaille sur speedy. La production est déléguée à carbon :
briefs dans ~/briefs/*.md, lancés par ~/run-*.sh (claude -p headless). Carbon héberge aussi
le runner CI — ce qui a des conséquences (voir les pièges).
La cadence, sans exception sur du code sensible :
brief → carbon → revue de sécurité adverse → CI verte → merge → informer
Règles dures : aucun trailer Co-Authored-By / Claude (règle contraignante du projet) ;
auteur = signature GPG + Signed-off-by du BDFL ; main protégée, tout passe par PR.
Agis en autonomie, informe à chaque merge, et ne sollicite le BDFL que pour de vraies décisions — en lui donnant alors tout le contexte, options écartées comprises.
✅ Bornes de ressources — posées (2026-08-09)
Directive BDFL : aucune version ne sort avec des bornes non fixées. Elle est honorée :
pids.max = 2048 et memory.high = 512 Mio s’appliquent par défaut ; un service non
borné n’existe plus, et retirer une borne se demande explicitement (unlimited).
Chiffres mesurés qui les justifient : air-sshd coûte 4,00 tâches et ~819 Kio par
connexion (linéaire, sans fuite ; 1 tâche / 1,5 Mio au repos). Et ce qui n’est pas borné
ailleurs : les connexions établies — DEFAULT_MAX_PREAUTH_TOTAL = 256 ne couvre que la
fenêtre pré-auth, l’AdmissionGuard rendant sa place au succès de l’authentification. C’est
ce qui rend pids.max utile plutôt que redondant.
Reste ouvert, sans blocage : exprimer ces valeurs en fraction de la capacité (systemd :
DefaultTasksMax = 15 % de threads-max) pour qu’elles suivent du Pi 4 au serveur.
Ce qui attend un arbitrage
Plus aucune décision n’attend le BDFL. Les trois qui restaient ont été tranchées le 2026-08-09 (n° 0, 0bis, 0ter). Ne subsiste ci-dessous qu’un flake à qualifier.
Le gate root sur le magasin— TRANCHÉ le 2026-08-09. Aucun gate n’a été relâché : le verbeknown_hostsPERSONNELair-ssh known-hosts add|removeécrit le magasin personnel sous l’identité de son propriétaire,air-keystoregardant le magasin administré derrière son gate. Chaque magasin est désormais derrière l’outil qui a l’identité qu’il faut. Ce qui subsiste, et qui n’est plus bloquant : le gate d’air-keystoreignore toujours--store— s’il devait un jour se prononcer par magasin, ce serait le chemin canonique résolu qui décide, sinon un lien symbolique rouvre l’échappatoire.
0bis. Un additif couche 1 pour créer un répertoire à mode choisi — FAIT le
2026-08-09 (AirFileSystem::make_directory_all, 12ᵉ additif couche 1 non scellé). Le
downgrade est fermé : ~/.config/air/ naît en 0700 quel que soit l’umask. À noter pour
la suite : l’énoncé d’origine était faux — AirFileManager::make_directory existait déjà
et posait un mode ; ce qui manquait était la forme récursive.
0ter. — FAIT le
2026-08-09 (air-launchd A-2 — le lanceur ne tue que l’enfant directAirCommand::new_session, 13ᵉ additif couche 1). Le service est chef de sa
session, donc de son groupe ; l’arrêt vise le groupe. Reste au temps 3 (cgroup) le
petit-fils qui crée sa propre session — une convention que la descendance peut rompre
n’est pas une frontière que le noyau tient.
- Le test keepalive rouge en suite, vert en isolé (2026-08-07)
connection::tests::a_peer_that_answers_the_probes_is_never_disconnectedéchoue en suite complète (108 s) et passe en isolé (3 s, 3/3). Vérifié préexistant : reproduit surmainaprèsgit stashde la branche d’épinglage. Il n’échoue pas à tous les coups — un balayage ultérieur l’a vu vert — donc il est sensible à la charge. Même piège que le tunnel inverse : chercher d’abord une échéance trop serrée dans le test, pas un défaut de la machine.
Ensuite : ADR-119 (extensions SFTP — coûte +1 additif couche 1, statvfs), ADR-120
(auth hostbased — amène le service privilégié air-keysign, lourd), ADR-121 (provisioning).
Puis la décision de re-sceau des couches 0/1 :
docs/notes/inventaire-additifs-couches-avant-re-sceau-fr.md — 11 additifs couche 1, tous
strictement additifs (donc mineur v3.9, pas le majeur d’ADR-123), et 1 additif couche 0
(fchmodat2) resté descellé sans tag depuis le 2026-07-30.
Pièges établis à la dure — ne pas les redécouvrir
Sur les tests
- Les suites
*_rootpassent en 0,02 s hors root : elles sautent par un chemin précoce en rapportant « ok ». En root : 44 s. Regarder la DURÉE, jamais le seul verdict. Prérequis : root +ssh+ comptes_air-sshdetair-t-session(présents sur speedy et carbon). cargo testn’annonce qu’un binaire fautif à la fois. Corriger le premier révèle le suivant : balayer tous les binaires en root plutôt qu’attendre que la CI les livre un par un.- Le pire profil de défaut : échec en suite complète, succès en isolé. Deux occurrences (partage d’un chemin fixe entre tests parallèles, contention). C’est ce qui fait conclure au flake et passer à autre chose.
- Un test peut passer parce que son montage est trop rapide. Une cible qui répond à mesure
qu’elle lit fait doubler la demi-fermeture par la réponse : le canal a beau être fermé à tort, les
octets sont déjà passés. Toute la famille des tests de redirection avait ce montage — c’est ce qui
a caché un
CHANNEL_CLOSEde trop pendant des mois. Faire répondre la cible après la fin de flux est ce qui ouvre la fenêtre. - Nos propres clients sont tolérants ;
ssh(1)ne l’est pas. Un défaut de protocole du serveur peut être invisible face à notre client et fatal face à un vrai client OpenSSH. Quand ce qui est éprouvé est une règle du protocole, le pair du test doit êtressh(1). - Sur speedy, les suites qui lancent un vrai
sshdéchouent EN ROOT : la machine n’a pas de serveur OpenSSH installé, donc pas de/run/sshd, etsshdrefuse de démarrer (« Missing privilege separation directory »). Hors root il n’en a pas besoin, d’où un profil « vert en utilisateur, rouge en root » qui n’a rien à voir avec le code. Remède :install -d /run/sshd(tmpfs — à refaire après chaque redémarrage). - Le compte
air-t-authzconditionne huit suites*_root— sans lui elles sautent en rapportant « ok » en 0,00 s. Carbon l’a ; speedy ne l’avait pas, et le provisionner (cf. l’en-tête deremote_forward_root.rs) a immédiatement révélé un flake latent que la CI franchissait par chance (#644). Provisionnement :useradd --system --shell /usr/sbin/nologin --home-dir /var/lib/air-t-authz --create-home air-t-authz. - Le confinement par cgroup n’est PAS éprouvé en CI. Le job tourne dans un cgroup dont
l’écriture est refusée (
EACCES, même en root — runner conteneurisé) : tous les tests d’air-cgroupet le test de descendance échappée du lanceur y sautent. Ils s’exécutent sur speedy (et sur toute session utilisateur, systemd déléguantuser@<uid>.service). Corollaire à ne pas oublier : ce que le temps 3 ajoutera de cgroups devra être éprouvé hors CI, et une PR verte ne dit rien de cette partie-là. cargo test --workspace --libne compile PAS les tests d’intégration des autres crates. Après tout changement d’une structure publique, seulcargo check --workspace --all-targetsdit la vérité : unServiceSpecélargi a fait rougir la CI sur un test d’air-launch-temoinque trois balayages locaux n’avaient jamais compilé.- Prouver qu’un test mord : retirer le correctif, vérifier que le test rougit, remettre. Cette
discipline a produit les meilleures preuves de ces campagnes (blocage à 2 068 992 octets sans
recrédit de fenêtre ; échec en 20,01 s sans
O_NONBLOCK; code 134 → 0 sur l’EPIPE).
Sur la couverture
- La mesure de branches se fait en
--lib:src/bin/**y est invisible, et un module de lib exercé seulement par des tests d’intégration compte comme non couvert. - Le gate est une non-régression contre
xtask/couverture-reference.toml. La référence se relève en CI uniquement — l’écart local ↔ CI atteint ~0,3 point. Ne jamais y poser une mesure locale, et ne jamais la baisser à la main. - Un job
test-coveragevert en mode par couche ne prouve pas que la mesure a abouti : l’échecllvm-covn’y est qu’unWARN, et les branches n’y sont pas mesurées. - Un
cargo fmtrouge masque les jobs suivants : le gate de couverture ne rend alors aucun verdict, et on croit la couverture bonne alors qu’elle n’a pas été mesurée.
Sur les schémas binaires
- Cap’n Proto n’admet aucun trou d’ordinal : « réserver » un id par commentaire est une promesse que le format ne peut pas tenir.
- Patron du zéro : le membre
absent/inheritva en premier (zéro du discriminant), pour qu’un artefact ancien relise « absent ». Référence :MatchRule.forceCommand,crates/air-sshd-schema/schema/sshd-config.capnp:410.
Sur git et GitHub
- Merger avec
--delete-branchune PR qui sert de base à une autre ferme la seconde, et GitHub refuse ensuite de la rouvrir. Vérifiergh pr view <n> --json baseRefNameavant de supprimer une branche.
Sur la délégation à carbon
- Il rend parfois son rapport en anglais : le rappeler dans chaque brief.
- Il lui est arrivé de ne pas committer avant la fin du launcher — travail intact mais non
poussé, à rapatrier par
scpdugit diff. Exiger « committe et pousse tôt », et toujours vérifier l’état de son arbre avant de conclure qu’un run a échoué. - Le launcher coupe à ~600 s sans sortie : un run long peut finir « en l’air » alors que le travail est fait.
Ce qu’on a appris sur la méthode
Une revue attaque ce que le code promet, pas seulement ce qu’il fait. Le défaut le plus grave
de ces campagnes — l’absence totale d’épinglage de clé d’hôte — n’était pas un bug du code écrit :
c’est que ProxyJump revendiquait une résistance au bastion compromis que le reste du système
ne tenait pas. Trois fois de suite, le motif s’est répété : le mécanisme était bon, c’est la phrase
qui promettait trop. Une revendication de sécurité non adossée au code est plus dangereuse que
son absence — elle fait cesser la vigilance.
Un gate qui ne nomme personne dérive. Le plancher de branches à 78 était devenu inatteignable dans tous les environnements sans que personne le voie, parce qu’un agrégat sur ~70 crates ne désigne aucun coupable. Il disait « 77,4 < 78 » et laissait chercher.
Un test peut certifier un trou. plain_host_key_defers_to_tofu_path passait au vert depuis
des mois : il vérifiait que le code déléguait la décision, sans jamais vérifier que quelqu’un
la recevait. Le chemin auquel il déférait n’existait pas. Un test qui constate un renvoi et
s’arrête là ne prouve rien — il rassure. Chercher, pour toute délégation, où est le consommateur.
Le socle est parfois déjà là. Le chantier annoncé comme « le plus grave » s’est révélé être un
câblage manquant, pas une brique manquante : air-keystore::hosts, le schéma binaire et le
programme de gestion existaient en entier. Avant d’estimer un chantier, chercher ce qui est déjà
écrit — l’écart entre « rien n’existe » et « rien n’est branché » change tout.
--no-fail-fast est le bon réflexe de balayage. cargo test s’arrête au premier binaire
fautif ; corriger celui-là révèle le suivant, un par exécution de dix minutes. cargo test --no-fail-fast les donne tous d’un coup.
Vérifier avant de flipper un statut. Passer ADR-118 à fait a révélé que son volet client
n’existait nulle part — ni là où on le croyait, ni là où il avait été « rattaché ».
Dire ce qu’on n’a pas fait. Les campagnes ont produit autant de valeur par leurs refus
argumentés (IdentitiesOnly déjà vrai par construction, CanonicalizeHostname et Match client
écartés, ProxyCommand reporté) que par leur code. Un ADR appliqué à moitié en silence devient
un document dont plus personne ne sait ce qu’il engage.