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.
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.
La charte a retiré sa table de correspondance Règle N → A-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.html | Identifiant 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 |
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é.
tabby-better-vault. Dépôt GitHub existant depuis le 2026-07-26 —
https://github.com/TooMuhtsh/tabby-better-vault — portant le plugin complet
(src/, package.json, webpack.config.js) depuis le
2026-07-28. Package npm : pas encore publié (voir ROADMAP.html,
§ Publication).tabby-better-sidebar : projet frère, pas une dépendance obligatoire
— better-sidebar peut détecter la présence de ce plugin et exposer un lien vers ses
réglages si installé, mais fonctionne normalement sans lui (interfaçage optionnel, voir
ROADMAP.html).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.
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.
TABBY_PLUGINS cassé sur Windows (bug Tabby, pas du
plugin) : utiliser une jonction NTFS dans %APPDATA%\tabby\plugins\node_modules\,
jamais cette variable d'env.package.json doit toujours avoir un champ
author (même vide), sinon échec silencieux de la découverte du plugin.tabby-core) : typescript@^4.9, pug@^2.0,
@types/node@^18.19, @ng-bootstrap/ng-bootstrap@^14 — à revérifier au
moment du scaffold (la version d'Angular ciblée par Tabby a pu changer depuis).tabby-ssh (peu probable, le
Vault n'a a priori rien à voir avec SSH) : npm install --ignore-scripts à cause d'un
postinstall natif inutile.templateUrl/styleUrls ne marchent pas
pour un plugin tiers ; utiliser template: require('./x.pug') et un import SCSS en
side-effect. S'applique à tout composant Angular ajouté par ce plugin (ex. panneau de réglages).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.
tabby-core sont probablement en
retard sur l'app installée, comme pour ProfilesService.@hidden ne veut pas dire "non exporté" : toujours
vérifier les DEUX niveaux (typings/index.d.ts ET le bloc d'export webpack réel de
dist/index.js compilé) avant de conclure qu'une classe est inutilisable.EditProfileModalComponent) — vérifier les noms de champs dans le bundle compilé, pas
seulement dans les typings.Tabby.exe --debug --remote-debugging-port=9333 pour le
Chrome DevTools Protocol. Après toute modification du plugin, tuer et relancer tout le process
Tabby.exe (pas un simple reload).gh auth login, configurer l'identité de commit localement au dépôt (pas
--global), et la réécriture d'historique (git filter-branch) doit être
lancée par l'utilisateur lui-même via le préfixe !.É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.
VaultService est bien exporté, aux deux niveauxContrairement à ce qui était redouté, aucune augmentation de type n'est nécessaire :
import { VaultService } from 'tabby-core' compile et fonctionne à l'exécution.
typings/index.d.ts ne contient pas le mot « vault », mais
il fait export * from './api', et typings/api/index.d.ts (ligne 36)
réexporte VaultService, Vault, VaultSecret, VaultFileSecret, VAULT_SECRET_TYPE_FILE,
StoredVault, VaultSecretKey. Ne pas conclure d'un simple grep sur
index.d.ts — suivre les réexports en cascade.dist/index.js contient bien
VaultService: () => (/* reexport */ VaultService) et
VAULT_SECRET_TYPE_FILE.VaultFileProvider et
UnlockVaultModalComponent. Ce dernier point conditionne le contournement décrit en
ROADMAP.html.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éthode | Pourquoi 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(). |
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.
config.encryptedLe 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 :
store.encrypted === true) : la pop-up apparaît
pendant le démarrage, dans ConfigService.maybeDecryptConfig(), appelé
depuis load() lui-même déclenché par un setTimeout(() => this.init())
posé dans le constructeur de ConfigService. Si l'utilisateur annule, Tabby
boucle sur une message box « Try again / Quit ». Fenêtre de tir extrêmement précoce —
c'est le risque de faisabilité restant (voir ROADMAP.html).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.
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.
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.
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 natif | isOpen() |
|---|---|---|
| Jeton servi depuis le keychain (déverrouillage automatique) | ❌ jamais rempli — on ne passe pas par la méthode native | false |
| Délégation à la méthode native (apprentissage, repli, mode observation, plugin désactivé) | ✅ rempli normalement, avec son setTimeout de purge | true ensuite |
Conséquences à connaître avant de s'étonner :
vault.isOpen() reste false. Impact réel
faible : cette méthode n'a qu'un seul appelant dans tout Tabby
(tabby-settings/src/components/vaultSettingsTab.component.ts:28) — l'onglet de
réglages du coffre affichera l'état « verrouillé » même quand tout fonctionne.setTimeout de purge n'est pas armé. La politique de durée devient
notre responsabilité et devra être exposée à l'utilisateur.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 :
vault.decrypt() et vault.load()
retombent sur this.getPassphrase(), qui est justement notre méthode. Le plugin se
bloquerait au démarrage. C'est la raison principale, et elle est rappelée en commentaire dans le
fichier pour dissuader toute « simplification » future.decrypt() appelle forgetPassphrase()
et affiche une notification « Incorrect passphrase ». Notre vérification doit rester silencieuse :
un jeton périmé se purge sans déranger l'utilisateur.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.
require ?| Question | Statut |
|---|---|
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")
: OK → remote.safeStorage : présent →
isEncryptionAvailable() : 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.
@electron/remote → voie principale pour safeStorage.keytar → repli crédible, déjà utilisé par tabby-ssh
(passwordStorage.service.ts) pour les mots de passe SSH quand le coffre est
désactivé.require('electron') fonctionne dans le renderer mais n'expose pas
safeStorage, réservé au processus principal — d'où le détour obligatoire par
@electron/remote.safeStorage est effectivement proxifié par
@electron/remote (tout objet du processus principal ne l'est pas forcément) et que
isEncryptionAvailable() renvoie true. C'est ce que mesure le spike.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.
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.
config.yamlQuand 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 :
vault: — le coffre chiffré lui-même, ce qui permet de vérifier un mot de passe
avant de le servir (#V10). Il doit rester en clair, sans quoi
Tabby ne pourrait pas déchiffrer sa propre config au démarrage.encrypted: — indique le régime en vigueur.encrypted: true.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.
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.
ngx-toastrngx-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.
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.
position: relative sur l'hôte que si son style calculé vaut
static : cela ne change pas le flux, seulement l'origine des coordonnées, et évite de
toucher au style de Tabby plus que nécessaire.config.yaml avant tout déchiffrementInvariant à 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.
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.
appearance.frame: thin — seule valeur
cohérente sous config chiffrée, puisque le processus principal ne peut de toute façon jamais la
lire et active toujours l'overlay. Mesuré : le .btn-group droit passe de
x=2002..2048 (collision avec la zone WCO x=1911..2048) à
x=1864..1910, et un window-controls-spacer de 138 px apparaît.navigator.windowControlsOverlay
(.visible + .getTitlebarAreaRect()) dit si Chromium peint des boutons
natifs par-dessus le contenu web, et env(titlebar-area-*) donne la même information
en CSS. Interrogeable via CDP (piège hérité #8) sans rien instrumenter.el.__ngContext__ est un index numérique, pas le LView, et
window.ng n'existe pas. On ne peut donc pas lire config.store depuis
CDP — passer par config.yaml (déchiffré temporairement) ou par les classes CSS que
les *ngIf laissent dans le DOM, qui trahissent les valeurs de config.ISSUE-tabby-titlebar-overlay.md à la racine du
dépôt, publiée sous Eugeny/tabby#11539
(Tabby 1.0.235 / Electron 38.8.6) — le fichier local et le corps de l'issue sont identiques. Correctif propre côté Tabby : sortir
appearance.frame du blob chiffré, comme vault/encrypted/
configSync.isEncryptionAvailable() est déjà false en basic_textLe 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" }
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.
basic_text. Mesuré : en session i3
(XDG_CURRENT_DESKTOP=i3, XDG_SESSION_TYPE=x11), le backend retenu reste
gnome_libsecret — parce que gnome-keyring-daemon tourne et revendique
org.freedesktop.secrets, indépendamment du bureau. Ce qui compte est la
disponibilité du service Secret, pas la détection du bureau. Bonne nouvelle collatérale :
un utilisateur i3 disposant de gnome-keyring obtient le chemin nominal, pas un refus abusif.--password-store=basic (déterministe, immédiat), ou l'absence réelle de service
Secret sur le bus de session. Aucune préparation particulière de la machine n'est nécessaire.isEncryptionAvailable() ne revient jamais
sur un trousseau verrouillé. Comme keychainStatus() est synchrone, passe par l'IPC
bloquant d'@electron/remote, et est appelé depuis le constructeur du NgModule, Tabby
gèle au démarrage et n'atteint jamais sa pop-up native. Le repli n'est pas
« tardif », il est inatteignable. Le try/catch n'y change rien : un
appel bloquant n'est pas une exception. Détail, chaîne causale et correctif à concevoir :
Campagne de vérification indépendante, dans le
registre des livrés.(() => { const ss = require('@electron/remote').safeStorage; return { isEncryptionAvailable: ss.isEncryptionAvailable(), hasMethod: typeof ss.getSelectedStorageBackend, backend: ss.getSelectedStorageBackend ? String(ss.getSelectedStorageBackend()) : 'METHODE ABSENTE' } })()[disabled] sur le toggle de Tabby ne neutralise pas le contrôleTout 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.
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.
enabled: true existant en false. Le fichier est propre à la
machine, pas à la session — ouvrir le panneau depuis une session dégradée ne doit pas effacer un
réglage posé depuis une session où le trousseau fonctionne.deleteToken() ne fait que réécrire notre fichier ; un jeton enregistré avant la
panne doit rester supprimable, c'est le moment où ça compte le plus), le mode
observation (VaultBridgeService.resolve() teste debug AVANT
enabled et avant l'état du trousseau — c'est le seul moyen d'observer le plugin dans
une session dégradée), le journal et le nom de machine qui
étiquette ses entrées.safeStorage peut bloquer indéfiniment : garder le point de passage, pas les appelantsMesuré (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.
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().
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.
withSafeStorage() dans osKeychain.ts,
et il doit le rester. Ne jamais appeler require('@electron/remote') ailleurs :
le garde-fou ne protège que ce qu'il enveloppe, et un futur appelant qui l'oublierait ramènerait le
défaut sans qu'aucun test ne s'en aperçoive. Quatre entrées aujourd'hui —
keychainStatus, encrypt, decrypt, et le panneau de réglages
qui appelle la première.
osKeychain.ts — un seul APPEL doit subsister dans src/, les autres
occurrences étant des commentaires ou des chaînes :
grep -rn "require('@electron/remote')" src/ (attendu : osKeychain.ts,
une ligne, dans withSafeStorage()).isEncryptionAvailable() avait été mesuré bloquant ;
que encryptString/decryptString le soient aussi n'était qu'une déduction.
La deuxième campagne a tranché : les trois bloquent. Envelopper le point de
passage plutôt que de parier sur l'un des trois était donc le bon choix — et l'argument tient
indépendamment du résultat, ce qui est précisément ce qu'on attend d'un tel choix.available: true de keychainStatus() ne dit RIEN, sur Linux,
de la capacité du trousseau à répondre. Il ne rapporte que le nom du backend, et
getSelectedStorageBackend() est mesuré comme ne touchant jamais le trousseau (0,00 s,
aucun trafic D-Bus, verrou inchangé). C'est voulu sur le chemin de resolve(), où le
vrai test est le decrypt() qui suit. C'était ruineux dans le panneau de
réglages : le bouton « Lever la suspension et vérifier » annonçait « disponible » sur un
trousseau verrouillé, effaçait le témoin, et le démarrage suivant regelait — défaut D1 de la
deuxième campagne, sévérité haute, sur le SEUL chemin de sortie offert à l'utilisateur. Toute
vérification qui engage quelque chose doit passer par keychainRoundTrip(), jamais par
keychainStatus().install() est appelée depuis le
constructeur du NgModule, et AVANT que enabled ne soit consulté (le test vit dans
resolve()). Un keychainStatus() qui n'y servait qu'à journaliser suffisait
à figer Tabby même plugin désactivé. Le diagnostic a lieu dans resolve(), une fois
enabled vérifié.isEncryptionAvailable() n'est plus appelé du tout quand
getSelectedStorageBackend() a répondu autre chose que basic_text : il
n'apportait aucune information de plus. À ne pas lire comme le correctif du gel — il retire un
appel bloquant du chemin, pas le risque.keychain = keychainStatus())
emportait, avec le gel, le moyen d'en sortir. La sonde est désormais explicite, ou conditionnée à
enabled.keychainRoundTrip() est délibérée : cette
vérification peut poser un nouveau témoin, donc bloquer à nouveau. C'est le prix d'un test réel, et
l'utilisateur qui l'a déclenché est là pour répondre à l'invite. Ce qui était fautif, c'est de
lever le témoin sans rien vérifier.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.
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.
serveFromToken() purge
le jeton invalide et rend la main à la saisie manuelle. Ce n'est pas ce qui rendait le
défaut réel. Ce qui le rendait réel, c'est qu'une notification affirmait à l'utilisateur
que son mot de passe était enregistré pendant que l'écran d'à côté lui disait l'inverse. Un
comportement sûr n'excuse pas un message faux.readStoredVault() nul : on n'enregistre rien, symétriquement à
serveFromToken(). Le journal le dit (« rien à vérifier contre »), plutôt que
d'enregistrer en silence une valeur non validée.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.
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.
en-US n'est volontairement enregistré par aucune table : les chaînes sources
sont l'anglais, et faire exister la locale dans translate.langs sans raison
rouvrirait précisément ce piège.TranslateService est typé any, contrairement au reste de tabby-coreMesuré 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.
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).
@ngx-translate/core. Il est
bundlé à l'intérieur de tabby-core et n'existe nulle part comme module
résolvable à l'exécution : un import direct produirait un require mort, symétrique de
#V8. Son absence est aussi ce qui protège — un tel import ne compile pas, alors que l'auto-import
d'un IDE le proposerait spontanément.| translate, lui, ne demande rien : TranslateModule figure dans
les exports du module racine de tabby-core, qu'un plugin importe déjà.
C'est ce que font les plugins internes de Tabby (vérifié sur tabby-ssh, dont le bundle
ne require pas @ngx-translate/core).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 :
npm run lint:i18n, qui compare les deux sens — chaîne du code sans traduction, clé de
table sans chaîne — et refuse les paramètres perdus en route.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.
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.
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.
OSCrypt est mise en cache pour la vie du processus principalChromium 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) :
Locked = true, et cinq fenêtres déchiffrent sans invite. La protection
« trousseau verrouillé » ne joue qu'au démarrage à froid. Tout scénario de test qui
verrouille le trousseau en cours de session ne teste pas ce qu'il croit tester.learnFromUser ne peut alors plus rien enregistrer. Seul un
redémarrage de Tabby rétablit l'accès.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.
msgid de Tabby qui portent le même nomLa 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.
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.
É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.
--user-data-dirElectron 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.
rename » ne garantit pas un fichier neufLe 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 :
writeFileSync écrit à
travers le lien et renameSync déplace le lien lui-même : la cible finale
devient un lien, la charge utile atterrit hors du dossier voulu, et un chmod qui suit
resserre les droits de la victime. « Le fichier qui atterrit est toujours neuf » est faux.writeFileSync(path, data, { mode }) n'applique le mode qu'à la
création. Sur un fichier préexistant l'argument est ignoré, et un chmod
postérieur laisse une fenêtre où le contenu complet est déjà lisible sous les anciens droits —
mesuré, pas déduit.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.
TabbyFormatedDatePipe existe, mais Tabby n'enregistre aucune donnée de localeChercher 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.
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.
author dans son package.json, Tabby ignore le plugin en silencefindPlugins() 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.
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.
require('tabby-ssh') charge une seconde copie : le patch se pose sur un jumeau que personne n'utiliseLe 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é.
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.
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.