Contexte & invariants

Dernière revue : 2026-07-28

La vérification de faisabilité du 2026-07-28 (lecture des sources et du bundle réels de tabby-core) a produit les premiers invariants propres — voir Anatomie du Vault de Tabby — et le plugin a été écrit dans la foulée : les pièges #V6 et suivants viennent du code réel, pas d'une lecture. La page liste aussi les pièges hérités du projet frère, qui partage exactement la même stack (Tabby, Angular 15, Electron, TypeScript, webpack) — à confirmer plutôt qu'à redécouvrir de zéro.

Compteur de numérotation

Prochain numéro libre : #V31#V1 à #V30 sont attribués, sans trou. Celui qui ajoute un piège incrémente ce compteur dans le même geste (A-6) : l'ordre d'affichage ne suit pas la numérotation, donc aucune lecture partielle ne donne cette information.

Le trou entre #V27 et #V29 a existé le temps que les branches de correctifs restent séparées : #V28 était réservé pour fix/c3-lot3-lisibilite pendant que #V29 s'attribuait sur une autre branche. Les deux se retrouvent ici au merge, dans l'ordre de leur numéro — un numéro se réserve, il ne se recycle jamais.

Correspondance locale — ancienne numérotation « Règle N »

La charte a retiré sa table de correspondance Règle NA-x à la révision 20260803-200821 : elle ne la porte plus pour personne. AI-HISTORY.html, en ajout seul (A-4), garde plusieurs références à l'ancienne numérotation antérieures au 2026-08-01 — décision du 2026-08-06 (entretien de cadrage) : porter ici, et seulement pour elles, la correspondance qui leur donne un sens.

