Você rotacionou a chave, o processo ficou com a antiga: como o Hermes corrigiu um bug de cache obsoleto no mesmo tick

É uma da manhã e você rotaciona sua chave da OpenRouter, porque a antiga vazou e precisa morrer agora. Você edita a linha no config.yaml, troca a cópia no .env e remove da lista de pareamento um usuário do Telegram que não deveria mais conseguir falar com o seu agente. Você não reinicia nada — o Hermes recarrega mudanças ao vivo, e você confia nisso há meses. De manhã, um profile está usando a chave nova numa boa, enquanto uma sessão de profile secundário continua disparando requisições com a chave antiga e enchendo o log de 401. E o usuário que você revogou? Continua passando.
Não é você esquecendo de salvar. A resposta do PR #145651, mergeado em 27 de outubro, é um pouco contraintuitiva: sua edição chegou ao arquivo, mas o processo acreditava que o arquivo não tinha mudado. E acreditava nisso porque estava observando uma fingerprint que não se move quando a leitura e a escrita caem dentro do mesmo tick do relógio.
O que um cache realmente observa
O Hermes mantém pequenos caches em todo lugar. Ler auth.json uma vez e guardar o parse; ler a lista de pareamento uma vez e guardar quem está aprovado; ler config.yaml uma vez e guardar as rotas de provider. Para evitar re-parse inútil, cada cache consulta antes uma “fingerprint” do arquivo. Os quatro valores de sempre:
mtime— hora da última modificaçãoctime— hora de mudança do inodesize— quantidade de bytesinode— o número do arquivo
Mesma fingerprint, reaproveita o parse. Fingerprint diferente, relê. Isso está certo quase sempre. Dá errado quando dois detalhes se alinham.
Detalhe um: mesmo tamanho, sobrescrita in-place. Transformar api_key: sk-old em api_key: sk-new mantém a contagem de bytes idêntica, e escrever de volta no mesmo lugar — por um editor ou por um script — mantém também o inode. Dois dos quatro valores agora estão garantidamente iguais.
Detalhe dois: resolução de timestamp. Em muitos kernels (notadamente runners de CI e alguns hosts mais antigos sem multigrain timestamps), mtime e ctime vêm de um relógio bem grosso. Se a leitura e a escrita caem no mesmo tick, as duas carregam exatamente o mesmo (mtime_ns, size, ino, ctime_ns). O cache vê uma fingerprint inalterada e conclui: nada a fazer, fica com o parse antigo.
Foi assim que o CI pegou. Um teste escreve config.yaml com a porta 3, lê, e depois reescreve no mesmo lugar com a porta 5 — mesmo tamanho, mesmo inode. A falha de asserção é quase cômica de tão explícita:
E At index 1 diff: 'http://127.0.0.1:3/v1' != 'http://127.0.0.1:5/v1'
Porta 3 e porta 5 têm um caractere cada, então o arquivo tem exatamente o mesmo tamanho em bytes. O teste deveria provar que uma chave rotacionada chega às sessões de profile secundário; provou que o cache estava servindo a rota antiga.
Por que reiniciar “resolve” e o live reload não
Esta é a parte que tira do sério: o bug só existe enquanto o processo está vivo.
Depois de um restart todo cache começa vazio, a primeira leitura precisa parsear, e você vê o valor novo. É também por isso que o sintoma é mal diagnosticado para sempre: “a config não aplicou”, “o live reload está quebrado”, “esse profile travou”. Toda explicação aponta para o sistema de configuração, quando o culpado real é o desenho da chave de cache.
O raio de alcance é maior do que um cache só. Algumas chaves usavam a tupla completa dos quatro valores; outras usavam apenas mtime; outras usavam mtime + size. Contorne uma e outro componente ainda pode te entregar o valor de ontem.
Um helper em vez de vinte patches
A correção não mexe em cada ponto. Ela adiciona um único helper no utils.py da raiz do repositório:
def file_cache_version(path, st=None, *, always_digest=False):
...
Ele retorna duas coisas: a assinatura do arquivo e um digest do conteúdo.
- A assinatura continua sendo a tupla barata do stat.
- O digest segue por padrão uma política de “janela racy”: se o mais recente entre
mtimeectimetiver menos de dois segundos (RACY_MTIME_WINDOW_NS), o conteúdo do arquivo é hasheado com blake2b; assim que o arquivo assenta, o digest vira uma string vazia.
Em termos simples: um arquivo recém-escrito é julgado pelo conteúdo, um arquivo já assentado é julgado pela assinatura de stat. O único custo é um parse extra para uma entrada cacheada enquanto o arquivo ainda estava racy, quando ele assenta. Para um cache revalidado a cada leitura, isso é nada.
always_digest=True existe para outro formato de chamador: uma assinatura guardada agora e comparada muito depois. Para esses, um digest que vira string vazia pareceria uma mudança, então eles hasheiam em toda chamada. O PR lista quais são:
- a assinatura do profile em serviço (uma falsa mudança reconecta os adapters do profile)
- o registro de falha de parse (uma falsa mudança limparia o erro com o arquivo ainda quebrado)
- o consentimento de shell-hook
- o carimbo do inventário do free tier
- o carimbo de vencimento do onboarding
- o manifesto de skills, que é escrito em disco e comparado pelo processo seguinte
Há uma exceção deliberadamente estreita: um worker preso a um snapshot de config congelado nunca hasheia o config.yaml. A checagem fica dentro do helper, então nenhum chamador consegue fazer um worker seguro abrir o arquivo do profile. Outros arquivos — auth.json, .env, listas de pareamento — mantêm o digest dentro dos workers.
E o helper nunca levanta exceção. Dado o stat anterior do chamador, um arquivo que sumiu ou ficou ilegível recebe o nome do erro como digest, e nenhuma versão legível consegue casar com isso. O cache invalida em vez de quebrar.
Vinte caches, qualquer um deles podendo servir um segredo velho
O PR então lista todo ponto em que uma leitura obsoleta poderia servir um segredo, token, allowlist, rota ou config, e converte os vinte. Alguns que aparecem no uso do dia a dia:
| Site | O que cacheia | Chave antes |
|---|---|---|
hermes_cli/auth.py global auth store |
o auth.json compartilhado — suas credenciais |
file_signature |
hermes_cli/auth.py Nous login status |
se você está logado | só st_mtime, mais um TTL de 15 s |
gateway/pairing.py approved list |
a allowlist de DM | a tupla de quatro valores |
gateway/platforms/webhook.py dynamic routes |
segredos HMAC por rota | (mtime_ns, size, ino) |
plugins/platforms/feishu/feishu_comment_rules.py |
allowlist e regras de pareamento de comentários do Feishu | só st_mtime |
tools/mcp_oauth_manager.py |
recarga de tokens OAuth do MCP | só st_mtime_ns |
providers/__init__.py named custom provider |
qual rota uma requisição pega | file_signature |
agent/prompt_builder.py skills manifest |
o snapshot de skills no prompt | file_signature |
Repare no que essa lista toca: credenciais, allowlists, rotas e o próprio prompt. As duas allowlists são as que devem acender o alerta — revogar acesso deveria valer imediatamente, não no próximo restart.
Na prática, depois da correção:
- uma
api_keyrotacionada (k-old→k-new) chega às sessões de profile secundário sem restart; - um pareamento revogado não tem acesso já na checagem seguinte;
- uma edição de porta do mesmo tamanho (
:3→:5) muda a rota na hora; - um token renovado, ou uma correção de config do mesmo tamanho, nunca fica ofuscado pelo parse anterior.
O que você deve fazer
Não há muito o que fazer, mas três coisas valem saber.
Primeiro, atualize. Uma correção assim chega à sua instalação na próxima tag; quem usa CLI passa pelo hermes update. Se quiser as ressalvas desse caminho, cobrimos no nosso guia de atualização.
Segundo, pare de usar restart como forma de fazer a config aplicar. Se você criou o hábito de dar um bounce no gateway depois de cada edição de config porque “senão às vezes não pega”, provavelmente era este bug batendo. Depois da atualização, o live reload se comporta do jeito que você assumia.
Terceiro, verifique revogações. Se você controla quem pode falar com um bot pela allowlist de pareamento (o mecanismo está descrito no nosso texto sobre allowlist de profile), mande uma mensagem depois de revogar e confirme que ela é recusada, em vez de supor que funcionou. Antes desta correção, “achei que tinha revogado aquilo” era um buraco real.
Também vale guardar segredos do jeito certo. Passamos pela diferença entre auth.json e .env, e pela criptografia com o keychain do sistema, no guia de segredos e criptografia.
A lição
O que torna esse bug desagradável não é a dificuldade. É que ele abre uma lacuna invisível entre “eu mudei” e “o sistema está usando o valor novo”. Você mudou um arquivo; o sistema confiou numa fingerprint; e a fingerprint não se move quando as duas ações caem no mesmo tick do relógio. A correção segue um bom instinto: em vez de adivinhar qual parser o cliente vai usar, tornar detectável o “esses dois podem discordar” — hashear o conteúdo onde dá, hashear toda vez onde não dá. Um cache deve ser uma otimização de performance, não uma cópia do mundo da semana passada.