Yurumi: a config que dava razão ao arquivo
yurumi·

Yurumi: a config que dava razão ao arquivo

7 min de leitura← Voltar para timeline

A chave certa, o valor errado

Exportei a chave no shell, subi o serviço, e a chamada saiu assinada com outra credencial. Não veio erro de autenticação: o token antigo ainda valia, só não era o que eu tinha configurado. É o pior formato de defeito de configuração — não quebra nada, apenas faz a configuração mentir.

O Yurumi lê chaves de API de duas origens: as variáveis de ambiente do processo, herdadas de quem o iniciou, e três arquivos .env em cascata, do legado ao mais específico, cada um sobrescrevendo o anterior. A docstring do load_secrets() é curta e não deixa dúvida: “Chaves do processo vencem; arquivos fazem merge”.

O contrato tem motivo e data. Nasceu em 28 de agosto, quando o loader central foi criado para resolver uma parada total: o roteador local de modelos tinha a credencial só no ambiente do processo, o loader antigo lia um único arquivo e pulava o provider principal em silêncio, degradando a extração. A regra existia para impedir que aquilo se repetisse.

Existia no comentário. No código, a ordem era o inverso.

A regra escrita, a ordem invertida

O laço do ambiente rodava antes do laço dos arquivos, e cada keys[k] = v posterior apagava o que o anterior tinha escrito. O ambiente entrava primeiro, os arquivos passavam por cima:

keys = {}
# 1. env do processo (comentado como "já carregado pelo Hermes")
for k, v in os.environ.items():
    if k.endswith("_API_KEY") or k.endswith("_TOKEN"):
        keys[k] = v
# 2-4. arquivos .env em ordem — e aqui o valor do arquivo vencia
for path in _SECRET_ENV_PATHS:
    ...
    keys[k] = v
return keys

Havia uma assimetria que ajudou a esconder o problema: do ambiente só entram chaves terminadas em _API_KEY ou _TOKEN, dos arquivos entra qualquer nome. O arquivo podia injetar nomes que o ambiente nunca forneceria e, de quebra, sobrescrever justamente as chaves filtradas. Na prática, o processo tinha a palavra final apenas quando o arquivo não dizia nada sobre o assunto — o contrário de ter prioridade.

O commit 07c20b8, de 23 de outubro, resolve com seis linhas entrando e quatro saindo de core/config.py. O laço do ambiente é movido para o fim da função, com um comentário que explica a ordem em vez de deixá-la implícita. Precedência declarada em prosa virou precedência declarada em código.

O teste que só provava na máquina certa

O achado veio de um teste de delegação. test_extract_delega_ao_loader_central e test_graph_delega_ao_loader_central afirmam que o extrator e o grafo enxergam, pelo loader central, a mesma chave do ambiente — igualdade direta entre o que o loader devolveu e o que está exportado.

Enquanto o arquivo tivesse o mesmo valor do ambiente, a igualdade passava e o teste parecia proteger o contrato. No dia em que a linha do arquivo ficou velha e os valores divergiram, a asserção quebrou e mostrou que a precedência era ficção.

As duas asserções, porém, continuam cercadas por if os.environ.get("NINEROUTER_API_KEY"):. Quando a variável não existe, o teste não falha — ele simplesmente não testa nada e fica verde por ausência de condição. A prova da precedência existe só nas máquinas onde a variável já está exportada, isto é, exatamente onde ela é desnecessária.

A mesma doença na suíte inteira

O padrão se repetiu nos testes de configuração. test_defaults_quando_sem_env_sem_yaml limpava quatro variáveis antes de conferir os defaults — endereço do banco vetorial, backend, identificador de usuário e o caminho do arquivo de config — enquanto get_config() lê cerca de quinze, entre elas a URL do embedder. No CI, onde nada está exportado, o teste passa. Num shell que carrega as variáveis do serviço, o default vem poluído e o teste quebra fora do CI. Verde no runner, vermelho na máquina, e nenhum dos dois estados dizendo algo sobre o código.

A cura veio no mesmo dia, no commit 14c9f58: uma função _limpa_env_yurumi() que varre os.environ e remove toda variável com prefixo YURUMI_. Não uma lista de quatro nomes que envelhece a cada feature nova — o prefixo inteiro. Com isso a suíte voltou a significar algo: 3717 testes, zero falhas, medidos na máquina de trabalho e não só no CI.

O mesmo commit ajustou o rastro: o painel de consumo do roteador, com 564 linhas novas em core/usage.py, adicionou superfície própria de leitura HTTP; o teste que conta chamadas de urlopen ganhou baseline novo e o bandit acusou B310 até a exceção ser justificada com o motivo real — a leitura é de um serviço em localhost. Exceção sem motivo escrito é exceção que não existe.

No caminho, a versão 0.2.0 também acertou o nome da credencial: o campo key_env deixou de esperar 9ROUTER_API_KEY e passou a exigir NINEROUTER_API_KEY, o nome canônico que os módulos que consultam o mesmo serviço já usavam. Uma chave, um nome, seis lugares.

O que isso muda

São duas correções com a mesma raiz: estado ambiente implícito decidindo resultado. O carregador obedecia ao arquivo mais próximo; os testes obedeciam ao shell de quem executava. Nos dois casos o software funcionava e a explicação era falsa. Chave velha no arquivo e ambiente limpo: passa. Chave certa no ambiente e arquivo velho: falha — e quem investiga tende a culpar o serviço que recebeu a credencial, não o loader que escolheu por conta própria.

A partir daqui, a precedência é uma linha de código com um comentário que diz por que ela está naquela posição, e a suíte é hermética por construção: qualquer YURUMI_* exportada pelo operador desaparece antes do primeiro assert. Se o verde depende do ambiente, ele não é verde — é coincidência.

Próximo passo

Falta fechar a última porta: as duas asserções de delegação precisam perder a guarda condicional e virar um teste hermético — arquivo fake com valor antigo, ambiente com valor novo, afirmação explícita de que o ambiente vence. Enquanto a prova depender de uma variável que talvez esteja exportada, o contrato é verificado por acidente.

# a chave do ambiente entra, ou o arquivo passa por cima?
NINEROUTER_API_KEY=do-ambiente pytest tests/test_secrets_loader.py -q

# quais nomes o loader enxerga (só as chaves, nunca os valores)
python3 -c "from core.config import load_secrets; print(sorted(load_secrets()))"

# hermeticidade: a suíte não deve reagir ao ambiente do operador
YURUMI_EMBED_URL=http://exemplo pytest tests/test_config.py -q

Leia também

Mesmo projeto, mesma trilha — as histórias se conectam.

Próximo passo

Se isto te serviu, tem mais: escrevo sobre o mesmo assunto nos outros projetos, e a loja tem as ferramentas que eu uso aqui.

Sem spam, descadastro de um clique. Também estou aqui: Loja de skills · Portfólio