Pieges connus (a ne pas re-decouvrir)
Enseignements durables tires de sessions de developpement reelles : chaque entree correspond a un bug ou un comportement contre-intuitif qui a reellement coute du temps, avec ce qu'il faut savoir pour ne pas y retomber.
A lire avant d'attaquer un composant que l'on ne connait pas. Les recits complets de diagnostic sont dans
../archive/PROGRESS-2026-08.md.Convention : on n'ajoute ici que ce qui reste vrai apres la session — un piege structurel, pas le journal d'un incident.
- Un
Option<String>destatussansskip_serializing_ifest EFFACE par un JSON merge patch.image_digest/snapshot_digestsont ecrits parimage-builder/vm-supervisor, mais le controller patchestatusen entier : unNonede son cote partait en"imageDigest": null, ce que l'API Kubernetes interprete comme une suppression. Le Workshop restait alors bloque enBuildingImagealors que son image existait dans le cache (environ une fois sur trois builds simultanes). Regle generale : tout champ destatusecrit par un AUTRE composant doit porterskip_serializing_if = "Option::is_none", et les chemins de reconciliation qui ne le calculent pas doivent le reporter tel quel. - Un test qui suffixe son namespace/ServiceAccount en
-testne s'isole pas forcement.ensure_api_server_roleecrit sur un nom de role OpenBao CONSTANT (API_SERVER_ROLE), pas derive de ses arguments : un test croyant s'isoler reecrivait en fait les bindings du role dont depend l'api-serverreel, qui echouait ensuite sur "service account name not authorized" jusqu'au redemarrage du controller — et le symptome apparaissait des heures plus tard, sans lien apparent avec le test. Sur une ressource partagee dont le nom est fixe, provisionner les valeurs de PRODUCTION (idempotent) plutot que des variantes de test. - Un alias interne de
net-proxy(llm-proxy,mcp-gateway,registry,git.atelier.internal) n'existe dans aucun DNS reel. Il n'etait joignable que par un client honorantHTTP_PROXY— le proxy resout alors l'alias sur l'en-teteHost. Tout client qui resout lui-meme son nom d'hote echouait : c'est le cas de Node.js, qui ignoreHTTP_PROXYpar defaut, donc de Claude Code. Le resolveur denet-proxyrepond desormais lui-meme pour ces alias (crates/net-proxy/src/dns.rs). Devant un composant qui n'atteint pas un service interne alors quecurly arrive depuis le meme guest, verifiergetent hosts <alias>avant toute autre piste. - Un modele a tarif fictif fausse toute lecture de la depense. Les
modeles
atelier-*-testsont factures des dollars par requete pour exercer l'application des plafonds sans attendre une vraie consommation. Le 2026-09-01, les journaux LiteLLM affichaient 211,49 $ dont 210,00 $ provenaient de QUATORZE requetes de test a 15 $ piece : la depense reelle etait de 1,49 $. J'ai failli rapporter le premier chiffre. Toute agregation presentee a un humain doit les ecarter — et le DIRE, un montant retire sans etre montre est un montant qu'on finira par ne plus savoir expliquer. - Deux services derriere le meme mot de passe n'acceptent pas la meme
preuve.
ttyd --credentialimplemente un vrai Basic Auth ;code-server --auth passwordl'IGNORE et redirige vers/logintant qu'il n'a pas son cookiecode-server-session, meme face a une requete parfaitement authentifiee en Basic. Le pont de l'api-serverinjectait le meme en-tete pour les deux, et son commentaire affirmait que « les deux exigent ce Basic Auth » — mesure le 2026-09-01 : faux pour code-server. Un302n'est pas un refus visible, la panne ressemblait a une page de login normale. Quand un service parle un protocole d'authentification, le verifier plutot que le supposer :curl -u ...doit rendre200, pas302. - Une note « verifie absent » se perime. La ligne
[~]du plan disait que le devcontainer ne consommait pas/session-auth, sur la foi d'une verification faite dans les CLONES LOCAUX. Le depot amont l'avait fait depuis. Une affirmation sur un depot tiers doit nommer ce qui a ete regarde (« clone local a telle date »), sinon elle se lit comme un fait durable et gele une tache qui n'a plus lieu d'etre. - Un test qui se saute en CI pourrit sans que rien ne le dise. Les tests
qui exigent de la vraie infrastructure (cluster, Postgres, Forgejo) se
sautent d'eux-memes en CI faute de connexion : ils ne deviennent donc
jamais rouges quand le code change sous eux. Constate le 2026-09-01 : sept
d'entre eux etaient casses, dont six par le passage a la propriete par
GROUPE de la veille (jeton sans
groupsni role ->403) et un par l'enrobagecd /workspaces/<repo>des commandes deleguees. La CI etait verte du debut a la fin. Apres tout changement touchant l'autorisation ou la forme des commandes, lancercargo test --workspacesur une machine qui A l'infrastructure — la CI ne peut pas le faire a votre place. - Un binaire de dev laisse tourner corrompt les tests d'integration. Un
target/debug/atelier-controlleroublie en arriere-plan reconcilie TOUS les Workshops du cluster, y compris ceux que les tests viennent de creer : il supprime le pod que le test s'apprete a verifier. Symptome le 2026-09-01 :apply_suspend_then_resumeechouait une fois sur deux en suite complete et passait 3/3 seul — ce qui ressemble exactement a une course dans le code, et n'en etait pas une.pgrep -af target/debugavant de diagnostiquer un test intermittent. cargo clippyne produit pas d'executable. Verifier avec clippy puis relancertarget/debug/<binaire>fait tourner l'ANCIEN binaire : le correctif compile, passe le lint, et ne s'execute pas. Constate le 2026-08-31 sur le confinement de securite — le controller reconciliait toutes les 15 s sans erreur et n'ecrivait jamais la condition attendue, ce qui a envoye chercher la cause du cote du schema du CRD et de l'elagage Kubernetes.cargo build -p <crate>avant tout redemarrage manuel ; en cas de doute, comparer l'horodatage du binaire a celui de la modification.- Une regle d'injection ne sert a rien si la requete ne passe pas par
l'injecteur. Le controller creait bien une Virtual Key par Workshop
(
atelier-wks-<nom>), la plafonnait, l'ecrivait dans OpenBao et generait la regle d'injection — maisnet-proxyaiguillait l'aliasllm-proxyDROIT vers LiteLLM, la ou l'alias Git pointe versidentity-proxy. La cle n'etait donc jamais utilisee : le guest envoyait le jeton statique partage, et le plafond ne contraignait rien. Corrige en reproduisant le montage Git (alias -> identity-proxy +hostAliasvers le ClusterIP du service), ce qui a demande d'exposer LiteLLM sur le port 80 : une microVM ne sort que sur 80 et 443, etidentity-proxyse connecte au port de la requete du guest. Verification qui tranche :kubectl logs <pod> -c identity-proxydoit affichercredential injecte host="llm-proxy". Sans cette ligne, la cle est ignoree quoi qu'en dise la configuration. - LiteLLM facture 0 pour une combinaison provider/modele dont il ignore le
tarif.
anthropic/deepseek-chat(endpoint Anthropic natif de DeepSeek) n'a pas de grille integree :/model/inforenvoieinput_cost_per_token: 0, toute la comptabilite reste a zero et les plafonds de Virtual Key ne se declenchent jamais — en silence, puisque les appels reussissent. Il faut poserinput_cost_per_token/output_cost_per_tokenexplicitement dansmodel_list. Les valeurs a utiliser sont celles que LiteLLM applique deja au meme modele sous son provider natif (deepseek/deepseek-chat), lisibles dans/model/info: aucun chiffre a inventer. Symptome : injection confirmee dans les logs d'identity-proxy, maisspendobstinement a0.000000. A ne pas conclure trop vite : l'absence de clesatelier-wks-*dans/key/listne prouve rien — le nettoyage d'un Workshop les revoque, alors que les clesatelier-build-*survivent. Verifier sur un Workshop VIVANT. - Un flux SSE ne doit pas se fermer sur « le travail est fini », mais sur
« j'ai emis l'evenement final ».
stream_handler(api-server) s'arretait des que la commande etait terminee, quel que soit l'evenement qu'il venait d'emettre. Les branches etant ordonnees stdout -> stderr -> status, un sondage trouvant a la fois de la sortie neuve ET une commande finie — le cas courant — envoyait la sortie puis fermait, sans jamais emettrestatus. Cote client,exitCoderestaitnull:pm-enginecomparaitNone != 0et concluait a l'echec de TOUTE execution, tests verts compris, consommant les trois tours d'auto-correction a chaque run. Ce qui l'a rendu visible : faire figurer le code de sortie dans la trace (exit code Nonesaute alors aux yeux). UnNonesilencieusement traite comme un echec est indistinguable d'un vrai echec — toujours afficher la valeur brute. --permission-mode acceptEditsn'autorise PAS les commandesBash. Il auto-approuve les editions de fichiers, rien de plus. En mode--print(non interactif), personne n'est la pour approuver le reste :git add,git commitetgit pushetaient donc refuses en silence. L'agent produisait un travail complet et correct qui restait en fichiers non suivis dans la microVM, etOpenPullRequestouvrait une PR vide. Le Workshop delegue s'executant dans une microVM Firecracker jetable sans acces reseau hors allowlist, la frontiere de securite est la microVM et non l'invite d'un CLI :DelegateToClaudeCodeutilise doncbypassPermissions. Signature du probleme :git statusdans le Workshop montre?? <fichiers>avec ungit logintact — le travail existe, il n'est simplement jamais entre dans l'index.- Un jeton fige a l'ouverture d'une session MCP expire en cours de
session. Une session Streamable HTTP emet plusieurs requetes HTTP au fil
de sa vie (POST de l'appel d'outil, flux SSE, DELETE de fermeture), et un
noeud comme
DelegateToClaudeCodevit bien plus longtemps qu'un jeton OIDC (300 s par defaut chez Keycloak) : l'api-server repondaitExpiredSignatureau milieu de la delegation.pm_engine.mcp_clientpose desormais l'en-teteAuthorizationpar requete via unhttpx2.Authadosse auOidcTokenProvider(qui cache et renouvelle deja). Allonger la duree de vie du jeton ne fait que deplacer la limite : c'est le rafraichissement qui supprime l'hypothese sur la duree des appels. - Un proxy HTTP ne doit pas reecrire que la PREMIERE requete d'une
connexion. Un client configure avec
HTTP_PROXY(tous les Workshops) garde sa connexion ouverte et envoie toutes ses requetes suivantes en forme absolue sur la meme socket.net-proxyreecrivait la premiere en forme origine puis basculait encopy_bidirectional: les suivantes arrivaient telles quelles auvicorn/LiteLLM, qui repondait404a partir du 2e echange. Symptome : Claude Code repond au premier tour puis echoue sans ecrire aucun fichier (api_error_status: 404,num_turns: 2dans--output-format json) — le PM ouvrait donc des PR vides alors que le Workshop etait sain. Corrige parforward_rewriting(crates/net-proxy/src/proxy.rs), qui boucle sur les requetes et suit le cadrage des corps (Content-Length/chunked). Deux reflexes qui auraient fait gagner la journee :curlne reproduit pas le bug (une seule requete par connexion, donc toujours reecrite) — il faut un client qui enchaine ; etclaude --output-format jsondonne le code d'erreur HTTP reel, la ou la sortie texte ne montre qu'un message trompeur. - Le message de Claude Code "There's an issue with the selected model … it
may not exist or you may not have access to it" ne dit pas ce qu'il pretend.
Il s'affiche aussi quand le modele est parfaitement valide et que la panne
reelle est ailleurs (API injoignable, DNS). Il apparait meme lors des
executions qui reussissent. Ne jamais l'utiliser comme diagnostic : verifier
d'abord qu'un appel HTTP direct a
$ANTHROPIC_BASE_URLaboutit depuis le guest. - Un
curlqui reussit ne prouve pas qu'un autre client reussira dans la meme microVM :curlhonoreHTTP_PROXY, la plupart des runtimes applicatifs non. C'est precisement ce qui a masque le piege ci-dessus pendant toute une session — l'appel de verification passait, l'application echouait. - Un
suspend/resumerestaure le filesystem du guest depuis le snapshot : toute modification faite a la main dans la microVM (y compris un/etc/hostsbidouille pour un test) survit au cycle et contamine les mesures suivantes. Repartir d'un Workshop neuf, ou nettoyer explicitement, avant de conclure qu'un correctif fonctionne. - Toute regle
iptablesde la microVM se termine par unDROP: un port ouvert cote hote mais absent de la liste passee aenable_transparent_gatewayest jete silencieusement. Comme c'est unDROPet non unREJECT, le client expire sur son timeout — le symptome ressemble a de la lenteur, jamais a un blocage. Devant un guest lent au boot, verifier la chaine AVANT de soupconner une latence. - Pour observer un guest dont ni
ttydnisshdne repondent, mettreRUST_LOG=atelier_firecracker=debugsurvm-supervisor: la console serie du guest est deja drainee dans ses logs (drain_console_pipes). C'est le seul canal d'observation qui ne depend d'aucun service du guest. - Les scripts de recuperation de credentials du devcontainer sortent en
exit 0en cas d'echec comme en cas de succes (repli deliberement silencieux, pour ne jamais demarrer un service sans authentification) :systemctl statusaffiche doncOK Finishedsur un repli. Ne jamais en deduire que le fetch a reussi — verifier le marqueurstderr. - Une valeur qu'un composant utilise pour lui-meme ET propage a un pod doit
etre dedoublee des lors que ce composant peut tourner hors cluster : en
dev, le controller pointe sur un port-forward (
127.0.0.1:...) qui ne designe rien depuis un pod. Corrige pour OpenBao (pod_addr) ; le meme probleme subsiste pourATELIER_LLM_PROXY_ADDR. - Ne JAMAIS construire une commande shell avec
json.dumps: les guillemets doubles laissentbashinterpreter backticks,$(...)et$VAR.shlex.quote(guillemets simples) est la seule forme sure — d'autant que les prompts du PM contiennent du texte issu du corps d'un ticket, entree non fiable. atelier_mcp_sessionfige son jeton OIDC a l'ouverture : une session MCP tenue plus de quelques minutes finit enExpiredSignature. Toute boucle d'attente doit rouvrir une session courte a chaque iteration.create_workshop(MCP) est asynchrone et laisseegressAllowlistvide par defaut. Un appelant qui cree des Workshops doit donc (a) fournir l'allowlist, sans quoi le build d'image ne peut jamais aboutir, et (b) attendre la phaseRunningavant toutexec_in_workshop.- Le nom du modele par defaut de Claude Code change a chaque version du CLI
et LiteLLM ne le connait pas : Claude Code sort alors en erreur sans
ecrire aucun fichier, ce qui se traduit par des PR vides et aucun
message explicite. Epingler le modele cote appelant (
--model) et garder le wildcard LiteLLM en repli. - Un LLM encadre frequemment sa reponse JSON dans un bloc markdown malgre
une consigne "UNIQUEMENT du JSON" : retirer les delimiteurs avant
json.loadsplutot que durcir le prompt. -
Une microVM ne peut sortir que sur les ports 80 et 443 (redirection transparente) ; tout autre port de destination est jete. Pour joindre un service interne au cluster depuis un guest, l'exposer sur le port 80.
-
fctools0.6.0/0.7.0-alpha.2 ne compilent pas avec seulement les featuresvm+jailed-vmm-executor+tokio-runtime+nix-syscall-backend: il faut aussidirect-process-spawner. /tmpesttmpfs,nodevsur la plupart des distros : un jail Firecracker qui y est enracine a des device nodes inertes, et le message d'erreur Firecracker ("configure the ACL") est trompeur. Utiliser unchroot_base_dirsur un filesystem sansnodev(/var/tmpen dev).- Pour un snapshot
fctools, les ressourcesProduced(ex: fichiers de snapshot) doivent recevoir uninitial_pathrelatif au jail (/snapshot.state), pas un chemin hote absolu — contrairement aux ressourcesMoved, qui se resolvent automatiquement. Vm::shutdown()defctoolsrenonce acleanup()si la VM n'est pas Paused/Running au moment de l'appel (ex: rootfs de test qui s'est deja eteint tout seul) ; il faut appelercleanup()inconditionnellement pour eviter de laisser trainer un jail orphelin.- Un patch JSON merge partiel sur
statusechoue en 422 si l'objet n'a encore aucun statut : le tout premier write de statut doit toujours etre complet. ENVBUILDER_GIT_CLONE_REFn'existe pas ; la revision s'encode dansENVBUILDER_GIT_URLsous la forme<repo>#<revision>.ENVBUILDER_DEVCONTAINER_JSON_PATHest relatif aENVBUILDER_DEVCONTAINER_DIR(.devcontainerpar defaut), pas a la racine du depot — source d'un bug de chemin double (.devcontainer/.devcontainer/...) avant correction.- Compiler un binaire avec une image
rust:*-bookwormpuis le faire tourner dansdebian:bookworm-slimcasse sur un mismatch de version glibc ; il faut un build multi-stage avec la meme base pour build et run. ip tuntap add <nom> mode tapechoue avec un message trompeur (argument "<nom>" is wrong: "mode"/"dev"/"name" not a valid ifname, quel que soit l'ordre des arguments) des que<nom>depasse 15 caracteres (IFNAMSIZ Linux = 16 octets, terminateur nul inclus) — le message ne mentionne jamais la longueur, seulement un mot-cle de la commande elle-meme, ce qui egare completement le diagnostic.- Pour tester du code necessitant
CAP_NET_ADMIN(creation de TAP, configuration d'IP) sans root ni sudo interactif :unshare --net --map-root-user -- <commande>donne un espace de noms reseau isole avec toutes les capacites necessaires, sans mot de passe. Insuffisant en revanche pour un test qui a aussi besoin d'une vraie route de sortie vers Internet (le namespace isole n'a quelo) — cf. section "Builder microVM". setcap cap_net_admin+eipsur un binaire ne suffit pas toujours : dans un environnement d'agent sandboxe, meme une copie dediee du binaire avec la capability posee peut echouer enOperation not permittedsur le vrai netns (confirme viagetcapcorrect +stracemontrant un simple appel RTNETLINK refuse) — la sandbox elle-meme filtre l'operation independamment des capabilities Linux.docker run --privileged(nouveau netns isole avecCAP_NET_ADMINeffectif + sortie Internet via le NAT Docker par defaut) contourne ce blocage, la ouunshare --netseul n'a pas de route de sortie.- axum 0.8 a change la syntaxe des parametres de route :
:nom(ancienne syntaxe) panique au demarrage avec un message qui suggere{nom}— pas une erreur de compilation, une panique runtime au premierRouter::routeconcerne. jsonwebtoken11 exige d'activer explicitement une feature de fournisseur crypto (rust_cryptoouaws_lc_rs) ; sans ca, toute operation JWK panique au runtime avec un message qui explique le probleme (pas d'erreur de compilation) —rust_cryptoevite une dependance a un compilateur C.jsonwebtoken::jwk::Jwk::from_encoding_key(&EncodingKey, Algorithm)derive directement les parametres publics (n/e pour RSA, x/y pour EC) depuis une cle privee : pratique pour construire un JWKS de test reel (vraie paire de cles, vraie signature) sans dupliquer la logique d'encodage/decodage a la main.docker run --privileged --network host --device=/dev/kvm --device=/dev/net/tunest une alternative viable a un accessudointeractif reel pour les tests necessitant a la foisCAP_NET_ADMIN(creation de TAP) ET une vraie sortie reseau vers des services de l'hote (net-proxy, registre OCI de dev) : contrairement a undocker run --privilegedisole (NAT Docker, netns separe),--network hostpartage directement le netns de l'hote — le TAP cree dans le conteneur est immediatement visible et routable depuis l'hote, sans configuration NAT/forwarding supplementaire. Compiler avec unCARGO_TARGET_DIRdedie dans ce conteneur (meme piege glibc que plus haut).sockaddr_un.sun_path(Linux) est limite a 108 octets : un chemin de jail Firecracker trop long (noms de test verbeux repetes dans le chemin, ex:{chroot_base_dir}/firecracker/{jail_id}/root/run/firecracker.socket) fait echouerconnect()enENAMETOOLONGa chaque tentative — silencieux et non exploitable si le code appelant (icifctools0.7.0-alpha.2,Vm::start) avale l'erreur dans une boucle de retry sans jamais la remonter (voir aussi le bullet suivant). Choisir des noms de jail/dossier de travail courts dans les tests.fctools0.7.0-alpha.2 : la boucle d'attente du socket API dansVm::start(src/vm/mod.rs:244,loop { if client.get(...).await.is_ok() { break } }) ne cede jamais la main a l'executeur async siclient.get()echoue de facon synchrone a chaque iteration (ex:ENAMETOOLONGsur le chemin du socket) — elle tourne alors indefiniment a 100% CPU sur un seul thread, empechant meme letimeout()englobant de se declencher (aucune erreur ni panic, juste un blocage total). A diagnostiquer viagdb -p <pid> -batch -ex btsur le thread actif (passtrace, qui ne voit rien d'utile si le blocage est purement en espace utilisateur sans syscall bloquant) : la pile remonte directement jusqu'a la ligne fautive.- Une image OCI convertie en
rootfs.ext4brut (crane export+mke2fs, sans passer par un runtime de conteneur au boot) perd toute sa metadonneeENV/ENTRYPOINT/etc. — un guest qui boote directement ce filesystem (init custom en PID 1, cas deatelier-builder-vm-init) doit re-fournir explicitement toute variable d'environnement dont un binaire de l'image a besoin (ex:KANIKO_DIRpourenvbuilder), meme si leDockerfilesource la definit viaENV. golang.org/x/net/http/httpproxy(utilise par le client HTTP standard de Go, donc parenvbuilder) exclut inconditionnellementlocalhostet les IP loopback du proxy configure viaHTTP_PROXY/HTTPS_PROXY— meme sansNO_PROXY, comportement non desactivable depuis l'environnement. Un service passe commelocalhost:<port>a un process qui doit y acceder via un proxy explicite (ex: guest microVM sans route par defaut) doit plutot recevoir une adresse non-loopback (IP reelle de l'hote/du lien point-a-point).reboot(RebootMode::RB_POWER_OFF)(cratenix) n'a aucun effet observable dans une microVM Firecracker minimale sans ACPI (pci=offdans lesboot_args) : le noyau n'a pas de handlerpm_power_offa invoquer et se contente d'unhalt("reboot: System halted"), sans que Firecracker detecte la fin de la VM. Avecreboot=kdans lesboot_args(deja necessaire pour ce type de machine minimale), c'estreboot(RebootMode::RB_AUTOBOOT)(reboot standard, pas power-off) qu'il faut appeler : il declenche un reset via le controleur clavier i8042 que Firecracker intercepte lui-meme comme signal de fin de VM.- Un
JobKubernetes n'est marque termine que lorsque tous sescontainers[](pas sesinitContainers[], sidecars natifs compris) ont fini avec succes : un sidecar long-vivant (ex:net-proxy, jamais de code de sortie) doit etre declare commeinitContaineravecrestartPolicy: "Always"(K8s >= 1.28/1.29, KEP-753 "sidecar containers") et non danscontainers[], sous peine d'unJobqui resteRunningindefiniment meme apres la fin reelle du conteneur principal. COPY --from=<stage> /dans unDockerfilemulti-etapes aplatit l'integralite du filesystem de ce stage sans passer par un registre OCI : equivalent local acrane export+tar xf(utilise ailleurs dans ce projet pour aplatir une image deja poussee), mais inutile ici puisque le contenu est deja disponible localement dans le build multi-etapes — simplifie significativement la construction d'un rootfs bootable a partir d'un stage Docker intermediaire (voircrates/image-builder/Dockerfile).- Une image Docker rechargee dans
kind(kind load docker-image) ne signale jamais qu'elle est perimee : reconstruire le code sans reconstruire ET recharger l'image dans le cluster produit des echecs silencieux et deroutants (le nouveau code n'est simplement jamais execute). Toujours verifierdocker images <nom>/ refairekind loadapres tout changement de code affectant une image utilisee en cluster. fctools0.7.0-alpha.2 :VmConfigurationDatane derive queSerialize(pasDeserialize), etVm::restoreexige unVmsource vivant dans le meme process (sonResourceSystemfournit les ressources a recopier) — cette API n'est pas concue pour un snapshot qui doit survivre a un redemarrage complet du process appelant. Contournement viable : ne pas serialiserVmConfigurationDatadu tout, la reconstruire a l'identique a partir des memes parametres qu'un boot normal (elle est entierement determinee par eux) — la coherence du chemin virtuel jaile (FlatVirtualPathResolver, base sur le nom de fichier, pas sur l'identite de la ressource) rend cette reconstruction valide sans avoir besoin de l'objet source. VoirVm::restore_persisted,crates/firecracker/src/vm.rs.- Le
controller, lance depuis un poste de dev (hors cluster), ne peut pas joindre une IP de pod kind directement — le reseau du CNI (10.244.0.0/16) n'est pas route vers l'hote par defaut.docker run --network container:<nom-du-noeud-kind>(partage le netns du noeud, qui lui route bien vers ses propres pods) contourne ce blocage pour du test manuel — meme categorie de contournement que celui deja documente pourCAP_NET_ADMIN/la microVM builder plus haut. En production, lecontrollertournant dans le cluster (pas encore le cas dans cette session de dev), ce probleme ne se pose simplement pas. - Un
JobKubernetes ne supprime pas son pod a la fin (Complete) — le pod reste visible (Terminatingpuis disparait apres la periode de grace par defaut, ~30s) : un controller qui interroge ce pod pendant cette fenetre (ex: pour un appel de controle avant liberation) peut le voir "encore la" alors que son process principal a deja fini — prevoir l'idempotence/tolerance a un second appel plutot que supposer qu'un seul suffira. - Le parametre de boot noyau Linux standard
ip=<client-ip>:<server-ip>: <gw-ip>:<netmask>:<hostname>:<device>:<autoconf>(autoconfiguration IP, cf.Documentation/admin-guide/nfs/nfsroot.rst) configure une interface et sa route par defaut avant que l'init du guest ne demarre, sans aucune cooperation de celui-ci — le bon outil pour donner une IP a une VM dont on ne controle pas l'init (contrairement a la microVM "builder", qui a son propre init personnalise et peut donc configurer son reseau elle-meme en espace utilisateur). - Chaque nouvelle image Docker qui commence a faire du reseau/pare-feu doit
explicitement installer
iproute2/iptables— l'oubli est facile (image-builderpuisvm-supervisoront chacun ete oublies une fois cette session) et le symptome (lancement de ip: No such file or directory, ou pire unCrashLoopBackOffsans message clair au premier coup d'oeil) ne pointe pas immediatement vers la cause. jsonwebtoken::Validation::new()activevalidate_aud: truepar defaut : sansset_audience(...)explicite, n'importe quel token portant unaud(le cas de tout token OAuth2 reel emis par Kanidm) est rejete enInvalidAudience— invisible avec des JWT de test synthetiques qui n'incluent jamaisaud. A ne decouvrir qu'en testant contre un vrai flux OAuth2, jamais avec des tokens fabriques a la main pour les tests.reqwestcompile avec la featurerustls-tlsignore le trust store systeme etSSL_CERT_FILE: contre un service TLS a CA auto-signee (ex: Kanidm de dev), il faut construire unClientavec.add_root_certificate(reqwest::Certificate::from_pem(...))explicitement — un simplereqwest::get()echoue toujours, meme sicurl/le systeme font confiance a cette CA.- Donner a un
Podde test un vrai conteneur planifiable (ex:registry.k8s.io/pause:3.9) fait qu'un vrai kubelet le prend en charge et ecrase toutstatus.podIPpatche manuellement par son adresse CNI reelle des que le conteneur demarre — y compris apres un premierpatch_statusreussi (course avec la reconciliation continue du kubelet). Pour un test qui a seulement besoin de controlerstatus.podIPsans faire tourner de vrai conteneur, fixerspec.nodeNamesur un nom de noeud inexistant : le Pod restePendinga jamais (aucun kubelet ne le reclame), et le patch de statut manuel n'est plus jamais ecrase. tonic-build0.14 a deplace toute la generation de code liee aprostvers un crate separe,tonic-prost-build(tonic_build::configure()n'existe plus) — cote runtime, le crate correspondant esttonic-prost(pas seulementprost).tonic-prost-build(commeprost-build) invoque le binaire externeprotocau moment du build ; sur une machine sansapt/sudo, un binaire precompile (github.com/protocolbuffers/protobuf/releases) pointe via la variable d'environnementPROTOC(.cargo/config.tomllocal, non commite car specifique a la machine) fonctionne tout aussi bien.- Un
DaemonSetqui bind-mounte tout/devde l'hote (au lieu de devices individuels) casse le mount par defaut de kubelet sur/dev/termination-log(CrashLoopBackOffimmediat, "read-only file system" au moment de creer ce point de montage) : toujours monter les devices necessaires individuellement (hostPathpar device, typeCharDevice), jamais/deventier. - Pour un device plugin Kubernetes (
/dev/kvmici),securityContext.capabilities.add: [NET_ADMIN]suffit a creer un TAP (ip tuntap add) mais pas a spawner un process dont les capabilities effectives viennent desetcapsur son binaire (ex:jailer, cf. Dockerfilevm-supervisor) : celui-ci echoue en "Operation not permitted" au spawn siSYS_ADMIN/SYS_RESOURCEne sont pas aussi explicitement ajoutees au conteneur — les capabilities de fichier ne peuvent etre elevees a l'exec que si elles font deja partie du bounding set du conteneur, l'ensemble par defaut containerd/Docker (CHOWN,DAC_OVERRIDE,FOWNER,SETUID,SETGID,SYS_CHROOT,MKNOD, etc.) ne les couvrant pas. Un restart de pod qui echoue apres la creation du TAP mais avant que Firecracker demarre laisse ce TAP vivant dans le netns du pod (partage entre redemarrages d'un meme pod, contrairement a un nouveau pod) : le redemarrage suivant echoue alors enEBUSYsur la creation du TAP, masquant completement l'erreur d'origine (SYS_ADMINmanquant) derriere un symptome different — a diagnostiquer avecrestartPolicy: Neverpour capturer le tout premier essai sans bruit de redemarrage. - L'endpoint API
/oauth2/authorisede Kanidm (utilise parget-oauth2-token.sh) exige unAuthorization: Bearerdeja present — confirme en testant reellement :401 Unauthorizedsystematique sans ce header, jamais de redirection vers une page de login. Ce n'est donc PAS l'URL a utiliser pour un flux browser-based classique (un navigateur sans session ne peut pas fournir ce bearer sur une simple navigation) :/ui/oauth2(meme query params) sert la SPA Kanidm, qui gere son propre login+consentement en JS avant de rediriger reellement le navigateur versredirect_uri— c'est cette URL qu'un client OAuth2 browser-based doit cibler, pas l'endpoint API. - Un client OAuth2 public Kanidm (
create-public, PKCE) refuse par defaut touteredirect_uripointant verslocalhost(protection standard contre le detournement d'un client public en local) : necessiteenable-localhost-redirectsexplicite en plus deadd-redirect-url, sans quoi l'echange de code echoue meme avec une redirect_uri par ailleurs correctement enregistree. - Une image devcontainer standard (
mcr.microsoft.com/devcontainers/base) n'a pas de systeme init installe : bootee directement par un noyau (sans runtime de conteneur), le PID 1 retombe sur un/bin/shnu apres l'echec de/sbin/init//etc/init//bin/init— rien ne demarre tout seul,postStartCommandn'etant rejoue que par le CLIdevcontainer. Ajoutersystemd/systemd-sysvet declarer ses propres services comme unites systemd est necessaire pour tout devcontainer destine a booter comme microVM. -
Dans une image devcontainer standard,
systemctl enable <unit>echoue silencieusement (exit 0, aucun symlink cree) : un fauxsystemctly est installe pour eviter que l'installation de paquets.debporteurs d'unites systemd echoue pendant undocker buildclassique (pas de PID 1 systemd a ce moment-la). Contournement : creer soi-meme le symlink d'activation (ln -s ../mon-service.service /etc/systemd/system/multi-user.target.wants/mon-service.service), comme le faitdeb-systemd-helperpour les paquets (qui, lui, n'est pas intercepte). -
Une chaine
iptablesqui filtre par liste blanche de ports de destination jette le trafic retour des connexions qu'on initie soi-meme (le port de destination y est un port ephemere, jamais dans la liste). Il faut un-m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPTen premiere regle, sinon la connexion part mais son SYN-ACK n'arrive jamais et le symptome (Connection timed out) fait chercher du cote du service cible, qui lui va parfaitement bien. - Un serveur Next.js custom n'a pas l'exclusivite de l'evenement
upgrade: Next accroche son propre listener sur notre serveur des la premiere requete HTTP servie (viareq.socket.server), et ce listener faitsocket.end()sur tout chemin qui correspond a une route Next — un Route Handler catch-all suffit. Deleguer explicitement aapp.getUpgradeHandler()ce qui ne nous concerne pas, et neutraliser l'auto-attachement pour le reste. - Un bug qui n'apparait qu'apres la premiere requete HTTP d'un process ressemble a s'y meprendre a de la flakiness. Avant de conclure "intermittent", chercher l'etat du process qui change entre deux essais : ici un test qui reussissait 6/6 sur un serveur fraichement redemarre echouait 2/2 une fois une page chargee.
fetch(undici) decompresse le corps selonContent-Encodingmais laisse cet en-tete etContent-Lengthintacts surresponse.headers. Un proxy qui les relaie tels quels produit unERR_CONTENT_DECODING_FAILEDcote navigateur : les retirer tous les deux.- Un client WebSocket qui a demande un sous-protocole
(
Sec-WebSocket-Protocol, ex.ttypourttyd) ferme la connexion en1006si le101ne le confirme pas. Un proxy WebSocket doit relayer le sous-protocole dans les deux sens, pas seulement les octets. - Tester un pont WebSocket avec un client
wsNode.js ne remplace pas un vrai navigateur : plusieurs bugs de ce lot (fermeture par Next, absence de sous-protocole) ne se manifestaient que via l'<iframe>et le vrai client du service. Un navigateur pilote (Chromium headless, cookies de session injectes) est peu couteux a mettre en place et aurait fait gagner beaucoup de temps. - Cote dashboard,
npm run devesttsx watch server.ts: lancernpx tsx server.tsa la main ne surveille pas le fichier, et on teste alors une version obsolete deserver.tssans s'en rendre compte (plusieurs resultats "incoherents" venaient de la). - En Next.js 16 (App Router + Turbopack + React 19), le mode dev initialise un
debugChannelde debug React dans le client (createFromReadableStream). Ce stream attend la fermeture du canal de debug transitant par le WebSocket HMR (/_next/hmr). Dans un custom server (dashboard/server.ts), déléguer l'upgrade WebSocket viaapp.getUpgradeHandler()au lieu de(app as any).upgradeHandlerbloquait silencieusement la socket HMR : le stream dudebugChannelne se fermait jamais,initialServerResponserestaitpendingindéfiniment, et l'hydratation React (hydrateRoot/useEffect) ne s'exécutait sur aucune page sans lever la moindre erreur en console. Résolu en utilisant(app as any).upgradeHandler(req, socket, head). - Le binaire
claudestandalone (compile Bun,/usr/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe, ~245 Mo) segfault systematiquement dans un Workshop batti surmcr.microsoft.com/devcontainers/python:1-3.12, avec une adresse fautive DIFFERENTE a chaque invocation (0xFFFFFFFFFFFFFFBEavec un prompt,0x0sur--version,0xBBADBEEF— une valeur de poison memoire classique — enchroot). Deux fausses pistes ecartees par la preuve avant la bonne (2026-09-01) : - Pas une corruption reseau/registre :
claude.exeextrait directement des layers OCI du registre (sha256sumcalcule sans erreur) est bit-a-bit identique a celui lu depuis lerootfs.ext4monte en loop sur le noeud kind (memesha256). - Pas une corruption du systeme de fichiers ext4 :
e2fsck -nsur lerootfs.ext4reellement utilise par le Workshop (retrouve dans le PVCatelier-image-cache,/var/local-path-provisioner/.../sha256_<digest du Workshop>/rootfs.ext4sur le conteneur du noeud kind) rapporteclean— aucune corruption de metadonnees. Preuve retenue : le memeclaude.exe, lance enchrootsur cerootfs.ext4monte, EN DEHORS DE TOUTE MICROVM/FIRECRACKER, segfault a l'identique (Bun v1.4.0 ... panic(main thread): Segmentation fault) — sur le noeud kind lui-meme (meme CPU, meme noyau hote que le reste du systeme). Le meme binaire, execute vianode /usr/lib/node_modules/@anthropic-ai/claude-code/cli-wrapper.cjs --version(le launcher Node.js de secours du paquet npm, normalement jamais invoque car le postinstall copie le binaire natif par-dessus) fonctionne parfaitement (2.1.197 (Claude Code), exit 0) dans le MEME chroot. Conclusion : bug du runtime Bun standalone lui-meme dans un environnement restreint (chroot minimal sans/proccomplet au moment du premier essai, microVM Firecracker ensuite) — pas une corruption de donnees ni un probleme de CPU/architecture. Contournement immediat pourpm-engine(non applique) : invoquernode .../claude-code/cli-wrapper.cjsau lieu declaudedansDelegateToClaudeCode, ou reinstaller le paquet npm avec--ignore-scriptspour empecher le postinstall d'ecraser le wrapper par le binaire Bun. Constate en validant reellement le chantier planificateur (ticket greenfield, 2026-09-01). Defaut connexe corrige le meme jour (reste utile independamment de la cause ci-dessus) :DelegateToClaudeCode(pm_engine.nodes) ne verifiait PAS DU TOUT le resultat de son propreexec_in_workshop— le crash restait invisible jusqu'aRunDevcontainerTests, qui echouait sur.devcontainer/test.sh: No such file or directory(symptome trompeur, pointe vers un oubli de l'agent alors qu'il n'a jamais tourne), etAutoCorrectionLooprappelait le meme binaire casse jusqu'a epuiser tout le budget de correction (3 tentatives identiques, aucune ligne de code ecrite). Le noeud echoue desormais immediatement des queexit_code != 0, sans passer par la boucle de correction — une erreur d'environnement ne se corrige pas en reformulant le prompt. Reverifie en reel : la deuxieme tentative de delegation crashe a l'identique mais fait echouer le graphe immediatement, sans les 3 tours de correction inutiles. ensure_image_build_job(crates/controller/src/reconcile.rs) ne cable PASATELIER_GIT_ALIAS_ADDR/ATELIER_LLM_PROXY_ADDR/hostAliasessur le sidecarnet-proxydu Jobimage-builder, contrairement aensure_parent_pod(le pod RUNTIME du Workshop, lui correctement cable) — verifie en lisant les deux fonctions cote a cote. Constate en pratique (2026-09-01) : un Jobimage-builderpointantdevcontainerRepovers le depot cible surgit.atelier.internalechoue avecconnexion directe a git.atelier.internal:3000(net-proxy::upstream::connect, resolution DNS normale, PAS l'alias interne) — la microVM builder clone donc dans le vide et s'eteint en ~3s sans avoir rien construit. Pourquoi ca n'a pas bloque le run reel de validation du meme jour : le nom de Workshoppm-1-task-1avait deja 16 versions en cache dans le registre (atelier-workshops/pm-1-task-1, sessions anterieures) — le Job a probablement reutilise une image cachee au lieu de rebuild depuis MON.devcontainer/devcontainer.json. Autrement dit : tout Workshop dont ledevcontainerRepoEST le depot cible (le flux normal depm-engine, pas un devcontainer externe typevscode-remote-try-python) risque de ne jamais pouvoir (re)construire son image la premiere fois, en silence si le cache masque le probleme. Corrige et verifie en reel le meme jour :ensure_image_build_jobcable desormaisATELIER_GIT_ALIAS_ADDRsur son sidecarnet-proxy(resolution directe du ClusterIP de la forge viagit_identity::resolve_cluster_ip, sans passer paridentity-proxy— ce Job n'en a pas, l'auth Git au build passe deja parresolve_git_credentialscoteimage-builder, jamais par l'injection d'en-tete). Deuxieme cause distincte trouvee en verifiant :ATELIER_GIT_HOST_SERVICEn'etait meme pas defini dans l'environnement du controller de dev (ctx.git_identityvalaitNone, feature entierement inactive, RUNTIME compris) — ajoute adeploy/dev/local-stack/env.sh. Avec les deux, une microVM builder fraiche (nom de Workshop jamais vu, donc sans cache registre) reste active des minutes durant au lieu de s'eteindre en 3s : preuve quegit.atelier.internalresout desormais et que le clone demarre reellement.- Une microVM builder qui clone avec succes peut ensuite rester bloquee
SANS AUCUNE erreur, en plein telechargement d'un binaire externe
volumineux (constate deux fois, 2026-09-01, en installant
opencodedans un devcontainer —curl | bashPUIS, separement, le postinstall npm deopencode-ai, memes symptomes les deux fois) :net-proxyjournaliseegress autorise ... host="release-assets.githubusercontent.com" ... allowed=true(le tunnel CONNECT s'etablit), puis plus AUCUNE ligne pendant 10+ minutes, alors que le processfirecrackerde la microVM reste vivant (CPU/memoire en hausse lente, pas de crash). Aucunrx_rate_limiter/tx_rate_limiterconfigure cote Firecracker (crates/firecracker/src/vm.rs) — la lenteur n'est donc pas une limitation de bande passante deliberee. Cause non identifiee : a suspecter le relais CONNECT denet-proxyface a un gros telechargement HTTPS (redirections de CDN GitHub, HTTP/2, ou un bug de streaming qui bufferise tout avant de relayer). Contourne, pas corrige, le meme jour :opencodeest desormais baque dans l'imageatelier-image-builderelle-meme (telecharge audocker build, reseau normal du host — 2,7s reels mesures, contre 10+ minutes bloquees via ce chemin) et injecte directement dans le rootfs parinject_opencode_binary(crates/image-builder/src/main.rs), sans jamais passer parnet-proxy. Le bug lui-meme (le tunnel CONNECT qui se bloque) reste non identifie et non corrige — seul le cas d'usageopencoden'en depend plus. opencodesegfault EXACTEMENT commeclaude.exe(2026-09-01, meme jour) : c'est aussi un executable Bun standalone (bun run --compile, confirme parnpm pack opencode-linux-x64— memesha256que le binaire injecte parinject_opencode_binary, donc pas un probleme de mauvaise variante baseline/avx2 selectionnee au build). Meme signature de crash (Bun vX.Y.Z ... panic(main thread): Segmentation fault, adresse fautive differente a chaque essai, dont0xBBADBEEF), reproduite dans le MEMEchrootdurootfs.ext4reel qui avait fait crasherclaude.exeplus tot ce jour. Consequence directe pour le chantier "remplacer Claude Code par opencode" : le motif initial (fuir ce crash) ne tient pas — les deux CLI partagent la meme fragilite Bun dans cet environnement precis. Le motif licence/open source, lui, reste valable independamment. Contrairement a@anthropic-ai/claude-code, le paquet npmopencode-aine fournit AUCUN launcher Node de secours (postinstall.mjsne pose quebin/opencode.exe, pas de.cjsinvocable vianode) : pas de contournement equivalent anode cli-wrapper.cjsdisponible ici. Cause racine du crash Bun toujours non identifiee (straceindisponible sur le noeud kind pour investiguer plus loin) — reste ouvert, bloquant pour toute execution reelle d'un agent Bun-compile dans ce Workshop, quel que soit le CLI choisi.ttyd(terminal web),code-server(IDE web), et l'utilisateurvscode/uid 1000 dontinject_workspace_refreshsupposait deja l'existence n'etaient fournis QUE par le devcontainer de demo externe (github.com/PhilippeVienne/atelier-workspace), jamais parimage-builder— verifie en lisantcrates/vm-supervisor,crates/net-proxyetcrates/controller/src/openbao.rs: aucun n'installe quoi que ce soit, tout est seulement consomme (sonde de readinessGUEST_TERMINAL_PORT=7681, endpoints metadata/session-auth//ssh-authorized-key). Consequence reelle : un Workshop sur n'importe quel autre depot cible ne repondait jamais surttyd:7681et ne passait donc jamaisRunning. Corrige et verifie en reel le meme jour (2026-09-01, meme technique qu'inject_opencode_binary) :ttyd(binaire statique) etcode-server(archive autonome, Node embarque) sont desormais baques dans l'imageatelier-image-builderaudocker buildet injectes parinject_terminal_and_ide(crates/image-builder/src/main.rs), avecensure_vscode_userqui cree l'utilisateur/groupevscodedirectement dans/etc/passwd//etc/group//etc/shadowdu rootfs SI absent (au lieu de le supposer). Verifie sur un vrai rootfs monte en boucle : les deux unites systemd sont installees et activees (symlinksmulti-user.target.wants/),ttyd --versions'execute sans probleme enchroot(binaire natif, aucun rapport avec le crash Bun ci-dessus). Non couvert par cette correction (reste ouvert) :sshdet le scriptatelier-fetch-ssh-authorized-key.sh(ssh-authorized-keydu meme endpoint metadata) — plus complexe (config sshd, generation de host keys) et pas sur le chemin critique du readiness-probe, laisse pour un chantier separe.- Aucune image de base sans systemd n'a ete testee :
vm-supervisorne passe aucuninit=au noyau (ATELIER_VM_BOOT_ARGS), donc PID1 du guest reste/sbin/initde l'image cible tel quel — une image sans systemd (beaucoup d'images "slim" le sont) n'executerait AUCUNE des unites installees ici, silencieusement. Question deja posee sans reponse dansdocs/archive/PROGRESS-2026-08.md:860-864: soit le devcontainer source installe son propre systeme init, soitimage-builder/vm-supervisordevront un jour en injecter un generique (a la maniere d'atelier-builder-vm-initpour la microVM builder) — toujours non tranche. Les deux points corriges et verifies en reel le meme jour (2026-09-01) : sshd: embarque (binaire Debian + bibliotheques resolues parldd, executees viald.so --library-path) et injecte parinject_sshd. Deux bugs reels trouves en testant une vraie connexion SSH de bout en bout (pas seulementsshd -t) :sshdse RE-EXECUTE lui-meme (execve) a chaque connexion entrante, en repartant du chemin binaire brut — le wrapperld.so --library-pathne survit pas a ce re-exec (libwrap.so.0: cannot open shared object file, alors meme que le fichier est present dans le lot de bibliotheques embarquees).LD_LIBRARY_PATH(variable d'environnement normale, heritee par tout processus enfant/re-exec) est necessaire EN PLUS du wrapper, pas a sa place.- Un compte avec
!en/etc/shadow(verrou explicite pose parensure_system_userpour "aucun mot de passe utilisable") est vu parsshdcomme ADMINISTRATIVEMENT VERROUILLE ("User vscode not allowed because account is locked") — un blocage qui s'applique a TOUTE methode d'authentification, y compris par cle publique, contrairement a ce qu'on pourrait supposer.*a la place n'a pas ce defaut. Touche aussi les comptesvscodePRE-EXISTANTS de l'image de base (Microsoft en pose un avec!) :unlock_shadow_ passwordcorrige desormais tout compte, cree par nous ou non. - (Mineur mais reel)
UsePrivilegeSeparation noest un directive DEPRECIEE, silencieusement ignoree par OpenSSH >= 7.5 —sshdexige toujours un compte systeme dedie (sshd, cree parensure_sshd_user) pour sa separation de privileges, quoi que dise la config. Verifie par une vraie connexionssh vscode@<guest> whoamireussie (cle publique, cle hote generee au premier demarrage).
- Init sans systemd : nouveau crate
crates/guest-init(atelier-guest-init), modele suratelier-builder-vm-initmais PERSISTANT (ne reboote jamais) — monte les pseudo-filesystems, lance les scripts de service en arriere-plan avec relance sur sortie, et boucle surwaitpidpour recolter les zombies (responsabilite non negociable d'un PID 1). Le reseau n'a pas besoin d'etre reconfigure : le noyau le fait lui-meme au boot (ip=, deja pose parvm-supervisor::kernel_ip_boot_arg).ensure_init_system(image-builder) detecte l'absence desystemddans le rootfs construit et bascule/sbin/initvers ce binaire dans ce cas seulement — les images avec systemd gardent leur fonctionnement inchange. Verifie surdebian:bookworm-slim(sans systemd, sans utilisateurvscodeprealable) :ttyd/code-server/sshddemarrent tous les trois sous ce nouvel init (unshare --pidreel, pas une simulation), et unkill -9sur l'un d'eux declenche bien sa relance automatique. deploy/dev/keycloak/realm-export.jsoncontenait un faux champ"//serviceAccountRoles": "<note explicative>", invente pour glisser un commentaire dans du JSON (qui n'en supporte pas). Keycloak avait importe ce fichier une seule fois avec succes a la creation du realm ; l'import n'est rejoue qu'a la prochaine absence du realm en base, jamais a un simple redemarrage — le fichier invalide est donc reste latent, invisible, pendant des jours. Constate le 2026-09-02 quand un redemarrage du conteneur du noeud kind (recuperation apres un incident sans rapport, voir plus bas) a fait perdre l'etat du realm : Keycloak a retente l'import et a echoue net (Unrecognized field "//serviceAccountRoles"),atelier-keycloak-devenCrashLoopBackOff. Le vrai role (atelier-pm-bot->developer, sans quoi l'api-server refuse la creation de Workshops en 403, voirDEVELOPER_ROLEdanscrates/api-server/src/routes.rs) est correctement pose ailleurs dans le fichier (users[].realmRoles) — ce faux champ n'etait qu'une note redondante. Retire ; toute note sur ce fichier doit vivre dans un commentaire Markdown a cote, jamais comme un champ JSON invente.- Incident reel, cause par une erreur de nettoyage (2026-09-02) : un
rm -rflance sur un repertoire de test QUI CONTENAIT ENCORE DES MONTAGES LIES (mount --bind /dev, pose pour un testchrootdu crash Bun ci-dessus) a traverse le bind-mount et supprime pour de vrai/dev/nulldu conteneur du noeud kind — un bind-mount partage les memes entrees que la source, une suppression a travers l'un supprime l'autre. Consequence :docker execcasse entierement sur ce conteneur (unable to setup user: stat /dev/null: no such file or directory), aucune commande possible dedans, y compris pour reparer. Recupere par undocker restartdu conteneur (choix de l'utilisateur, l'autre option etant unmknodmanuel en root — indisponible sanssudointeractif depuis cette session) :/devde ce noeud est undevtmpfs, repeuple automatiquement par le noyau au (re)demarrage. Le redemarrage a lui-meme revele le bug ci-dessus (Keycloak). Regle a appliquer desormais, sans exception : ne JAMAISrm -rfun repertoire qui a servi de point de montage sans verifiermount | grep <repertoire>et tout demonter d'abord — un repertoire de test avec des bind-mounts actifs n'est jamais "juste des fichiers". - Le crash Bun ne se reproduit PAS dans une vraie microVM (2026-09-02,
conclusion de l'enquete ouverte la veille) :
opencode --versionpuis unopencode runcomplet tournent sans incident dans un Workshop Firecracker fraichement construit (/proc,/syset/devy sont tous les trois montes, verifie). Le SIGILL — rapporte par le gestionnaire de crash de Bun comme un "segfault", ce qui a longtemps oriente le diagnostic a cote — ne survient que dans un environnement d'execution ampute de ces pseudo-systemes de fichiers, typiquement unchrootde diagnostic monte a la main. Ce n'est ni la glibc (binaire strictement identique, il tourne sur l'hote comme dans undocker run), ni le CPU hybride (teste soustaskset), ni une corruption de l'image. Corollaire de methode : unchrootminimal n'est PAS un substitut fidele a la microVM pour reproduire un plantage d'exécutable. - La console serie du guest est journalisee en
debug!(2026-09-02) :drain_console_pipes(crates/firecracker/src/vm.rs) envoie toute la sortie de la microVM builder danstracing::debug!. Au niveauINFOpar defaut du Job*-image-build, un echec d'envbuilderest donc totalement invisible : le seul symptome visible est une microVM qui "s'eteint trop vite" puis uncrane exportqui echoue enMANIFEST_UNKNOWN— message qui ne dit rien de la vraie cause. Plusieurs heures ont ete perdues a soupconner KVM, le registre et le redemarrage du noeud, alors qu'un seul run avecRUST_LOG=debugdonnait la reponse en clair (ici :reference not found, le depot de test n'avait pas de branchemain). Reflexe a avoir : devant un build de devcontainer qui echoue sans explication, rejouer le Job avecRUST_LOG=debugAVANT toute autre hypothese. opencodeexige une sectionmodelsexplicite (2026-09-02) : pour un fournisseur OpenAI-compatible declare a la main,opencodene decouvre RIEN via/v1/models— il ne connait que le catalogue models.dev et ce qui est declare dansprovider.<nom>.models. Sans cette section,opencode modelsne liste rien pour le fournisseur etopencode run --model atelier/atelier-workshop-agentse bloque indefiniment, sans message, sans code d'erreur. Ajoutee dans la config generee parinject_net_proxy_config(crates/image-builder/src/main.rs). A cote de ca,opencode runn'ecrit strictement rien tant que son stdin n'est pas ferme : diagnostiquer avec< /dev/null, sinon meme les messages d'erreur restent invisibles.- Une variable d'environnement DEFINIE mais VIDE n'est pas "absente"
(2026-09-02) :
deploy/dev/local-stack.shn'ecrit le bloc LiteLLM que siDEEPSEEK_API_KEYouANTHROPIC_API_KEYest exporte au moment ou on le lance ; sinon il genereATELIER_LLM_PROXY_AUTH_TOKEN="". Le motifstd::env::var(...).ok()acceptait cette chaine vide, et le guest recevait un jeton d'authentification vide dans/etc/environment— pire qu'une absence franche de configuration, puisque l'agent partait quand meme et echouait sans rien dire. Corrige par un.filter(|v| !v.trim().is_empty())danscrates/controlleretcrates/image-builder: une valeur vide vaut desormais "non configure", comme l'absence de la variable. En dev,ATELIER_LLM_PROXY_ADDRdoit valoir l'adresse vue par le controller (port-forward127.0.0.1:14000) etATELIER_LLM_PROXY_POD_ADDRcelle vue par les pods — meme dedoublement queOPENBAO_ADDR/ATELIER_OPENBAO_POD_ADDR. - La ConfigMap LiteLLM ne se met pas a jour toute seule (2026-09-02) :
editer
deploy/dev/llm-proxy/config.yamlne change rien tant que la ConfigMap n'est pas recreee ET le Deployment redemarre. L'aliasatelier-workshop-agentajoute pouropencodeetait donc absent du proxy en fonctionnement : la requete retombait sur le wildcard"*"(route versanthropic/deepseek-chat, l'endpoint Anthropic de DeepSeek), qui rejette les outils d'opencodeen400—tools[0]: unknown variant 'custom'.opencoderetente alors en silence, ce qui se voit seulement comme un blocage. Les400n'apparaissent que dans les logs du pod LiteLLM : c'est la premiere chose a regarder quand un agent "ne repond pas" sans erreur. - Ce qu'un redemarrage du noeud kind detruit dans OpenBao (2026-09-02) :
le pod OpenBao de dev perd ses METHODES D'AUTH au redemarrage (les
secrets KV, eux, survivent — ce qui rend le diagnostic trompeur : le
secret cherche est bien la, mais plus personne ne peut s'authentifier
pour le lire). Symptome cote api-server :
cle SSH indisponible pour ce Workshop, avec unlogin OpenBao refuseenWARNcomme seule trace. Remise en etat : rejouer le blocbao auth enable kubernetes+bao write auth/kubernetes/configdedeploy/dev/local-stack.sh, puis REDEMARRER le controller — c'est lui qui cree le role et la policyatelier-api-server, et seulement a son demarrage. Cause voisine et meme symptome exact : le jeton de ServiceAccount (deploy/dev/local-stack/api-server-sa-token) a une duree de vie de 24 h et expire donc chaque jour ;kubectl create token atelier-api-server --duration=24hle regenere. Verifier lequel des deux est en cause en rejouant le login a la main (POST /v1/auth/kubernetes/login) : le message y est explicite (token is expired), contrairement aulogin OpenBao refusecote api-server. - Une chaine de bugs silencieux entre le PM et son agent (2026-09-02) :
faire tourner le graphe complet du PM sur un depot greenfield, avec un
devcontainer ORDINAIRE (
mcr.microsoft.com/devcontainers/javascript-node:20, aucune surcouche atelier), a fait tomber six defauts d'affilee. Aucun ne produisait de message utile ; tous se presentaient sous le meme deguisement,connexion SSH echouee: DisconnectedouUnexpected server error. Ils sont listes ici dans l'ordre ou il a fallu les demeler, parce que chacun masquait le suivant : export LD_LIBRARY_PATHglobal dans le script de demarrage desshd. Nos bibliotheques viennent de bookworm, l'image cible est plus recente :mkdir,chmod,chown,seqmouraient tous enstack smashing detected/GLIBC_2.38 not found. Le script s'arretait a sa premiere ligne utile. La variable ne doit etre posee que sursshd/ssh-keygen, viaenv.sshdse re-execute par connexion. Le re-exec repart du chemin brut, donc avec l'editeur de liens de l'image cible et nos bibliotheques bookworm : mort immediate, connexion coupee avant l'echange de versions (kex_exchange_identification), alors quesshdannonce tranquillementServer listening on port 2222.-rdesactive ce re-exec.- Le port SSH.
crate::exec(api-server) visait22par defaut, herite de l'epoque ou seul le devcontainer de demo fournissait SSH, via le service systeme. Notresshdinjecte ecoute sur2222. Sur toute image ordinaire, l'exec frappait donc a une porte que personne n'ecoutait. UsePAM noprive le guest de/etc/environment. C'estpam_envqui lit ce fichier, et une session SSH non interactive n'ouvre ni/etc/profileni~/.bashrc. L'agent demarrait sansOPENCODE_CONFIG_CONTENTni jeton LLM :opencodene connaissait aucun fournisseur et mourait surUnexpected server error. Corrige par~/.ssh/environment+PermitUserEnvironment yes.git cloneignorait le proxy. libcurl, sousgit, ne lit deliberement quehttp_proxyen MINUSCULES pour les URLhttp://(protection historique contre l'en-tete CGIProxy:). Avec les seules majuscules, le clone tentait de resoudregit.atelier.internallui-meme et echouait ; le workspace etait livre vide, sans code ni depot git, et l'agent n'avait rien sur quoi travailler.- Le Workshop s'annoncait
Runningtrop tot. La sonde ne verifiait quettyd, qui ecoute avantsshd: toutexec_in_workshoplance dans la foulee echouait. Elle verifie desormais les deux portes d'entree. La lecon commune : chacun de ces defauts etait invisible en test unitaire et invisible avec le devcontainer de demo — lequel apportait son propresshdsysteme, avec PAM, sur le port 22, et masquait donc a lui seul les points 1 a 4. Un composant qu'on rend generique doit etre exerce sur une image ORDINAIRE, pas sur celle qui a servi a l'ecrire. - Le snapshot d'un Workshop survit au Workshop (2026-09-02) : les
snapshots persistants sont indexes par NOM
(
/cache/snapshots/default_<nom>/). Recreer un Workshop portant un nom deja utilise ne reconstruit rien :vm-supervisorrestaure l'ancien snapshot, donc l'ancien rootfs — sans les binaires fraichement injectes, et avec l'ancienne cle SSH. Symptome : un Workshop qui semble ignorer une image tout juste reconstruite. En debug, prendre un nom neuf (ou supprimer le repertoire de snapshot) plutot que de rejouer le meme. - Deux comptes pour un meme uid, et c'est tres bien ainsi (2026-09-02) :
beaucoup d'images de devcontainer utilisent deja l'uid 1000 (
nodesur les images Node,ubuntuailleurs).ensure_vscode_usercree malgre toutvscodeavec ce meme uid :/etc/passwdporte alors deux noms pour un seul uid, etwhoamiaffiche le nom de l'image. Ce n'est pas un bug — memes droits, meme acces aux fichiers du workspace, etsshdresout bien/home/vscode— mais il faut le savoir avant de s'alarmer en voyantwhoamirepondrenodedans un Workshop. - Le keep-alive d'une session d'agent survit a celui de la destination
(2026-09-02) :
forward_rewriting(crates/net-proxy/src/proxy.rs) gardait UNE connexion vers la destination pour toute la duree de la connexion cliente. Or celle d'un agent vit des dizaines de minutes, avec de longues pauses pendant que le modele reflechit, la ouuvicorn(sous LiteLLM) ferme apres quelques secondes d'inactivite. La requete suivante partait donc dans une socket morte :relai du corps de la requetecote net-proxy,AI_APICallError: the socket connection was closed unexpectedlycote agent. Constate sur une connexion ouverte 2 min 38 s plus tot.opencoderetente, ce qui rendait le defaut presque invisible — un seul echec sur dix-huit echanges — mais un client sans retry, ou une requete non idempotente, y perdrait le tour. net-proxy rouvre desormais une connexion quand la destination a raccroche ENTRE deux requetes, donc avant d'avoir ecrit le moindre octet de la suivante : aucun rejeu, aucune requete dupliquee. Le test de regression a ete verifie dans les deux sens (il echoue si l'on neutralise la detection). - Une execution deleguee sans plafond global (2026-09-02, meme run que le
raccrochage de destination ci-dessus) :
wait_for_exec_completion(pm_engine/exec_client.py) n'avait qu'untimeout_sPAR OPERATION reseau, et le serveur emet unpinga chaque sondage meme quand rien n'avance — il rearme donc ce delai indefiniment. Un agent reste ainsi suspendu 1 h 20 sur un appel au modele parti dans une socket morte, sans que rien, ni cote PM ni cote atelier, ne le signale : ni erreur, ni log, le graphe attendait juste. Ajoutetotal_timeout_s(45 min par defaut,asyncio.timeoutautour de tout l'echange) : passe ce plafond, l'execution echoue franchement (status: Failed), avec la sortie deja recue conservee — c'est elle qui dit ou l'agent s'est arrete. - Le meme raccrochage, un cran plus loin :
identity-proxy(2026-09-02) : le correctif net-proxy ci-dessus n'a pas suffi. Un run reel est reste bloque 45 minutes malgre lui (garde-fou cote PM declenche) :net-proxychaine tout le trafic LLM paridentity-proxy(injection de credentials), qui maintient LUI AUSSI une connexion persistante vers la vraie destination — meme defaut, une couche plus bas, dans une crate distincte que mon premier correctif ne touchait pas. Pire encore :forward()(crates/identity-proxy/src/proxy.rs) rendait la main SANS repondre au client sur une ecriture en echec (Broken pipe) — le client (net-proxy) restait alors a attendre une reponse qui n'arriverait jamais, PENDANT que net-proxy lui-meme attendait une requete suivante qui n'arriverait pas davantage : chacun des deux bouts attendait l'autre, une impasse parfaite, invisible de l'exterieur (aucune erreur, aucun log au-dela d'unWARNisole). Correctif different de celui de net-proxy, plus simple ici puisqueforward()est une boucle synchrone requete/reponse (pas de tache de copie separee) :try_readavec un tampon d'1 octet, standard pour detecter un FIN sans bloquer, AVANT d'ecrire le moindre octet de la requete courante — jamais apres un echec d'ecriture partiel, qui rendrait un rejeu incorrect (le corps deja consomme depuis le client ne peut pas etre relu). Lecon a retenir : une connexion egress passe par plusieurs hops (net-proxy->identity-proxy-> destination), et CHACUN peut independamment garder une connexion persistante trop longtemps vivante. Corriger le premier hop sans verifier les suivants laisse le meme defaut intact, juste plus loin dans la chaine. NO_PROXYne couvrait paslocalhost(2026-09-02) : seul169.254.0.1(net-proxy lui-meme) y figurait. Le reflexe le plus banal qui soit pour un agent qui vient d'ecrire un service HTTP —curl http://localhost:3000/pour verifier qu'il repond — partait donc vers net-proxy, qui ne connait "localhost" que comme LUI-MEME et repond502. Constate en Workshop reel : un agent dont la suite de tests venait de passer (3/3) a conclu, sur la foi de ce faux502, que son propre serveur ne repondait pas.localhost,127.0.0.1ajoutes aNO_PROXY/no_proxy.- Un exec pouvait rester coince pour toujours : deux gardes-fous distincts,
pas un seul (2026-09-02) : un agent qui met son propre serveur en
arriere-plan pour le tester (
node server.js & ...; kill %1; wait) et se trompe dans son propre nettoyage bloquaitexec_in_workshopindefiniment. Cause reelle, pas une supposition : dans un shell SSH NON INTERACTIF, le controle de tache ("job control") est desactive par defaut —kill %1/kill $PIDechoue souvent en silence, et lewaitqui suit attend alors un process encore vivant qui ne se terminera jamais. Constate deux fois de suite (2026-09-02), scripts differents, memes symptomes :exit_code: Noneapres plusieurs minutes de silence, rien commite ni pousse. Ce n'est PAS un bug reseau — le process est reellement encore vivant, du point de vue du shell qui l'a lance. Deux mecanismes complementaires, chacun avec une portee differente : timeout --kill-afterautour de CHAQUEexec_in_workshop(crates/api-server/src/exec.rs::with_ceiling) : GNUtimeout, sans--foreground(le defaut), place la commande dans son PROPRE groupe de processus et, a expiration, envoie le signal a CE GROUPE ENTIER — pas seulement a son enfant direct. Le serveur oublie en arriere-plan meurt donc avec le reste. Verifie manuellement en conditions reelles :timeout --kill-after=3s 2s bash -c 'sleep 30 & wait'tue effectivement lesleeparrere-plan, confirme par son absence danspsapres coup — ne JAMAIS supposer ce genre de comportement documente sans le verifier une fois en pratique.- Watchdog dans
atelier-guest-init(PID 1 du guest,kill_stale_orphans+ heartbeatPOST /heartbeatversnet-proxytoutes les 15s) : complementaire, PAS redondant. Il ne voit QUE les processus deja reparentes a PID 1 (de vrais orphelins), jamais un process encore rattache a un parent vivant plus haut dans l'arbre — le cas le plus courant d'un exec SSH bloque, ou la sessionsshdqui l'a lance reste en vie tout du long et empeche justement toute reparentation. Couvre le reste (undisown/nohupdont le parent direct est deja sorti) et donne une preuve positive, horodatee, cote hote (killed_stale_orphansdans le heartbeat, enWARNcote net-proxy) qu'un process est reellement reste coince — utile meme quand le plafondtimeouta deja fait son travail par ailleurs. Le heartbeat est pour l'instant purement observationnel (logsnet-proxy) : il n'est pas encore remonte dansstatus.conditionsdu Workshop, ce qui demanderait quecrates/controllervienne LIRE cet etat en plus de simplement le stocker. - Le vrai bug que la validation devait trouver : le bug des PR vides est
revenu (2026-09-02) : un run complet et propre de bout en bout —
planificateur greenfield en une seule sous-tache, sshd/proxy corriges,
tests devcontainer passes 5/5 — a quand meme abouti a une PR (#20) au diff
VIDE (
baseetheadau meme SHA). L'agent avait ecrit un code entierement correct, fait passer sa propre suite de tests, puis simplement OUBLIE d'executergit commit/git push— le tout dernier geste d'une longue session agentique, precisement celui qu'un LLM (comme un humain) est le plus susceptible d'oublier.exit_code: 0malgre tout : rien ne distinguait ce run d'un succes reel avant d'aller verifier la PR elle-meme.open_pull_request(pm_engine/nodes.py) DETECTAIT deja ce cas (OpenPullRequest: la PR ... ne contient AUCUN fichier modifie, decision documentee et deliberee de ne pas bloquer le graphe, pour laisser l'humain seul juge) — mais rien ne CORRIGEAIT la situation en amont, et le paquet transmis a la revue humaine (interrupt) ne porte quequestionetpr_url, paspr_changed_files: un relecteur qui approuve sans aller re-verifier la PR lui-meme ne voit jamais qu'elle est vide. Corrige structurellement, pas en esperant que l'agent s'en souvienne mieux la prochaine fois :delegate_to_opencodeenchaine desormais un SECONDexec_in_workshop, construit par pm-engine lui-meme (git add -A && (diff --cached --quiet || commit) && push), qui garantit le commit ET le push quel que soit ce que l'agent a fait ou pas fait — commit oublie, push oublie (l'autre moitie du meme oubli, silencieuse si on ne pousse pas INCONDITIONNELLEMENT), ou travail deja correctement termine (no-op, verifie qu'aucun commit vide n'est ajoute). Trois cas verifies par de vrais depots git jetables, pas des mocks.open_pull_requesta ete durci en consequence (2026-09-02, meme session) : une PR encore vide malgre ce commit garanti ne peut plus signifier "l'agent a oublie" — elle echoue desormais bruyamment (RuntimeError) au lieu de continuer vers une revue humaine qui n'a rien a approuver. - La session MCP pm-engine/api-server ne survivait pas non plus a une
longue attente (2026-09-02, meme journee, troisieme couche du meme
defaut) :
delegate_to_opencodeouvrait UNE session MCP (atelier_mcp_session) et la gardait ouverte pendant TOUTE la boucle — y compris les deux longues attentes (wait_for_exec_completion, dix a vingt minutes ou plus le temps qu'opencodereflechisse). Un hop intermediaire (Traefik,http://api.atelier.local) a fini par couper cette connexion pour inactivite :MCPError: Session terminatedau moment du DEUXIEME appel (le commit automatique), alors que le premier (la delegation elle-meme) avait reussi.run_devcontainer_testsavait pourtant deja la bonne convention : la session n'enveloppe que l'appelexec_in_workshoplui-meme (qui rend la main immediatement, l'attente reelle se faisant sur un flux SSE separe), jamais l'attente.delegate_to_opencodederogeait seul a cette regle. Corrige en alignant sur cette convention : une session COURTE par appel, ouverte puis refermee avant chaquewait_for_exec_completion. - Aucun outil MCP ne permet a
pm-enginede provisionner des identifiants git en ecriture pour un Workshop (2026-09-02, decouverte en creusant pourquoigit pushechouait avecfatal: could not read Username ... No such device or address) : le mecanisme d'injection de credential existe bel et bien (identity-proxy+ secret OpenBaosecret/data/workshops/<name>/git, champpassword, injecte commeAuthorization: token <PAT>sur les requetes vers l'aliasgit.atelier.internal— voircrates/controller/src/git_identity.rs) etcrates/image-buildersait deja LIRE ce meme secret pour un clone authentifie. Mais RIEN ne l'ECRIT automatiquement :create_workshop(crates/api-server/src/mcp_server.rs::CreateWorkshopParams) n'a aucun parametre de credential, et le seul mecanisme d'ecriture existant (crates/api-server/src/credentials.rs) est concu pour etre pilote MANUELLEMENT depuis le dashboard par un humain (une regle d'injection generique par hote), jamais appele parpm-engine. Consequence probable : AUCUN run PM automatise, dans cette session comme vraisemblablement dans les precedentes, n'a jamais pu reellement pousser du code sans qu'un humain ait prealablement configure ce credential a la main — ce qui explique retrospectivement une bonne part de la saga des "PR vides" documentee plus haut, au-dela de la seule negligence de l'agent. Contourne manuellement pour cette session en ecrivant le secret via le token OpenBao root (exactement le geste qu'un operateur ferait via le dashboard) avant de lancer un nouveau run. Reste a faire : soitcreate_workshopgagne un parametre de credential optionnel, soit pm-engine appelle un nouvel outil MCP dedie avant de deleguer — non implemente cette session, une decision de conception qui merite d'etre prise consciemment plutot que corrigee dans l'urgence.
Corrige dans la meme session, une fois le vrai run de bout en bout
obtenu : BaseGitProvider gagne une methode git_push_credential()
(non abstraite, None par defaut, meme convention que
changed_file_count/list_root_entries) qui reexpose le jeton QUE LE
PROVIDER DETIENT DEJA (le meme qui lui sert a ouvrir des PR/creer des
branches) sous la forme d'un couple (username, password) HTTP Basic.
provision_workshop l'appelle juste apres create_workshop, via un
nouvel outil MCP set_workshop_git_credential (crates/api-server), qui
ecrit dans le MEME secret OpenBao
(secret/data/workshops/<name>/git, champs username/password) que
lisent deja image-builder et identity-proxy. Piege au passage dans
l'implementation : la policy OpenBao d'api-server
(ensure_api_server_role) EXCLUAIT EXPLICITEMENT ce chemin (reserve
jusque-la aux composants tournant dans le pod du Workshop) — sans
l'etendre, la nouvelle ecriture aurait echoue en 403. Et KV v2 remplace
tout le contenu d'un secret a chaque ecriture (pas de fusion) : ecrire
username puis password en deux appels separes aurait perdu le
premier — d'ou write_fields_for, qui ecrit plusieurs champs
ATOMIQUEMENT, teste dans les deux sens (le bug REPRODUIT reellement en
test, contre la vraie instance OpenBao de dev, pas seulement corrige a
l'aveugle).
Deuxieme piege trouve en relecture (/code-review) sur ce meme
correctif : provision_workshop appelait set_workshop_git_credential
APRES le bloc if await _workshop_exists(...): continue. Sur une reprise
LangGraph (le noeud est rejoue depuis son checkpoint apres un crash), si
create_workshop avait deja reussi mais que le process est mort (ou
qu'OpenBao etait injoignable) juste avant l'appel de credential, le
Workshop existe desormais — _workshop_exists est vrai, continue saute
le reste de l'iteration, et le credential n'est JAMAIS ecrit, sur aucune
reprise future. On retombe alors exactement sur le bug que ce depot
corrige (fatal: could not read Username ... No such device or address),
mais uniquement sur le chemin de reprise. Corrige en ne sautant que
create_workshop (via un if/else, plus un continue) : l'appel a
set_workshop_git_credential sort du bloc conditionnel et s'execute a
chaque passage, y compris pour un Workshop deja existant — sans risque,
puisque c'est une ecriture KV v2 idempotente (elle remplace simplement la
meme valeur).
- 2026-09-02, port-forward manquant vers LiteLLM sur le port attendu par
atelier-api-server: en voulant exercer pour de vrai (pas de skip)test_provision_and_suspend_workshop_via_real_mcpettest_mcp_session_drives_a_real_workshop_lifecycle(necessitentKEYCLOAK_PM_BOT_SECRET), ces deux tests echouaient avecSecurity dependencies unreachable: LiteLLM injoignable, creation refusee (Fast-Fail). Cause :atelier-api-serverlocal (process de dev, pas un pod) litATELIER_LLM_PROXY_ADDR=127.0.0.1:14000— un port DIFFERENT de celui (4000) generalement port-forwarde pour testerllm-proxydirectement depuis l'hote — et rien n'ecoutait sur 14000 (aucunkubectl port-forwardactif dessus, contrairement aux autres services de dev). Ni un bug de code ni du a la session en cours (le redeploiement du ConfigMapatelier-llm-proxy-configpour ajouter un modele mock, voir tache 5.6.3, n'y est pour rien — confirme en reproduisant l'echec puis en le resolvant uniquement en ajoutantkubectl port-forward svc/atelier-llm-proxy 14000:4000) : un simple trou d'environnement de dev local, invisible tant que personne n'exerce ce chemin precis (echoue silencieusement enskipsans le jeton Keycloak requis). Une fois ce second port-forward en place, les deux tests echouaient encore, mais pour une raison sans rapport, elle aussi pre-existante :plusieurs groupes (atelier-core, atelier-demo) : precisez ownerGroup— l'identiteatelier-pm-botde cet environnement appartient a plusieurs groupes, etPmEngineDeps.workshop_owner_groupn'est pas renseigne dans la configuration de test. Non corrige (hors perimetre de la tache en cours, la tache 5.6.3 surReviewArchitecturene touche a aucun de ces deux mecanismes) — a reprendre si quelqu'un a besoin de ces deux tests vraiment verts plutot que verifies "au moins jusqu'a ProvisionWorkshop".