Référence dans AI-HISTORY.htmlIdentifiant actuel
Règle 7, cas B (audit d'un .AIRules/ ayant dérivé)A-15
Règle 1 (dossier .AIRules/ versionné, commité et poussé à chaque mise à jour) A-1, A-7, A-10
Règle 1 (.tempfiles/ ignoré par Git)option tempfiles
Règle 3 (métadonnées d'attribution IA en commit)option attribution
Convention de numérotation

Deux séries distinctes, à ne jamais confondre : les numéros sans préfixe désignent toujours les pièges hérités du projet frère (numérotation de son AI-CONTEXT.html, qui fait autorité) ; #V1, #V2… désignent les pièges propres à ce projet. Un nouveau piège propre prend toujours le prochain numéro #V libre, jamais un numéro recyclé.

Identité du projet

Pièges hérités du projet frère — à anticiper dès le scaffold

Détail complet de chacun dans l'AI-CONTEXT.html de tabby-better-sidebar — pas dupliqué ici pour éviter que les deux copies divergent avec le temps. Seul le résumé et le pourquoi ça s'applique probablement ici aussi.

Environnement & build

Version de Tabby de référence : 1.0.235 (au 2026-08-08)

Version installée sur le poste de développement, et celle contre laquelle les pièges ci-dessous ont été vérifiés. Le tabby-core qu'elle embarque s'annonce, lui, en 1.0.231-nightly.0 : c'est ce numéro que cible la peer dependency du package.json, pas celui de l'application — les confondre fait chercher une incompatibilité qui n'existe pas. À mettre à jour ici et dans le README quand la référence change.

Composants Angular dans un plugin tiers

Vérifier une classe/API Tabby avant de s'y fier

Vérification faite le 2026-07-28

VaultService est confirmé accessible depuis un plugin tiers (voir #V1) — ces trois pièges hérités ne se sont donc pas matérialisés sur cette classe précise. Ils restent valables pour toute autre classe Tabby que ce plugin voudrait utiliser plus tard.

Debug & workflow de dev

Git & GitHub

Anatomie du Vault de Tabby — pièges propres (#V)

Établis le 2026-07-28 par lecture de tabby-core@1.0.231-nightly.0 : src/services/vault.service.ts, src/services/config.service.ts, src/components/unlockVaultModal.component.ts, typings/api/index.d.ts et le bloc de réexport webpack de dist/index.js. Sources lues dans l'app installée (C:\Program Files\Tabby\resources\builtin-plugins\), qui embarque son propre src/ — c'est la référence la plus fiable, plus fraîche que les typings npm.

#V1 — VaultService est bien exporté, aux deux niveaux

Contrairement à ce qui était redouté, aucune augmentation de type n'est nécessaire : import { VaultService } from 'tabby-core' compile et fonctionne à l'exécution.

#V2 — Le mot de passe maître n'est PAS injectable programmatiquement

Constat central du projet

Le cache du mot de passe vit dans let _rememberedPassphrase, une variable de portée module dans vault.service.ts, précédée du commentaire explicite // Don't make it accessible through VaultService fields. C'est un choix de conception délibéré de Tabby : aucun champ ni méthode publique n'y donne accès en écriture.

Détail des méthodes qui acceptent un paramètre passphrase?aucune ne remplit le cache :

MéthodePourquoi elle ne suffit pas
setEnabled(enabled, passphrase?) Ne fait quelque chose que si !this.store — inopérante dès que le coffre existe, c'est-à-dire dans tous les cas qui nous intéressent.
decrypt(store, passphrase?) / load(passphrase?) Utilisent bien la passphrase fournie, mais ne la mémorisent jamais. Déchiffrer une fois n'ouvre pas le coffre pour le reste de l'application.
encrypt(vault, passphrase?) Contient la seule écriture du cache hors modal — if (_rememberedPassphrase) { _rememberedPassphrase = passphrase } — mais elle est gardée par le cache lui-même : elle ne fait que rafraîchir une valeur déjà présente, jamais l'initialiser. Inutilisable pour un premier déverrouillage.
getPassphrase() Seul chemin d'écriture réel, et il passe obligatoirement par l'ouverture du modal UnlockVaultModalComponent via ngbModal.open().

#V3 — Fournir la passphrase à nos propres appels ne sert à rien

Piste à écarter d'emblée : les consommateurs internes de Tabby appellent tous le Vault sans argument et retomberont donc systématiquement sur le cache natif — getSecret() appelle load() sans passphrase, et ConfigService.maybeDecryptConfig() appelle getPassphrase() avant tout decrypt(). Déverrouiller « pour nous seuls » n'évite donc jamais la pop-up aux autres plugins ni à Tabby lui-même. Le seul objectif valable est de remplir le cache natif.

#V4 — Deux moments de pop-up très différents selon config.encrypted

Le design initial supposait une pop-up systématique au démarrage. C'est faux dans un cas sur deux, et la différence change complètement la fenêtre de tir du plugin :

Vérifié par le spike le 2026-07-28 — le patch arrive à temps

Journal d'exécution : %APPDATA%\tabby\better-vault-spike.log. Le résultat décisif est la ligne #V4 patch posé … (isEnabled=false) alors que le config.yaml contient bien un coffre (vaultPresent=true). isEnabled() vaut !!this.store, et store n'est renseigné que par setStore(), appelé depuis ConfigService.init(). Le fait qu'il soit encore false au moment du patch prouve que nous passons avant init() — donc avant maybeDecryptConfig(), donc avant la pop-up du cas « config chiffrée ». Le raisonnement ci-dessous est confirmé par la mesure.

Réserve : la machine de test a une config non chiffrée (encrypted=absent). Le cas encrypted: true n'a donc pas été observé directement — mais l'ordre établi (patch avant setStore, donc avant init()) est précisément ce qui le gouverne. Confiance élevée, pas certitude absolue.

Observé au passage : deux appels à getPassphrase() lors de l'usage réel, tous deux issus de decrypt() (dist/index.js:20621). Le premier a mis ~7 s (saisie utilisateur dans la pop-up), le second a répondu en 1 ms — le cache natif faisait son travail. Le comportement de Tabby est donc resté strictement inchangé par notre enveloppe, comme voulu.

Raisonnement d'origine, antérieur à la mesure (2026-07-28)

La séquence de démarrage lue dans app.asar est loadPlugins()getRootModule(pluginModules)bootstrapModule(). Les bundles des plugins sont donc évalués avant tout Angular, et les constructeurs des NgModule s'exécutent pendant la création — synchrone — de l'injecteur racine. Or ConfigService ne lance pas init() directement : son constructeur l'arme via setTimeout(() => this.init()), donc au tick suivant. Un patch posé dans le constructeur de notre NgModule devrait par conséquent arriver à temps même en config chiffrée.

À confirmer par le spike : la création de l'injecteur est le point le plus précoce où une instance de VaultService puisse exister, donc il n'y a aucune marge de manœuvre en amont si cette présomption se révèle fausse.

#V5 — La durée de mémorisation est dictée par le modal, pas par le service

getPassphrase() arme un setTimeout qui remet le cache à null après Math.max(1000, rememberFor * 60000) ms, où rememberFor vient de la valeur retournée par le modal (options natives : 1, 5, 15, 60, 1440, 10080 minutes ; la préférence de l'utilisateur est persistée dans window.localStorage.vaultRememberPassphraseFor par le modal lui-même, jamais par le service). Conséquence pratique : qui contrôle la réponse du modal contrôle aussi la durée du déverrouillage — et un rememberFor long est un choix de sécurité à exposer à l'utilisateur, pas à coder en dur.

#V6 — L'état du cache natif dépend du chemin emprunté

Le contournement retenu remplace getPassphrase sur l'instance. Le cache natif _rememberedPassphrase n'est alors alimenté que dans certains cas — c'est conditionnel, pas absolu :

Chemin empruntéCache natifisOpen()
Jeton servi depuis le keychain (déverrouillage automatique) ❌ jamais rempli — on ne passe pas par la méthode nativefalse
Délégation à la méthode native (apprentissage, repli, mode observation, plugin désactivé) ✅ rempli normalement, avec son setTimeout de purgetrue ensuite

Conséquences à connaître avant de s'étonner :

#V10 — Les paramètres cryptographiques du coffre sont répliqués chez nous

Couplage non contractuel à surveiller à chaque mise à jour de Tabby

src/vaultCrypto.ts reproduit le déchiffrement de Tabby (PBKDF2 100 000 itérations, SHA-512, AES-256-CBC, clé 256 bits) pour vérifier qu'un mot de passe est le bon avant de le servir. Si Tabby change ces paramètres, notre vérification échouera systématiquement.

La dégradation est sûre — jeton purgé, retour à la saisie manuelle, aucune erreur visible — mais silencieuse : le déverrouillage automatique cesserait simplement de fonctionner, sans que rien ne l'explique. À revérifier après toute mise à jour majeure de Tabby.

Pourquoi ne pas appeler l'API native de déchiffrement plutôt que de la répliquer :

Corollaire de conception : la vérification a lieu contre le coffre lu directement dans config.yaml, pas via vault.store. Ce dernier est encore null au moment qui compte, puisqu'en config chiffrée getPassphrase() est appelée avant setStore() (#V4). Lire le fichier est le seul moyen de valider dans les deux régimes.

#V7 — Quels modules Node un plugin tiers peut-il vraiment require ?

Trois questions distinctes — ne pas les confondre (état au 2026-07-28)
QuestionStatut
Le module @electron/remote est-il résolvable depuis un plugin tiers ? Prouvé par lecture du chargeur (voir ci-dessous).
safeStorage est-il proxifié par @electron/remote ? Prouvé : il figure explicitement dans browserModuleNames (@electron/remote/common/module-names), la liste même des modules du processus principal exposés au renderer. À noter qu'il est absent de la surface utilisée par ElectronService de Tabby — d'où le doute initial.
isEncryptionAvailable() renvoie-t-il true sur cette machine ? Mesuré : true (spike du 2026-07-28, Windows 11 / DPAPI). Reste à revérifier sur Linux le jour où le plugin y sera porté : la réponse y dépend d'un service de trousseau actif.

Le risque jugé le plus élevé du projet est clos. Les trois volets sont vérifiés, les deux premiers par lecture du code, le troisième par mesure : require("@electron/remote") : OKremote.safeStorage : présentisEncryptionAvailable() : true.

Rien côté Tabby ne s'y oppose non plus : l'app appelle bien require('@electron/remote/main').enable(webContents), et aucun gestionnaire maison n'intercepte l'événement remote-get-builtin pour filtrer les modules accessibles (les deux seules occurrences dans app.asar appartiennent à @electron/remote lui-même).

Mécanisme, dans initModuleLookup() du chargeur de plugins de Tabby (app.asar, module plugins) :

function initModuleLookup (userPluginsPath) {
    global['module'].paths.map(x => nodeModule.globalPaths.push(normalizePath(x)))
    const paths = []
    paths.unshift(path.join(userPluginsPath, 'node_modules'))
    paths.unshift(path.join(remote.app.getAppPath(), 'node_modules'))   // ← app.asar/node_modules
    ...
    process.env.NODE_PATH += path.delimiter + paths.join(path.delimiter)
    nodeModule._initPaths()
}

Tabby ajoute donc <app.asar>/node_modules au NODE_PATH puis force Module._initPaths() : tout ce que l'app embarque devient résolvable depuis n'importe quel plugin. Vérification faite dans l'en-tête de l'archive app.asar — sur les 351 modules de premier niveau, @electron/remote et keytar sont bien présents tous les deux.

#V8 — La liste exacte des modules pré-cachés par Tabby (externals sûrs)

Le même chargeur remplace global.require et Module.prototype.require pour servir un jeu de modules déjà instanciés — c'est ce qui garantit qu'un plugin partage bien la même instance d'Angular et de RxJS que Tabby (sans quoi l'injecteur partagé ne fonctionnerait pas). Ce sont les externals à déclarer sans hésiter dans webpack.config.js :

@angular/core, @angular/common, @angular/compiler, @angular/forms, @angular/platform-browser, @angular/platform-browser/animations, @angular/platform-browser-dynamic, @ng-bootstrap/ng-bootstrap, ngx-toastr, rxjs, rxjs/operators, zone.js, plus tabby-core, tabby-local, tabby-settings, tabby-terminal.

À noter : tabby-ssh n'est pas dans cette liste de modules pré-cachés — il reste résolvable via le dossier des plugins intégrés, mais il ne bénéficie pas de la même garantie d'instance unique.

#V9 — Cause racine du piège hérité #1 (TABBY_PLUGINS cassé sur Windows)

Trouvée incidemment dans le même initModuleLookup() :

process.env.TABBY_PLUGINS.split(':').map(x => paths.push(normalizePath(x)))

Le séparateur est codé en dur à ':' — séparateur de chemins POSIX. Sur Windows, un chemin comme C:\Users\... est donc découpé en ['C', '\Users\...'], deux chemins invalides. Le bug n'est ni aléatoire ni lié à l'environnement : il est structurel, et la variable est inutilisable telle quelle sur Windows, quoi qu'on y mette. La jonction NTFS n'est pas un contournement de confort, c'est la seule voie. À remonter au projet frère, dont le piège #1 ne documente que le symptôme.

#V11 — Ce plugin ne peut pas lire ses propres réglages dans config.yaml

Impossibilité structurelle — pas un contournement de confort

Quand l'utilisateur active le chiffrement de configuration, config.yaml ne contient plus que trois clés en clair : vault, encrypted et configSync. Tout le reste — profils, réglages, y compris ceux de ce plugin — vit à l'intérieur du blob chiffré. Or c'est exactement l'instant où le plugin doit agir. Lire config.store.betterVault exigerait donc le mot de passe que le plugin est justement chargé de fournir : poule et œuf.

Conséquence : ConfigProvider est inutilisable pour ce plugin, et un réglage placé dans config.yaml serait silencieusement ignoré dans le cas d'usage principal. L'état complet (activation + jeton) vit dans %APPDATA%\tabby\better-vault.json — voir src/store.ts.

Ce qui reste lisible en clair, et sur quoi on peut donc compter :

Le chiffrement de config n'est pas un obstacle, c'est le meilleur cas d'usage

Réflexe initial de l'utilisateur en découvrant ce piège : désactiver le chiffrement de configuration pour que le plugin fonctionne. C'est l'inverse qu'il faut retenir — config non chiffrée, la pop-up n'apparaît qu'à la première demande de secret ; config chiffrée, elle apparaît à chaque démarrage. C'est donc là que le déverrouillage automatique apporte le plus de valeur, et il n'y a aucune raison de renoncer au chiffrement : il suffit que les réglages du plugin vivent ailleurs.

#V12 — Un SCSS importé par un composant n'existe que si ce composant est instancié

Le SCSS d'un plugin tiers est chargé par un import en side-effect (import './x.scss', piège hérité #3) — donc au moment où le module JS qui l'importe est évalué. Un style importé depuis un composant n'est présent que si ce composant a été instancié au moins une fois.

Concrètement : le style de la notification d'enregistrement, s'il était importé par le composant du panneau de réglages, n'existerait qu'après une première ouverture de l'onglet. Un utilisateur qui ne l'ouvre jamais verrait une notification non stylée — sans aucune erreur pour le signaler. Règle : tout style visant quelque chose d'extérieur au composant doit être importé depuis src/index.ts, évalué dès le chargement du plugin.

Corollaire pour ngx-toastr : l'option toastClass remplace la classe par défaut au lieu de s'y ajouter — sa valeur par défaut est 'ngx-toastr', qu'il faut donc conserver explicitement ('ngx-toastr ma-classe') sous peine de perdre toute la mise en forme de base. La classe de type (toast-info, etc.) est ajoutée automatiquement, elle.

Deux détails de mise en forme constatés sur les notifications, tous deux silencieux : (1) la largeur est fixée par .toast-container.toast-top-center .ngx-toastr (spécificité 0,3,0), qui l'emporte sur un sélecteur en .toast-container .ma-classe (0,2,0) — d'où un !important nécessaire sur width, et sur elle seule ; (2) l'icône est une image de fond dont la couleur est figée dans le SVG : impossible de la teindre en CSS, il faut redéclarer le pictogramme. Penser aussi à conserver le retrait à gauche (padding-left) qui lui réserve sa place, faute de quoi elle chevauche le texte.

#V13 — Ancrer une notification à un onglet impose de se passer de ngx-toastr

ngx-toastr place ses toasts dans un conteneur positionné par rapport à la fenêtre : positionClass ne propose que des coins d'écran (toast-bottom-left, etc.). Pour qu'une notification appartienne visuellement au terminal que l'utilisateur regarde, il faut l'insérer soi-même dans le corps de l'onglet actif, que Tabby marque de la classe content-tab-active sur son élément tab-body (appRoot.component.pug). C'est ce que fait src/inlineToast.ts.

Le piège n'est pas le positionnement, c'est le moment

Au démarrage, le coffre est déverrouillé avant qu'un seul onglet n'existe. Un querySelector('tab-body.content-tab-active') à cet instant renvoie null : la notification ne s'afficherait jamais dans le cas d'usage principal. Il faut donc attendre le premier onglet actif (AppService.activeTabChange$, plus un court délai pour laisser le DOM se rendre), avec un abandon au-delà de quelques dizaines de secondes — une notification tardive n'aurait plus de rapport lisible avec l'événement qu'elle annonce.

#V14 — Le processus principal lit config.yaml avant tout déchiffrement

Invariant à retenir bien au-delà de ce cas précis : tout réglage que le processus principal consomme au moment de créer la fenêtre retombe silencieusement sur sa valeur par défaut quand la config est chiffrée. Le blob chiffré ne laisse en clair que vault, encrypted et configSync ; le processus principal n'a aucune passphrase à cet instant, donc pour lui appearance, terminal, hacks… valent undefined. Le renderer, lui, tourne après le déverrouillage du coffre et voit les vraies valeurs. Les deux processus peuvent donc être en désaccord — sans le moindre message d'erreur.

Cas réel diagnostiqué le 2026-07-29 — barre de titre Windows

Symptôme : les boutons natifs réduire/agrandir/fermer de Windows peints par-dessus la tab-bar de Tabby, et impossibilité de déplacer la fenêtre en attrapant le haut. Suspectés à tort : tabby-better-sidebar, tabby-better-vault, tabby-sftp-plus. Aucun n'était en cause.

Mécanisme : le processus principal fait "native" === configStore.appearance?.frame ? o.frame = true : (o.titleBarStyle = "hidden", "win32" === process.platform && (o.titleBarOverlay = {…})). Config chiffrée, appearance est undefined → branche else → overlay natif activé. Le renderer, lui, lit le vrai frame: native et ne rend donc ni title-bar, ni window-controls, ni window-controls-spacer (leurs *ngIf exigent frame == "thin", cf. appRoot.component.pug lignes 99–108). Personne ne réserve les 138 px : le .btn-group droit se retrouve sous les boutons natifs. .btn-space perd au passage ses classes .persistent et .drag, d'où la perte de la zone de glissement.

#V15 — isEncryptionAvailable() est déjà false en basic_text

Le commentaire d'origine de osKeychain.ts affirmait le contraire : que safeStorage.isEncryptionAvailable() restait true en mode dégradé sous Linux, et que seul getSelectedStorageBackend() permettait de détecter la supercherie. C'est faux. Mesuré le 2026-07-29 sur Electron 38.8.6 (Tabby 1.0.235, Ubuntu Desktop, lancement tabby --password-store=basic) :

{ "isEncryptionAvailable": false, "hasMethod": "function", "backend": "basic_text" }
Conséquence : l'ordre des tests décide du message lu par l'utilisateur

keychainStatus() testait la disponibilité en premier et sortait donc systématiquement par le motif générique « le système n'offre pas de chiffrement ». La branche basic_text — la seule qui explique pourquoi ce mode n'est pas fiable (clé codée en dur, sel fixe « saltysalt », une itération de PBKDF2) — n'était jamais atteinte. L'utilisateur était bien protégé, mais avec un message exact et inexploitable.

Le backend est désormais interrogé avant. Le test de disponibilité reste derrière, utile pour Windows et macOS où getSelectedStorageBackend n'existe pas, et pour les Electron antérieurs à ce changement de comportement.

#V16 — [disabled] sur le toggle de Tabby ne neutralise pas le contrôle

Tout laisse pourtant croire l'inverse : ToggleComponent hérite de CheckboxComponent, qui expose un @Input() disabled, un @HostBinding('class.disabled') et un @HostListener('click') qui sort immédiatement quand l'entrée est posée — et toggle.component.scss style déjà &.disabled { opacity: 0.5 }. Mesuré sur Tabby 1.0.235 : le contrôle apparaît bien atténué, et reste actionnable. La case à cocher interne porte son propre [(ngModel)], que le garde du @HostListener ne couvre pas.

Masquer plutôt que griser

Un réglage qui paraît inerte sans l'être est pire qu'un réglage franchement absent : il invite exactement à la fausse confiance que le garde-fou existe pour empêcher. Un *ngIf ne dépend d'aucun comportement tiers et ne peut pas mentir.

Corollaire à ne pas oublier : ne jamais faire reposer une garantie sur la seule absence d'un élément d'interface. persist() porte un second verrou qui refuse d'écrire enabled: true sans trousseau utilisable, indépendamment du gabarit.

#V17 — Tout appel à safeStorage peut bloquer indéfiniment : garder le point de passage, pas les appelants

Mesuré (deux campagnes du 2026-07-29, banc Electron 38.8.6) : sur un trousseau Linux présent mais verrouillé, isEncryptionAvailable(), encryptString() et decryptString() bloquent tous les trois — 6/6 essais tués à 45 s sans retour, en série entrelacée. Voir #V15 pour l'historique de la fausse affirmation qui masquait ce cas.

Ce n'est pas un gel silencieux — corrigé le 2026-07-29 au soir

La première rédaction de ce piège affirmait que l'appel « ne revient jamais, sans afficher de dialogue ». Les deux termes sont réfutés par mesure : le système affiche une invite d'authentification GNOME, et l'appel rend la main en 4 à 7 secondes dès qu'on y répond (3/3 essais, avec déverrouillage effectif de la collection). C'est une invite bloquante, pas un gel irrécupérable.

La conséquence est double. Le garde-fou reste nécessaire — un utilisateur absent bloque indéfiniment, et c'est le cas nominal d'un démarrage. Mais il devient praticable de vérifier réellement le trousseau depuis une action de l'utilisateur, puisqu'il est devant son écran pour répondre à l'invite : c'est ce que fait keychainRoundTrip().

Un appel bloquant n'est pas une exception

Aucun try/catch ne rattrape un gel, et @electron/remote fait de l'IPC synchrone : le fil du renderer est arrêté dans l'appel, plus aucun JavaScript ne s'exécute. On ne peut donc pas non plus poser de délai d'attente depuis le fil que l'on bloque. Le seul recours qui survive au gel est une trace écrite sur disque AVANT l'appel, relue au démarrage suivant — c'est src/keychainGuard.ts.

#V18 — Servir et enregistrer n'avaient pas les mêmes exigences, sans raison

Mesuré (deuxième campagne du 2026-07-29, défaut D2) : une phrase erronée saisie dans la pop-up native était chiffrée, écrite et annoncée comme enregistrée — à l'instant précis où Tabby affichait Could not decrypt config — BAD_DECRYPT pour la même saisie. Relecture du jeton : c'était bien la valeur fausse qui avait été stockée.

L'asymétrie était le défaut, pas l'absence de contrôle

passphraseOpensVault() existait déjà, et serveFromToken() s'en servait — il refuse de servir un mot de passe qu'il n'a pas pu vérifier. learnFromUser() ne l'appelait pas. Refuser de servir ce qu'on n'a pas vérifié et accepter d'enregistrer ce qu'on n'a pas vérifié ne se défendent pas ensemble : c'est le même secret, la même incertitude, et l'enregistrement est le geste le plus engageant des deux.

#V19 — Enregistrer une traduction avant Tabby met toute son interface en anglais

Vérifié dans le bundle compilé de tabby-core (2026-08-01). LocaleService.setLocale() ne charge le .po d'une langue que if (!translate.langs.includes(lang)). Or setTranslation() appelle updateLangs(), qui inscrit la langue dans cette même liste. Un plugin qui enregistre ses chaînes en premier fait donc sauter le chargement des traductions de Tabby — pas seulement les siennes : toute l'application reste en anglais pour la session.

Deux façons de tomber dedans, une seule façon d'en sortir

Le second piège est dans la signature : setTranslation(lang, table) remplace la table de la langue. Sans le troisième argument shouldMerge à true, le plugin efface les traductions de Tabby au lieu de s'y ajouter. La valeur par défaut est false — l'oubli est silencieux et total.

La fusion n'a donc lieu que depuis localeChanged$, que setLocale() émet après son chargement, ou sous test explicite de translate.langs. localeChanged$ étant un Subject et non un BehaviorSubject, il ne rejoue pas sa dernière valeur : le rattrapage au démarrage est nécessaire, et c'est le test de langs qui le rend sûr.

#V20 — TranslateService est typé any, contrairement au reste de tabby-core

Mesuré le 2026-08-01, pas déduit : compiler t.methodThatDoesNotExist() sur un TranslateService passe sans aucune erreur, là où le même appel sur un LocaleService est refusé (TS2339). La cause est que tabby-core réexporte TranslateService depuis @ngx-translate/core, absent de ce projet, et que skipLibCheck: true fait taire l'import non résolu au lieu de le signaler.

Le membre le plus dangereux est justement celui qui n'était pas vérifié

Sans types, rien ne rattrape l'oubli du troisième argument de setTranslation() — dont #V19 explique qu'il efface toutes les traductions de Tabby. D'où l'interface locale TranslateApi dans src/i18n/index.ts, qui redéclare les trois seuls membres employés, au prix d'un @Inject() explicite (une interface s'efface à la compilation, donc emitDecoratorMetadata ne peut plus en déduire le jeton).

#V21 — La chaîne source est la clé : rien ne signale qu'une traduction est morte

Tabby cherche ses traductions par le msgid gettext, c'est-à-dire la chaîne source anglaise, et son CustomMissingTranslationHandler compile la clé elle-même quand aucune traduction ne correspond. Conséquences en chaîne, toutes silencieuses :

Piège ICU que le français déclenche naturellement

Ces chaînes traversent TranslateMessageFormatCompiler, pour lequel l'apostrophe droite est un caractère d'échappement lorsqu'elle précède une accolade. jusqu'{date} est muet ; jusqu'au {date} ne l'est pas. Tabby lui-même écrit des apostrophes non échappées dans des chaînes à paramètres (« est toujours en cours d'exécution » avec {command}), ce qui confirme que seul le contact direct avec l'accolade pose problème. Le validateur le vérifie.

#V22 — Ce qui est écrit sur disque ne se traduit pas : identifiants, jamais phrases

Le témoin du garde-fou (better-vault-keychain.lock) est écrit avant l'appel qui gèle et relu au démarrage suivant — éventuellement sous une autre locale. Il y stockait la phrase française de l'opération gelée : un témoin écrit en français aurait été relu en anglais, et l'unique indice dont dispose l'utilisateur pour savoir où le trousseau a cessé de répondre serait devenu incohérent au premier changement de langue. Il porte désormais un OperationId, rendu à l'affichage.

La même règle vaut pour le journal, pour une raison voisine

better-vault.log est persistant et relu après coup. Le traduire à l'écriture rendrait un même fichier multilingue dès que l'utilisateur change de locale, et incherchable pour qui l'analyse. Il reste donc en anglais, non traduit (décidé le 2026-08-01).

Les cinq messages qui doivent servir les deux — motif d'indisponibilité du trousseau, phrase du garde-fou — passent par src/messages.ts : un Message transporte sa chaîne source et ses paramètres sans être rendu. Le journal l'écrit en anglais, l'interface le traduit avec la même chaîne comme clé. Une seule table de vérité, deux rendus — et un sourceParams distinct pour les paramètres qui sont eux-mêmes des chaînes à traduire, sans quoi une phrase française afficherait un nom d'opération resté en anglais.

#V23 — La clé d'OSCrypt est mise en cache pour la vie du processus principal

Chromium acquiert sa clé de chiffrement une fois et la garde. Deux conséquences opposées, toutes deux mesurées (Ubuntu, gnome-keyring, 2026-08-01) :

Ce que ça impose au code

Aucun bouton de récupération ne peut promettre une nouvelle tentative réelle dans la même session. Un message qui ne dit pas que le redémarrage est le seul remède laisse l'utilisateur cliquer indéfiniment sur un bouton qui ne touche plus rien.

#V24 — Fusionner une table de traduction écrase les msgid de Tabby qui portent le même nom

La clé étant la chaîne source anglaise (#V21) et l'enregistrement se faisant avec shouldMerge = true, toute clé qui coïncide avec un msgid de Tabby remplace la traduction de Tabby pour l'application entière, pas seulement dans notre panneau. Les mots courts sont les plus exposés : 'Cancel', 'Save', 'Close', 'Enable'.

Sans effet tant que les valeurs coïncident — c'est le cas aujourd'hui pour 'Cancel' dans les trois tables — donc le jour où ça casse, ce n'est pas le jour où la clé est ajoutée. Une divergence introduite bien plus tard, dans une seule langue, suffit. Prolonge #V19 : greffer quelque chose sur un mécanisme de Tabby peut dégrader Tabby, et ça ne se voit jamais en regardant le panneau qu'on vient d'écrire.

#V25 — Un fichier témoin ne dit pas si son auteur est encore vivant

Un témoin posé avant un appel bloquant et relu au démarrage suivant confond deux faits distincts : « un appel est en vol en ce moment » et « un appel n'est jamais revenu ». Deux fenêtres Tabby suffisent à produire l'observable — mesuré en usage multi-fenêtres ordinaire, sans provocation.

Le pid seul ne suffit pas à trancher

Écrire le pid dans le témoin ne résout rien tant qu'on ne le relit pas — et le relire ne suffit pas davantage : un processus gelé a lui aussi un pid vivant. Ce qui discrimine, c'est pid vivant + ancienneté : une opération gardée saine revient en millisecondes, une gelée jamais. Un compteur de profondeur en variable de module traite le cas intra-processus et n'a aucun équivalent inter-processus.

#V26 — Un chemin de configuration codé en dur ignore --user-data-dir

Electron laisse choisir le dossier de profil ; app.getPath('userData') est la seule source qui le suive. Un chemin déduit de la plateforme coïncide avec lui sur le profil par défaut — donc ne se distingue de lui qu'en présence d'un second profil, ce qui n'arrive jamais en test ordinaire.

Mesuré : une instance lancée sur un profil distinct écrit ses fichiers dans le profil par défaut. Le plus grave n'est pas l'emplacement mais ce qui en découle — fichier d'état partagé, témoin de garde-fou partagé (ce qui rend #V25 reproductible à volonté), et validation du mot de passe contre le config.yaml d'un autre profil.

#V27 — « Temporaire puis rename » ne garantit pas un fichier neuf

Le motif est correct contre la troncature, mais deux hypothèses tacites tombent dès que le temporaire porte un nom fixe et donc prévisible :

Et ce que le motif ne couvre pas du tout

Créer en wx après unlink ferme les deux : l'appel échoue si quoi que ce soit occupe le nom, lien compris, et le fichier est nécessairement neuf donc créé au bon mode. Envelopper le tout, enfin : un dossier en lecture seule fait échapper un EACCES depuis l'écriture du temporaire, bien avant le rename que le try surveille.

rename ne donne l'atomicité que face à un lecteur concurrent, pas face à une coupure d'alimentation — ce sont deux garanties différentes, et sans fsync sur le temporaire seule la première est acquise.

#V28 — TabbyFormatedDatePipe existe, mais Tabby n'enregistre aucune donnée de locale

Chercher l'équivalent officiel avant d'écrire le sien est la bonne réflexe, et il aboutit ici : tabby-core réexporte bien TabbyFormatedDatePipe, vérifié dans le bundle compilé, et son corps est formatDate(date, 'medium', locale.getLocale()). Le réutiliser paraît donc évident.

Sauf que formatDate d'Angular exige que les données de la locale visée aient été déclarées par registerLocaleData() — seul en-US est fourni d'office, et toute autre locale lève « Missing locale data ». Or l'appel n'existe nulle part dans app.asar : les douze occurrences du nom y sont la définition de la fonction par Angular lui-même, jamais un appel de Tabby.

Ce qu'il faut en retenir

Un export vérifié ne garantit pas un appel sûr. Le contrôle habituel de ce projet — la classe est-elle réellement exportée ? (#V1) — répond oui et ne dit rien de ses préconditions. Ici la dépendance manquante est chez Angular, pas chez Tabby, et n'apparaît qu'à l'exécution sous une locale non anglaise.

Les dates du plugin passent donc par Intl natif avec la locale rendue par LocaleService : la bonne langue, sans donnée à enregistrer. Le format diffère un peu de celui de Tabby — une exception au milieu d'un message de panne coûterait plus cher.

Non mesuré, et assumé comme tel : formatDate n'a pas pu être exercé hors d'un contexte Angular (le module refuse de se charger sans compilateur JIT). Ce qui est mesuré, c'est l'absence d'appel à registerLocaleData ; le reste est le comportement documenté d'Angular.

#V29 — Sans author dans son package.json, Tabby ignore le plugin en silence

findPlugins() lit le package.json de chaque candidat et fait :

let author = info.author
author = author.name || author

Sur un author absent, le déréférencement lève un TypeError — que le try englobant avale pour rendre null. Le plugin n'est alors pas découvert du tout : rien dans la liste des plugins, aucun panneau de réglages, aucune ligne de journal, aucun message d'erreur. Le seul indice est un Cannot load package info for … dans la console du processus principal, que personne ne regarde.

Les quatre champs que Tabby lit vraiment sont keywords (qui doit contenir tabby-plugin, et dont l'absence est proprement traitée), version, description et author. Seul le dernier est déréférencé sans garde.

Où ça mord

Mesuré le 2026-08-02 en préparant la quatrième campagne : un package.json réduit aux champs jugés utiles — pour retirer l'URL du dépôt d'un paquet remis à une instance aveuglée — a fait disparaître le plugin sans le moindre signal. Le diagnostic est venu de l'instance auditrice, pas de l'outillage.

À vérifier avant publication npm : un paquet publié sans author s'installerait correctement et ne se chargerait jamais. La leçon générale est celle du champ facultatif : facultatif pour npm ne veut pas dire facultatif pour le consommateur, et c'est le consommateur qui décide.

#V30 — require('tabby-ssh') charge une seconde copie : le patch se pose sur un jumeau que personne n'utilise

Le chargeur de plugins de Tabby n'intercepte les require par nom que pour une liste close : @angular/*, rxjs, ngx-toastr, zone.js, et quatre modules Tabby seulement — tabby-core, tabby-local, tabby-settings, tabby-terminal (cachedBuiltinModules + builtinModules de son initModuleLookup, lu dans app.asar). tabby-ssh n'en fait pas partie : depuis ce plugin, Node le résout alors normalement — c'est-à-dire dans le node_modules du plugin d'abord, visible à l'exécution à travers la jonction NTFS (et livré tel quel dans un paquet npm installé). Une seconde copie du module se charge, avec des classes homonymes mais distinctes.

Le symptôme est parfaitement silencieux : PasswordStorageService étant providedIn: 'root', injector.get(classeDupliquée) ne lève rien — Angular fabrique un jumeau du singleton, vierge, que personne d'autre ne consulte. Un patch d'instance posé dessus compile, s'installe, se journalise… et ne se déclenche jamais : les vrais appelants (SSHService, les sessions) tiennent l'autre instance. Mesuré le 2026-08-07, première passe de test du chantier granularité — l'appel « exclu » était servi automatiquement, et l'énumération des records de l'injecteur montrait deux singletons homonymes, le vrai non patché.

Les deux règles qui en sortent

1. tabby-ssh ne va jamais dans les devDependencies — même piège que le #34 du projet frère, sous une autre forme ; la sidebar vendorise ses typings, ce plugin n'en a simplement plus besoin. 2. L'instance réelle s'obtient par l'injecteur seul : multi-provider ProfileProvider de tabby-core (intercepté par nom, donc toujours le vrai), entrée id === 'ssh', champ passwordStorage — voir src/profileExclusions.service.ts. Bénéfice collatéral : plus aucune dépendance de chargement sur tabby-ssh.

Le piège vaut aussi pour un harnais de test : un require('tabby-ssh') tapé dans la console CDP résout selon les chemins du renderer (la vraie copie, ici), pas selon ceux du plugin — deux require du même nom, deux réponses. En cas de doute, énumérer injector.records et compter les singletons homonymes.

Invariant propre à ce projet — sécurité du secret

Non négociable, à respecter dès la première implémentation

La variable JS contenant le mot de passe maître déchiffré en clair doit être immédiatement écrasée (= null) dès que l'injection dans le moteur de Tabby est validée — voir détail complet dans ROADMAP.html. C'est la seule règle de ce projet qui n'est pas négociable même en phase d'exploration/prototype : un secret en clair ne doit jamais traîner en mémoire plus longtemps que nécessaire.