Key rotiert, Prozess behielt den alten: Wie Hermes einen Cache-Bug im selben Clock-Tick behoben hat

Es ist 1 Uhr nachts, und du rotierst deinen OpenRouter-Key, weil der alte geleakt ist und jetzt sterben muss. Du änderst die Zeile in config.yaml, ziehst die Kopie in .env nach und nimmst einen Telegram-Nutzer aus der Pairing-Liste, der nicht länger mit deinem Agenten reden soll. Neustarten tust du nichts — Hermes lädt Änderungen live nach, darauf verlässt du dich seit Monaten. Am Morgen benutzt ein Profil fröhlich den neuen Key, während eine Follow-Profile-Session weiter mit dem alten Requests feuert und das Log mit 401ern flutet. Und der Nutzer, dem du den Zugang entzogen hast? Kommt immer noch durch.
Das liegt nicht daran, dass du das Speichern vergessen hättest. Die Antwort in PR #145651, gemergt am 27. Oktober, ist etwas kontraintuitiv: Deine Änderung ist tatsächlich in der Datei angekommen, aber der Prozess war überzeugt, die Datei hätte sich nicht verändert. Und er war davon überzeugt, weil er einen Fingerprint beobachtet hat, der sich nicht bewegt, wenn Lesen und Schreiben in denselben Clock-Tick fallen.
Was ein Cache wirklich beobachtet
Hermes hält überall kleine Caches. auth.json einmal lesen und das Parse-Ergebnis merken; die Pairing-Liste einmal lesen und merken, wer freigegeben ist; config.yaml einmal lesen und sich die Provider-Routen merken. Um sinnloses Neu-Parsen zu vermeiden, schaut jeder Cache zuerst auf einen „Fingerprint“ der Datei. Üblicherweise sind das vier Werte:
mtime— Zeitpunkt der letzten Änderungctime— Zeitpunkt der Inode-Änderungsize— Anzahl der Bytesinode— die Dateinummer
Gleicher Fingerprint, also Parse-Ergebnis weiterverwenden. Anderer Fingerprint, also neu lesen. Das stimmt fast immer. Es geht schief, wenn zwei Details zusammenkommen.
Detail eins: gleiche Länge, In-Place-Overwrite. Aus api_key: sk-old wird api_key: sk-new — die Byte-Anzahl bleibt identisch, und wenn in die Datei hineingeschrieben wird (per Editor oder per Skript), bleibt auch die Inode gleich. Zwei der vier Werte sind damit garantiert identisch.
Detail zwei: Timestamp-Auflösung. Auf vielen Kernels (besonders auf CI-Runnern und manchen älteren Hosts ohne Multigrain-Timestamps) kommen mtime und ctime von einer ziemlich groben Uhr. Landen Lesen und Schreiben im selben Tick, tragen beide Schreibvorgänge exakt dasselbe (mtime_ns, size, ino, ctime_ns). Der Cache sieht einen unveränderten Fingerprint und schließt daraus: nichts zu tun, altes Parse-Ergebnis behalten.
So hat es die CI erwischt. Ein Test schreibt config.yaml mit Port 3, liest die Datei und schreibt sie dann in place mit Port 5 neu — gleiche Länge, gleiche Inode. Die fehlgeschlagene Assertion ist fast komisch banal:
E At index 1 diff: 'http://127.0.0.1:3/v1' != 'http://127.0.0.1:5/v1'
Port 3 und Port 5 sind je ein Zeichen, die Datei ist also byte-genau gleich groß. Der Test sollte beweisen, dass ein rotierter Key bis in Follow-Profile-Sessions durchkommt; bewiesen hat er, dass der Cache stattdessen die alte Route auslieferte.
Warum ein Neustart es „repariert“ und ein Live-Reload nicht
Das ist der Teil, der Leute in den Wahnsinn treibt: Der Bug existiert nur, solange der Prozess lebt.
Nach einem Neustart startet jeder Cache leer, der erste Lesezugriff muss parsen, und du siehst den neuen Wert. Genau deshalb wird das Symptom ewig falsch diagnostiziert: „die Config greift nicht“, „Live-Reload ist kaputt“, „dieses Profil hängt fest“. Jede Erklärung zeigt auf das Konfigurationssystem, während der eigentliche Übeltäter das Design des Cache-Keys ist.
Der Radius ist größer als ein einzelner Cache. Manche Keys nutzten das komplette Vierer-Tupel, manche nur mtime, manche mtime + size. Umgehst du einen, kann dir eine andere Komponente immer noch den Wert von gestern reichen.
Ein Helper statt zwanzig Patches
Der Fix patcht nicht jede Fundstelle einzeln. Er ergänzt einen einzigen Helper in der utils.py im Repo-Root:
def file_cache_version(path, st=None, *, always_digest=False):
...
Er gibt zwei Dinge zurück: die Datei-Signatur und einen Content-Digest.
- Die Signatur bleibt das billige stat-Tupel.
- Der Digest folgt standardmäßig einer „racy window“-Policy: Liegt der spätere der beiden Zeitstempel
mtimeundctimeweniger als zwei Sekunden zurück (RACY_MTIME_WINDOW_NS), werden die Datei-Bytes mit blake2b gehasht; sobald die Datei sich beruhigt hat, wird der Digest zu einem leeren String.
Anders gesagt: Eine gerade geschriebene Datei wird nach ihrem Inhalt beurteilt, eine zur Ruhe gekommene nach ihrer stat-Signatur. Der einzige Preis ist ein zusätzliches Parsen für einen Eintrag, der gecacht wurde, während die Datei noch racy war, sobald sie sich beruhigt. Für einen Cache, der bei jedem Lesen ohnehin neu geprüft wird, ist das nichts.
always_digest=True gibt es für eine andere Art von Aufrufer: eine Signatur, die jetzt gespeichert und erst viel später verglichen wird. Bei denen würde ein Digest, der auf einen leeren String umspringt, wie eine Änderung aussehen, also wird bei jedem Aufruf gehasht. Der PR nennt sie:
- die Signatur des ausgelieferten Profils (eine falsche Änderung verbindet die Adapter des Profils neu)
- der Parse-Failure-Eintrag (eine falsche Änderung würde den Fehler löschen, obwohl die Datei weiterhin kaputt ist)
- die Zustimmung zu Shell-Hooks
- der Inventory-Stempel der Free-Tier-Nutzung
- der Due-Stempel des Onboardings
- das Skills-Manifest, das auf die Platte geschrieben und vom nächsten Prozess verglichen wird
Eine Ausnahme ist bewusst eng gefasst: Ein Worker, der an einen eingefrorenen Config-Snapshot gebunden ist, hasht config.yaml nie. Die Prüfung sitzt im Helper, damit kein Aufrufer einen sicheren Worker dazu bringen kann, die Profildatei zu öffnen. Bei anderen Dateien — auth.json, .env, Pairing-Listen — bleibt der Digest auch innerhalb von Workern aktiv.
Und der Helper wirft nie. Auf Basis des früheren stat des Aufrufers bekommt eine Datei, die inzwischen verschwunden oder unlesbar geworden ist, den Fehlernamen als Digest, den keine lesbare Version treffen kann. Der Cache invalidiert, statt abzustürzen.
Zwanzig Caches, von denen jeder ein altes Secret hätte ausliefern können
Der PR listet danach jede Stelle auf, an der ein veraltetes Lesen ein Secret, einen Token, eine Allowlist, eine Route oder Config ausliefern könnte, und stellt alle zwanzig um. Ein paar, die dem Alltag entsprechen:
| Stelle | Was gecacht wird | Key vorher |
|---|---|---|
hermes_cli/auth.py globaler Auth-Store |
die geteilte auth.json — deine Credentials |
file_signature |
hermes_cli/auth.py Nous-Login-Status |
ob du eingeloggt bist | nur st_mtime, plus 15 s TTL |
gateway/pairing.py Approved-Liste |
die DM-Allowlist | das Vierer-Tupel |
gateway/platforms/webhook.py dynamische Routen |
HMAC-Secrets pro Route | (mtime_ns, size, ino) |
plugins/platforms/feishu/feishu_comment_rules.py |
Feishu-Comment-Pairing-Allowlist und Regeln | nur st_mtime |
tools/mcp_oauth_manager.py |
Neuladen von MCP-OAuth-Tokens | nur st_mtime_ns |
providers/__init__.py benannter Custom-Provider |
welche Route ein Request nimmt | file_signature |
agent/prompt_builder.py Skills-Manifest |
der Skills-Snapshot im Prompt | file_signature |
Schau, was diese Liste berührt: Credentials, Allowlists, Routen und den Prompt selbst. Die beiden Allowlists sind die, bei denen man aufhorchen sollte — das Entziehen von Zugang soll sofort wirken, nicht beim nächsten Neustart.
Konkret nach dem Fix:
- ein rotierter
api_key(k-old→k-new) erreicht Follow-Profile-Sessions ohne Neustart; - ein widerrufenes Pairing hat bei der allernächsten Prüfung keinen Zugang mehr;
- eine Port-Änderung gleicher Länge (
:3→:5) ändert die Route sofort; - ein erneuerter Token oder eine gleich große Korrektur an der Config wird nie vom vorherigen Parse-Ergebnis überlagert.
Was du tun solltest
Viel zu tun gibt es nicht, aber drei Dinge lohnen sich zu wissen.
Erstens: Upgraden. Ein Fix dieser Art erreicht deine Installation mit dem nächsten Tag; CLI-Nutzer gehen über hermes update. Wenn dich die Fallstricke dieses Wegs interessieren, haben wir sie in unserem Upgrade-Guide behandelt.
Zweitens: Hör auf, Neustarts als Mittel einzusetzen, damit Config wirkt. Wenn du dir angewöhnt hast, das Gateway nach jeder Config-Änderung durchzubooten, weil es „sonst manchmal nicht greift“, hast du vermutlich genau das hier getroffen. Nach dem Update verhält sich Live-Reload so, wie du es angenommen hast.
Drittens: Widerrufe verifizieren. Wenn du über die Pairing-Allowlist steuerst, wer mit einem Bot reden darf (der Mechanismus steht in unserer Profil-Allowlist-Erklärung), schick nach dem Widerruf eine Nachricht und prüf, dass sie abgelehnt wird, statt es anzunehmen. Vor diesem Fix war „ich dachte, ich hätte das widerrufen“ ein echtes Loch.
Es lohnt sich außerdem, Secrets richtig abzulegen. Den Unterschied zwischen auth.json und .env und das Verschlüsseln mit dem System-Keychain haben wir in der Anleitung zu Secrets und Verschlüsselung durchgespielt.
Das Fazit
Bösartig macht diesen Bug nicht sein Schwierigkeitsgrad. Es ist die unsichtbare Lücke, die er zwischen „ich habe es geändert“ und „das System benutzt den neuen Wert“ aufreißt. Du hast eine Datei geändert; das System vertraute einem Fingerprint; und der Fingerprint bewegt sich nicht, wenn beide Aktionen im selben Clock-Tick landen. Der Fix folgt einem guten Instinkt: Statt zu raten, welchen Parser ein Client verwendet, macht er „diese könnten sich uneinig sein“ erkennbar — den Inhalt hashen, wo es geht, und jedes Mal hashen, wo es nicht geht. Ein Cache soll eine Performance-Optimierung sein, keine Kopie der Welt von letzter Woche.