fix(auth): close Anthropic OAuth review gaps

This commit is contained in:
joaomarcos
2026-08-26 13:38:26 -03:00
committed by Teknium
parent e1a210652a
commit 0099f250c2
13 changed files with 788 additions and 153 deletions
+53 -41
View File
@@ -1,12 +1,14 @@
# Anthropic OAuth — Issues encontrados no hermes-agent # Anthropic OAuth — Issues encontrados no hermes-agent
> **Atualização:** todos os 5 bugs abaixo (incluindo o Bug 5, achado pelo teste de esforço) foram **corrigidos** nesta sessão. > **Estado atual do PR #87891:** os achados abaixo são históricos e devem ser lidos junto com o estado atual. O fluxo OAuth Anthropic do dashboard foi removido, não corrigido em linha: o catálogo expõe `flow: "external"` e as rotas `start`/`submit` rejeitam o fluxo. O fluxo interativo de terminal (`hermes auth add anthropic`) permanece explicitamente fora do escopo desta remoção.
> Issues: [#87887](https://github.com/NousResearch/hermes-agent/issues/87887) (bugs 1-2) · [#87888](https://github.com/NousResearch/hermes-agent/issues/87888) (bugs 3-4) · [#87889](https://github.com/NousResearch/hermes-agent/issues/87889) (bug 5). > Issues: [#87887](https://github.com/NousResearch/hermes-agent/issues/87887) (bugs 1-2) · [#87888](https://github.com/NousResearch/hermes-agent/issues/87888) (bugs 3-4) · [#87889](https://github.com/NousResearch/hermes-agent/issues/87889) (bug 5).
> >
> **Atualização 2 (review do PR #87891):** em vez de manter os patches dos bugs 1, 2 e 4 abaixo, o fluxo de login OAuth do **dashboard web** (`_start_anthropic_pkce` / `_submit_anthropic_pkce` / `_save_anthropic_oauth_creds` em `hermes_cli/web_server.py`) foi **removido por completo**. Um endpoint HTTP não-supervisionado emitindo tokens de assinatura Claude Pro/Max fora do cliente oficial da Anthropic esbarra nas políticas de uso da Anthropic para credenciais OAuth, então a correção mais segura é eliminar essa superfície em vez de só corrigi-la. O catálogo de providers do dashboard agora marca `anthropic` como `flow: "external"`, apontando para `hermes auth add anthropic` (o fluxo PKCE via terminal, fora do escopo desta mudança e não afetado). Bugs 3 e 5 (race condition no refresh cross-processo e o `PermissionError` no lock do Windows) continuam corrigidos como descrito abaixo — são bugs do fluxo de login `claude_code`/CLI, que permanece. > **Decisão de segurança:** um endpoint HTTP não-supervisionado emitindo tokens de assinatura Claude Pro/Max fora do cliente oficial da Anthropic fica fora da política aceita para este produto. A correção é eliminar essa superfície, não mantê-la com patches de CSRF ou de persistência.
>
> **Controles que permanecem:** a serialização de refresh inclui `anthropic`; a fonte compartilhada `claude_code` usa um lock baseado no arquivo compartilhado tanto no `CredentialPool` quanto no resolver direto; o write-through de `hermes_pkce` atualiza `.anthropic_oauth.json`; e o lock Windows trata a inicialização concorrente do arquivo. A cobertura atual inclui teste com processos independentes e perfis distintos.
**Data:** 2026-08-16 **Data:** 2026-08-16
**Branch:** `fix/update-orphan-history-guard-87694` **Branch do PR:** `fix/anthropic-oauth-csrf-race-apikey-shadow`
**Pedido original:** investigar se o OAuth da Anthropic tem bug real — rotas, race conditions, zombie processes, segurança — e provar com testes. **Pedido original:** investigar se o OAuth da Anthropic tem bug real — rotas, race conditions, zombie processes, segurança — e provar com testes.
--- ---
@@ -15,34 +17,32 @@
**Sim, funciona.** Nenhum dos bugs encontrados impede login ou uso normal. São dois problemas distintos, ambos reais e comprovados por teste, mas nenhum é "quebrado/não autentica": **Sim, funciona.** Nenhum dos bugs encontrados impede login ou uso normal. São dois problemas distintos, ambos reais e comprovados por teste, mas nenhum é "quebrado/não autentica":
| Fluxo de login | Afetado por quê | | Fluxo de login | Estado no PR #87891 |
|---|---| |---|---|
| `claude setup-token` (CLI oficial Anthropic, credencial lida de `~/.claude/.credentials.json`, `source="claude_code"`) | Nenhum dos bugs 1-3 — é o único caminho protegido nos dois casos | | `claude setup-token` (CLI oficial Anthropic, credencial lida de `~/.claude/.credentials.json`, `source="claude_code"`) | Permanece disponível; o refresh compartilhado é serializado e revalidado |
| `hermes model` no terminal, opção 1 (OAuth via CLI) | Bug de race condition (só sob múltiplos processos Hermes concorrentes); limpa `ANTHROPIC_API_KEY` corretamente, então **não** sofre o Bug 4 | | `hermes auth add anthropic` no terminal | Permanece disponível e escreve a credencial Hermes; o refresh de `hermes_pkce` tem write-through |
| Login via **dashboard web** do Hermes | Bug de segurança (CSRF/PKCE leak, bugs 1-2) + race condition (bug 3) + **Bug 4: não limpa `ANTHROPIC_API_KEY` antiga, que continua vencendo o OAuth** | | Login Anthropic no **dashboard web** do Hermes | Removido; o catálogo retorna `flow: "external"` e não há emissão de tokens por HTTP |
**Atualização:** o sintoma relatado pelo usuário ("configuro com OAuth e o Hermes usa API key mesmo assim") foi diagnosticado como o **Bug 4** — ver seção dedicada abaixo. **Nota sobre o sintoma original:** o caminho dashboard que podia deixar uma API key antiga sombrear o OAuth não existe mais. A prioridade de uma API key explícita na resolução continua sendo deliberada para credenciais configuradas pelo usuário.
- **Bug de segurança:** não impede login, mas expõe o fluxo do dashboard a um vetor de CSRF já corrigido no CLI e nunca replicado lá. - **Bug de segurança histórico:** não impede login, mas expunha o fluxo do dashboard a um vetor de CSRF. A superfície HTTP foi removida no PR; o fluxo de terminal continua separado.
- **Bug de race condition:** só aparece com **múltiplos processos Hermes rodando ao mesmo tempo** (fleet workers, cron + sessão interativa) disputando o refresh do mesmo token no exato instante em que ele expira. Uso single-process (maioria dos usuários, a maior parte do tempo) nunca bate nisso. - **Bug de race condition:** só aparece com **múltiplos processos Hermes rodando ao mesmo tempo** (fleet workers, cron + sessão interativa) disputando o refresh do mesmo token no exato instante em que ele expira. Uso single-process (maioria dos usuários, a maior parte do tempo) nunca bate nisso.
--- ---
## O que foi feito nesta investigação ## O que foi feito nesta investigação
1. Localizado o código real do fluxo Anthropic (não documentação): `agent/anthropic_adapter.py`, `agent/credential_pool.py`, `hermes_cli/auth.py`, `hermes_cli/auth_commands.py`, `hermes_cli/web_server.py`. 1. Localizado o código real nos módulos `agent/anthropic_adapter.py`, `agent/credential_pool.py`, `hermes_cli/auth.py`, `hermes_cli/auth_commands.py` e `hermes_cli/web_server.py`.
2. Comparado o fluxo de login via CLI (`run_hermes_oauth_login_pure`) com o fluxo paralelo do dashboard web (`_start_anthropic_pkce` / `_submit_anthropic_pkce`) — achado o bug de CSRF por essa comparação. 2. Comparado o fluxo interativo de terminal com a implementação paralela do dashboard e confirmado que os bugs de CSRF/PKCE pertenciam à superfície HTTP removida.
3. Comparado a proteção cross-processo que Codex/xAI recebem no refresh (`_auth_store_lock`) com o que a Anthropic recebe — achado o bug de race condition por essa comparação. 3. Comparado o lock cross-processo de Codex/xAI com a Anthropic e alinhado a autoridade do lock à fonte real: `auth.json` por perfil e `~/.claude/.credentials.json` para `claude_code`.
4. Escritos dois arquivos de teste novos que **provam** os bugs (testes que devem falhar contra o código atual, e falham): 4. Mantida a regressão de refresh em `tests/agent/test_credential_pool_anthropic_refresh_race.py`, ampliada a carga em `tests/agent/test_anthropic_oauth_stress.py` e coberto o lock Windows em `tests/hermes_cli/test_auth_store_lock_concurrent.py`.
- `tests/hermes_cli/test_anthropic_dashboard_pkce_csrf.py` (5 testes) 5. Adicionada cobertura de dispatcher para provar que o dashboard não cria sessão nem emite URL/código OAuth Anthropic.
- `tests/agent/test_credential_pool_anthropic_refresh_race.py` (3 testes)
5. Rodado `pytest` nos dois arquivos: **7 failed, 1 passed** — confirma os bugs objetivamente (não é opinião).
6. Investigado zombie process especificamente no fluxo Anthropic — **não encontrado**. 6. Investigado zombie process especificamente no fluxo Anthropic — **não encontrado**.
7. Nenhum código de produção foi alterado — só testes de regressão + este relatório. 7. A documentação desta investigação foi atualizada para refletir a remoção do fluxo dashboard e os testes atuais; não representa uma reprodução contra o código atual quando descreve o estado anterior.
--- ---
## Bug 1 — PKCE `code_verifier` vazado como `state` (só no dashboard web) ## Bug 1 (histórico) — PKCE `code_verifier` vazado como `state` (só no dashboard web)
**Onde:** `hermes_cli/web_server.py`, função `_start_anthropic_pkce()` (~linha 10637-10661) **Onde:** `hermes_cli/web_server.py`, função `_start_anthropic_pkce()` (~linha 10637-10661)
@@ -59,7 +59,7 @@ params = {
} }
``` ```
Isso é **exatamente** o bug que já existiu no fluxo de CLI e foi corrigido (histórico documentado em `tests/agent/test_anthropic_oauth_pkce.py`: PR #1775 corrigiu → PR #2647 reintroduziu → PR #3107 removeu a função antiga → PR #10699/issue #10693 corrigiu de vez na função sobrevivente). O CLI (`agent/anthropic_adapter.py::run_hermes_oauth_login_pure`) gera um `state` independente (`secrets.token_urlsafe(32)`). O **dashboard web nunca recebeu essa correção** — é uma implementação paralela que reintroduz o problema numa rota diferente. Isso era **exatamente** o bug que já existiu no fluxo de CLI e foi corrigido (histórico documentado em `tests/agent/test_anthropic_oauth_pkce.py`: PR #1775 corrigiu → PR #2647 reintroduziu → PR #3107 removeu a função antiga → PR #10699/issue #10693 corrigiu de vez na função sobrevivente). O CLI (`agent/anthropic_adapter.py::run_hermes_oauth_login_pure`) gera um `state` independente (`secrets.token_urlsafe(32)`). O dashboard web tinha uma implementação paralela; no PR #87891 essa implementação foi removida, portanto o código vulnerável abaixo não é mais um call path ativo.
**Consequência:** o `code_verifier` (que por RFC 7636 §7.2 deve ficar confidencial) vaza via histórico do navegador, cabeçalho `Referer`, e logs de acesso da Anthropic (`platform.claude.com`). **Consequência:** o `code_verifier` (que por RFC 7636 §7.2 deve ficar confidencial) vaza via histórico do navegador, cabeçalho `Referer`, e logs de acesso da Anthropic (`platform.claude.com`).
@@ -69,11 +69,11 @@ assert 'dg7ihLbmv6ooNu2-V_dKcVPKp29kNJewr4s8UBSr1gE' != 'dg7ihLbmv6ooNu2-V_dKcVP
``` ```
state e verifier são literalmente o mesmo valor. state e verifier são literalmente o mesmo valor.
**Status:** ✅ **corrigido** — `_start_anthropic_pkce()` agora gera `state` independente via `secrets.token_urlsafe(32)`. **Status no PR:** ✅ **neutralizado por remoção** — `_start_anthropic_pkce()` não existe mais e o catálogo Anthropic é `flow: "external"`. O teste atual verifica que a rota `start` rejeita o fluxo, em vez de testar uma função removida.
--- ---
## Bug 2 — Callback do dashboard nunca valida o `state` (zero proteção CSRF) ## Bug 2 (histórico) — Callback do dashboard nunca valida o `state` (zero proteção CSRF)
**Onde:** `hermes_cli/web_server.py`, função `_submit_anthropic_pkce()` (~linha 10664-10692) **Onde:** `hermes_cli/web_server.py`, função `_submit_anthropic_pkce()` (~linha 10664-10692)
@@ -86,11 +86,11 @@ exchange_data = json.dumps({
}).encode() }).encode()
``` ```
O código nunca faz `if state_from_callback != sess["state"]: reject`. Qualquer valor (ou nenhum) é aceito e a troca de token prossegue. O CLI faz essa checagem (`if received_state != oauth_state: abort`); o dashboard não faz nenhuma. O código antigo nunca fazia `if state_from_callback != sess["state"]: reject`. Qualquer valor (ou nenhum) era aceito e a troca de token prosseguia. O CLI faz essa checagem (`if received_state != oauth_state: abort`); o dashboard não tem mais esse endpoint.
**Evidência (teste real rodado):** payload POST real capturado mesmo com `state` adulterado (`attacker-controlled-state`) — a troca de token aconteceu do mesmo jeito. **Evidência (teste real rodado):** payload POST real capturado mesmo com `state` adulterado (`attacker-controlled-state`) — a troca de token aconteceu do mesmo jeito.
**Status:** ✅ **corrigido** — `_submit_anthropic_pkce()` agora rejeita a troca de token quando o `state` não bate. **Status no PR:** ✅ **neutralizado por remoção** — `_submit_anthropic_pkce()` não existe mais; a rota `submit` genérica rejeita o provider Anthropic e não cria nem completa uma sessão OAuth.
--- ---
@@ -114,7 +114,7 @@ O único caminho de recuperação em caso de falha (`_sync_anthropic_entry_from_
if self.provider != "anthropic" or entry.source != "claude_code": if self.provider != "anthropic" or entry.source != "claude_code":
return entry return entry
``` ```
Credenciais de login nativo do Hermes (`hermes_pkce`) ou do dashboard (`manual:dashboard_pkce`) **não têm recuperação nenhuma**. Se perdem a corrida, caem em `self._mark_exhausted(entry, None)` (linha 1705) — mesmo com um token válido existindo em disco, escrito pelo processo que ganhou. Credenciais de login nativo do Hermes (`manual:hermes_pkce`, gravadas em `~/.hermes/.anthropic_oauth.json`, e a forma sem prefixo usada pelo seeding) ou as antigas emitidas pelo dashboard (`manual:dashboard_pkce`) **não tinham recuperação nenhuma**. Ao perder a corrida, o processo caía em `self._mark_exhausted(entry, None)` — mesmo com um token válido existindo em disco, escrito pelo processo vencedor.
**Cenário real (não ataque, uso normal):** dois processos Hermes concorrentes (ex: fleet worker + sessão CLI, ou dois cron jobs) compartilhando a mesma conta Anthropic via login nativo. Token expira; ambos tentam refresh ao mesmo tempo; servidor da Anthropic aceita só o primeiro; o segundo recebe `invalid_grant` e fica marcado como exhausted — pedindo reautenticação mesmo com credencial válida disponível. **Cenário real (não ataque, uso normal):** dois processos Hermes concorrentes (ex: fleet worker + sessão CLI, ou dois cron jobs) compartilhando a mesma conta Anthropic via login nativo. Token expira; ambos tentam refresh ao mesmo tempo; servidor da Anthropic aceita só o primeiro; o segundo recebe `invalid_grant` e fica marcado como exhausted — pedindo reautenticação mesmo com credencial válida disponível.
@@ -130,7 +130,7 @@ PASSED test_concurrent_claude_code_refresh_recovers_via_credentials_file
# contraste: fonte 'claude_code' SE recupera — prova a assimetria # contraste: fonte 'claude_code' SE recupera — prova a assimetria
``` ```
**Status:** ✅ **corrigido**. `"anthropic"` foi incluído na tupla protegida por `_auth_store_lock`, e um novo método `_sync_anthropic_entry_from_pool_store()` (espelhando o padrão já usado pelo xAI) resincroniza a partir do próprio credential-pool store — funciona pra **todas** as fontes (`claude_code`, `hermes_pkce`, `manual:dashboard_pkce`), não só `claude_code`. **Status no PR:** ✅ **corrigido para os fluxos que permanecem**. `"anthropic"` foi incluído na tupla protegida por `_auth_store_lock`; `claude_code` também usa um lock keyed ao arquivo compartilhado `~/.claude/.credentials.json`; e `_sync_anthropic_entry_from_pool_store()` cobre fontes persistidas do pool. O dashboard `manual:dashboard_pkce` foi removido, não é mais uma fonte emitida.
--- ---
@@ -152,7 +152,7 @@ Ordem de prioridade na resolução de credencial:
O comentário do próprio código é explícito sobre a intenção: "An explicit user-configured key must not be shadowed by auto-discovered [...] credential-pool OAuth credentials." — ou seja, se `ANTHROPIC_API_KEY` estiver preenchida (mesmo que antiga/esquecida), ela sempre ganha da credencial OAuth do pool (prioridade 3 bate prioridade 5), mesmo depois de um login OAuth bem-sucedido. O comentário do próprio código é explícito sobre a intenção: "An explicit user-configured key must not be shadowed by auto-discovered [...] credential-pool OAuth credentials." — ou seja, se `ANTHROPIC_API_KEY` estiver preenchida (mesmo que antiga/esquecida), ela sempre ganha da credencial OAuth do pool (prioridade 3 bate prioridade 5), mesmo depois de um login OAuth bem-sucedido.
**A causa raiz específica:** o fluxo de login OAuth do **dashboard web** (`hermes_cli/web_server.py::_save_anthropic_oauth_creds`) grava a credencial no credential pool, mas **nunca limpa** a variável `ANTHROPIC_API_KEY`. Isso é diferente do fluxo de OAuth via **CLI** (`hermes model` → opção 1 → `save_anthropic_oauth_token()`), que limpa a API key antiga automaticamente ao salvar o token OAuth. Resultado: se em qualquer momento anterior uma `ANTHROPIC_API_KEY` ficou configurada (setup antigo, teste, auto-detecção), fazer login OAuth pelo dashboard não a remove, e ela continua vencendo pra sempre na resolução de credencial. **A causa raiz específica:** o fluxo de login OAuth do **dashboard web** (`hermes_cli/web_server.py::_save_anthropic_oauth_creds`) gravava a credencial no credential pool, mas **não limpava** a variável `ANTHROPIC_API_KEY`. Esse fluxo foi removido no PR. O fluxo de OAuth via **CLI** (`hermes model` → opção 1 → `save_anthropic_oauth_token()`), que limpa a API key antiga automaticamente ao salvar o token OAuth, é separado e permanece fora do escopo.
**Correção recomendada:** em `_save_anthropic_oauth_creds()` (web_server.py), limpar `ANTHROPIC_API_KEY` do mesmo jeito que `save_anthropic_oauth_token()` já faz no fluxo de CLI — ou, na resolução (`resolve_anthropic_token`), preferir a credencial OAuth mais recente quando o usuário acabou de completar um login explícito, em vez de uma API key estática que pode estar obsoleta. **Correção recomendada:** em `_save_anthropic_oauth_creds()` (web_server.py), limpar `ANTHROPIC_API_KEY` do mesmo jeito que `save_anthropic_oauth_token()` já faz no fluxo de CLI — ou, na resolução (`resolve_anthropic_token`), preferir a credencial OAuth mais recente quando o usuário acabou de completar um login explícito, em vez de uma API key estática que pode estar obsoleta.
@@ -163,13 +163,13 @@ hermes doctor
``` ```
Se estiver preenchida, é essa a causa. Correção rápida: esvaziar `ANTHROPIC_API_KEY` no `.env` do Hermes, ou refazer o login OAuth pela opção 1 do `hermes model` no terminal (que já limpa corretamente). Se estiver preenchida, é essa a causa. Correção rápida: esvaziar `ANTHROPIC_API_KEY` no `.env` do Hermes, ou refazer o login OAuth pela opção 1 do `hermes model` no terminal (que já limpa corretamente).
**Status:** ✅ **corrigido**. `_save_anthropic_oauth_creds()` agora limpa `ANTHROPIC_API_KEY` ao salvar o login OAuth do dashboard, igual o fluxo de CLI já fazia. **Status no PR:** ✅ **neutralizado por remoção** — não existe mais `_save_anthropic_oauth_creds()` nem login Anthropic no dashboard. A prioridade de `ANTHROPIC_API_KEY` na resolução continua deliberada para uma chave explicitamente configurada.
--- ---
## Bug 5 — PermissionError sob concorrência real no lock cross-processo (achado pelo teste de esforço) ## Bug 5 — PermissionError sob concorrência real no lock cross-processo (achado pelo teste de esforço)
**Como foi achado:** ao escrever um teste de carga (`tests/agent/test_anthropic_oauth_stress.py`) simulando 20 "processos Hermes" concorrentes disputando o refresh (necessário pra validar o Bug 3 sob estresse, não só com 2 threads), o teste **falhou de verdade** — 16 de 20 threads levantaram `PermissionError: [Errno 13] Permission denied` vindo de dentro do próprio `_auth_store_lock()`. **Como foi achado:** ao escrever um teste de carga (`tests/agent/test_anthropic_oauth_stress.py`) simulando concorrência real no refresh (necessário pra validar o Bug 3 sob estresse, não só com uma chamada isolada), a inicialização do lock no Windows podia levantar `PermissionError: [Errno 13] Permission denied` vindo de dentro do próprio `_auth_store_lock()`.
**Causa raiz:** `hermes_cli/auth.py::_file_lock()` — a checagem "garante que o arquivo de lock tem pelo menos 1 byte" (necessária pro `msvcrt.locking()` do Windows) fazia um `write_text()` **sem tratamento de exceção**, fora do loop de retry que existe logo depois: **Causa raiz:** `hermes_cli/auth.py::_file_lock()` — a checagem "garante que o arquivo de lock tem pelo menos 1 byte" (necessária pro `msvcrt.locking()` do Windows) fazia um `write_text()` **sem tratamento de exceção**, fora do loop de retry que existe logo depois:
@@ -191,7 +191,7 @@ if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0):
pass # outro holder já garantiu conteúdo; segue pro loop de retry pass # outro holder já garantiu conteúdo; segue pro loop de retry
``` ```
**Status:** ✅ **corrigido**. Testes: `tests/agent/test_anthropic_oauth_stress.py` (reproduz de forma confiável, 16/20 falhas antes do fix, 0 depois) e `tests/hermes_cli/test_auth_store_lock_concurrent.py` (cobertura dedicada e genérica do lock, independente da Anthropic). **Status no PR:** ✅ **corrigido**. O write de inicialização do lock é best-effort e qualquer contenção segue para o loop de retry. A cobertura do lock é `windows_only`, e o teste de refresh com perfis distintos usa processos independentes e exige exatamente um POST do refresh token compartilhado.
--- ---
@@ -206,31 +206,43 @@ Checado especificamente no fluxo Anthropic:
--- ---
## Arquivos criados nesta investigação ## Arquivos e cobertura atuais do PR
| Arquivo | O que é | | Arquivo | O que é |
|---|---| |---|---|
| `tests/hermes_cli/test_anthropic_dashboard_pkce_csrf.py` | 5 testes provando bugs 1 e 2 | | `tests/hermes_cli/test_web_oauth_dispatch.py` | Dispatcher: Anthropic external; `start`/`submit` dashboard rejeitados |
| `tests/agent/test_credential_pool_anthropic_refresh_race.py` | 3 testes provando bug 3 | | `tests/agent/test_credential_pool_anthropic_refresh_race.py` | 3 testes de lock/sincronização Anthropic |
| `RELATORIO_ANTHROPIC_OAUTH_BUGS.md` | Relatório técnico detalhado (versão anterior desta investigação, bugs 1-3) | | `tests/agent/test_anthropic_oauth_stress.py` | Carga em threads + processo cross-profile com POST único |
| `ANTHROPIC_ISSUES.md` | Este arquivo — resumo consolidado, inclui bug 4 (causa real do "não funciona" relatado) | | `tests/agent/test_anthropic_keychain.py` | Resolver direto Claude Code sob lock compartilhado |
| `tests/hermes_cli/test_auth_store_lock_concurrent.py` | Concorrência do lock real, marcada `windows_only` |
| `tests/agent/test_credential_pool_oauth_writethrough.py` | Rotação `hermes_pkce` preservada no `.anthropic_oauth.json` |
| `RELATORIO_ANTHROPIC_OAUTH_BUGS.md` | Relatório técnico histórico e estado da implementação |
| `ANTHROPIC_ISSUES.md` | Este arquivo — resumo consolidado e matriz de escopo |
**Nota sobre dados sensíveis:** este relatório foi verificado com busca por regex de chaves reais (`sk-ant-...`, `ANTHROPIC_API_KEY=`, `ANTHROPIC_TOKEN=`) — nenhuma encontrada. Só há valores sintéticos usados nos testes automatizados (ex: `sk-ant-oat-rotated-1`), nunca uma chave real. O diagnóstico do Bug 4 pede pro usuário rodar `hermes doctor` / checar o próprio `.env` localmente, mas o resultado desse comando **não foi colado neste relatório** — só a explicação da causa raiz no código. **Nota sobre dados sensíveis:** este relatório foi verificado com busca por regex de chaves reais (`sk-ant-...`, `ANTHROPIC_API_KEY=`, `ANTHROPIC_TOKEN=`) — nenhuma encontrada. Só há valores sintéticos usados nos testes automatizados (ex: `sk-ant-oat-rotated-1`), nunca uma chave real. O diagnóstico do Bug 4 pede pro usuário rodar `hermes doctor` / checar o próprio `.env` localmente, mas o resultado desse comando **não foi colado neste relatório** — só a explicação da causa raiz no código.
Nenhum arquivo de produção foi alterado. Arquivos de produção alterados pelo PR: `agent/anthropic_adapter.py`, `agent/credential_pool.py`, `hermes_cli/auth.py` e `hermes_cli/web_server.py`. O frontend também recebe a correção de escopo em `web/src/lib/api.ts` para que operações OAuth sigam o perfil selecionado.
## Como reproduzir ## Como validar o estado atual
```bash ```bash
python -m pytest tests/hermes_cli/test_anthropic_dashboard_pkce_csrf.py tests/agent/test_credential_pool_anthropic_refresh_race.py -v scripts/run_tests.sh tests/hermes_cli/test_web_oauth_dispatch.py tests/agent/test_credential_pool_anthropic_refresh_race.py tests/agent/test_anthropic_oauth_stress.py tests/agent/test_credential_pool_oauth_writethrough.py tests/agent/test_anthropic_keychain.py tests/hermes_cli/test_auth_store_lock_concurrent.py -q
``` ```
Resultado com o código atual (não corrigido): **7 failed, 1 passed**. Para a cobertura frontend de perfil OAuth:
```bash
cd web
npx vitest run src/lib/api.test.ts
```
Os resultados dependem do ambiente e devem ser registrados no PR junto com a cabeça testada; os blocos históricos acima não são resultados do código atual.
## Referência rápida de linhas ## Referência rápida de linhas
| Arquivo | Linhas | | Arquivo | Linhas |
|---|---| |---|---|
| `hermes_cli/web_server.py` | 10637-10661 (`_start_anthropic_pkce`), 10664-10728 (`_submit_anthropic_pkce`) | | `hermes_cli/web_server.py` | Catálogo `anthropic` como `flow: "external"`; dispatcher rejeita `start`/`submit` |
| `agent/credential_pool.py` | 1307-1344 (`_refresh_entry`), 855-905 (`_sync_anthropic_entry_from_credentials_file`), 1442-1483 (recuperação), 1705 (`_mark_exhausted`) | | `agent/credential_pool.py` | `_refresh_entry`, `_sync_anthropic_entry_from_pool_store`, lock compartilhado Claude Code e fallback de recuperação |
| `agent/anthropic_adapter.py` | 1125-1186 (`refresh_anthropic_oauth_pure`), 1189-1239 (`_refresh_oauth_token`), 1531-1658 (`run_hermes_oauth_login_pure`, fluxo CLI correto) | | `agent/anthropic_adapter.py` | `claude_code_credentials_path`, refresh puro, fluxo CLI PKCE separado e write-through Hermes |
| `web/src/lib/api.ts` | `/api/providers/oauth` incluído no escopo de perfil do dashboard |
+33 -33
View File
@@ -1,15 +1,17 @@
# Relatório de Investigação — Bugs no fluxo OAuth da Anthropic (hermes-agent) # Relatório de Investigação — Bugs no fluxo OAuth da Anthropic (hermes-agent)
> **Atualização (review do PR #87891):** o fluxo de login OAuth do **dashboard web** (`_start_anthropic_pkce` / `_submit_anthropic_pkce` / `_save_anthropic_oauth_creds` em `hermes_cli/web_server.py`) foi **removido por completo**, em vez de mantido com os patches dos bugs 1, 2 e 4 descritos abaixo. Um endpoint HTTP não-supervisionado emitindo tokens de assinatura Claude Pro/Max fora do cliente oficial da Anthropic esbarra nas políticas de uso da Anthropic para credenciais OAuth. O catálogo de providers do dashboard agora marca `anthropic` como `flow: "external"`, apontando para `hermes auth add anthropic` (fluxo PKCE via terminal, fora do escopo desta mudança). Os testes `tests/hermes_cli/test_anthropic_dashboard_pkce_csrf.py` e `tests/hermes_cli/test_web_server_oauth_write.py` foram removidos por testarem exclusivamente código agora inexistente. Bugs 3 e 5 (race condition no refresh cross-processo e o `PermissionError` no lock do Windows) seguem corrigidos como descrito abaixo — afetam o fluxo de login `claude_code`/CLI, que permanece. > **Estado atual do PR #87891:** este arquivo preserva os achados contra a implementação anterior. O fluxo de login OAuth do **dashboard web** (`_start_anthropic_pkce` / `_submit_anthropic_pkce` / `_save_anthropic_oauth_creds`) foi removido por completo, em vez de mantido com patches de CSRF ou de shadowing. Um endpoint HTTP não-supervisionado emitindo tokens de assinatura Claude Pro/Max fora do cliente oficial da Anthropic fica fora da política aceita para este produto. O catálogo marca `anthropic` como `flow: "external"`, e as rotas `start`/`submit` rejeitam esse fluxo. O fluxo PKCE interativo de terminal (`hermes auth add anthropic`) permanece explicitamente fora do escopo desta remoção.
>
> Os testes `tests/hermes_cli/test_anthropic_dashboard_pkce_csrf.py` e `tests/hermes_cli/test_web_server_oauth_write.py` foram removidos por testarem exclusivamente código inexistente na cabeça atual. Bugs 3 e 5 continuam cobertos: refresh Anthropic com lock cross-processo, lock adicional para o arquivo compartilhado `claude_code` (inclusive no resolver direto), write-through de `hermes_pkce`, e cobertura nativa Windows.
**Data:** 2026-08-16 **Data do achado original:** 2026-08-16
**Branch:** `fix/update-orphan-history-guard-87694` **Branch do PR:** `fix/anthropic-oauth-csrf-race-apikey-shadow`
**Escopo investigado:** `agent/anthropic_adapter.py`, `agent/credential_pool.py`, `hermes_cli/auth.py`, `hermes_cli/auth_commands.py`, `hermes_cli/web_server.py` **Escopo investigado:** `agent/anthropic_adapter.py`, `agent/credential_pool.py`, `hermes_cli/auth.py`, `hermes_cli/auth_commands.py`, `hermes_cli/web_server.py`
**Testes novos:** `tests/hermes_cli/test_anthropic_dashboard_pkce_csrf.py`, `tests/agent/test_credential_pool_anthropic_refresh_race.py` **Cobertura atual:** `tests/hermes_cli/test_web_oauth_dispatch.py`, `tests/agent/test_credential_pool_anthropic_refresh_race.py`, `tests/agent/test_anthropic_oauth_stress.py`, `tests/agent/test_credential_pool_oauth_writethrough.py`, `tests/agent/test_anthropic_keychain.py`, `tests/hermes_cli/test_auth_store_lock_concurrent.py` e `web/src/lib/api.test.ts`
## Resumo executivo ## Resumo executivo
Foram encontrados **dois bugs reais e reproduzíveis** no fluxo OAuth da Anthropic, ambos confirmados por testes automatizados que falham contra o código atual (evidência objetiva, não especulação): Foram encontrados **dois bugs reais e reproduzíveis** na implementação anterior do fluxo OAuth da Anthropic, ambos confirmados por testes automatizados que falhavam contra aquela versão (evidência objetiva, não especulação):
| # | Bug | Categoria | Severidade | | # | Bug | Categoria | Severidade |
|---|-----|-----------|------------| |---|-----|-----------|------------|
@@ -21,7 +23,7 @@ Não foi encontrada evidência de **zombie processes** no fluxo Anthropic especi
--- ---
## 1. Bug de segurança — PKCE `code_verifier` vazado como `state` (dashboard web) ## 1. Bug histórico de segurança — PKCE `code_verifier` vazado como `state` (dashboard web)
### Onde ### Onde
@@ -88,7 +90,7 @@ params = {..., "state": oauth_state}
--- ---
## 2. Bug de segurança — ausência total de validação de `state` no callback do dashboard ## 2. Bug histórico de segurança — ausência total de validação de `state` no callback do dashboard
### Onde ### Onde
@@ -148,7 +150,9 @@ if not state_from_callback or state_from_callback != sess["state"]:
### Onde ### Onde
`agent/credential_pool.py`, método `CredentialPool._refresh_entry()` (~linha 1307-1344): `agent/credential_pool.py`, método `CredentialPool._refresh_entry()` (implementação anterior; as linhas atuais mudaram):
O trecho abaixo registra a condição **antes** do PR e não descreve o código atual:
```python ```python
# Codex and xAI OAuth refresh tokens are single-use. The # Codex and xAI OAuth refresh tokens are single-use. The
@@ -164,7 +168,7 @@ if self.provider in ("openai-codex", "xai-oauth"):
return self._refresh_entry_impl(entry, force=force) # <- anthropic cai aqui, SEM lock return self._refresh_entry_impl(entry, force=force) # <- anthropic cai aqui, SEM lock
``` ```
O próprio comentário do código explica a razão de existir o lock: refresh tokens single-use não podem ser disputados por dois processos Hermes ao mesmo tempo. Só que **`"anthropic"` não está na tupla `("openai-codex", "xai-oauth")`** — mesmo o refresh da Anthropic sendo, pela documentação do próprio arquivo `agent/anthropic_adapter.py::_refresh_oauth_token`, também single-use: O próprio comentário do código explica a razão de existir o lock: refresh tokens single-use não podem ser disputados por dois processos Hermes ao mesmo tempo. Na implementação anterior, **`"anthropic"` não estava na tupla `("openai-codex", "xai-oauth")`** — mesmo o refresh da Anthropic sendo, pela documentação do próprio arquivo `agent/anthropic_adapter.py::_refresh_oauth_token`, também single-use:
> "Claude Code's OAuth refresh tokens are single-use: a successful refresh rotates the pair and invalidates the old refresh token." > "Claude Code's OAuth refresh tokens are single-use: a successful refresh rotates the pair and invalidates the old refresh token."
@@ -185,7 +189,7 @@ if self.provider != "anthropic" or entry.source != "claude_code":
return entry return entry
``` ```
Ou seja: **só funciona para credenciais que vieram do Claude Code CLI** (`~/.claude/.credentials.json`). Credenciais originadas do login OAuth nativo do próprio Hermes (`hermes_pkce`, gravadas em `~/.hermes/.anthropic_oauth.json`, e também as emitidas pelo dashboard — `manual:dashboard_pkce`) **não têm nenhuma rota de recuperação**. Ao perder a corrida, o processo cai direto em `self._mark_exhausted(entry, None)` (linha 1705), mesmo que uma credencial válida já exista em disco (escrita pelo processo vencedor). Ou seja: **só funcionava para credenciais que vieram do Claude Code CLI** (`~/.claude/.credentials.json`). Credenciais originadas do login OAuth nativo do próprio Hermes (`manual:hermes_pkce`, gravadas em `~/.hermes/.anthropic_oauth.json`, e também as antigas emitidas pelo dashboard — `manual:dashboard_pkce`) **não tinham nenhuma rota de recuperação**. Ao perder a corrida, o processo caía direto em `self._mark_exhausted(entry, None)`, mesmo que uma credencial válida já existisse em disco (escrita pelo processo vencedor).
### Cenário concreto de exploração / impacto ### Cenário concreto de exploração / impacto
@@ -197,7 +201,7 @@ Isso não exige ataque — acontece em uso normal: múltiplos processos Hermes c
### Evidência de teste ### Evidência de teste
`tests/agent/test_credential_pool_anthropic_refresh_race.py` — 3 testes: Os testes abaixo são a evidência histórica da regressão na implementação anterior:
``` ```
FAILED test_anthropic_refresh_is_not_protected_by_cross_process_lock FAILED test_anthropic_refresh_is_not_protected_by_cross_process_lock
@@ -210,7 +214,7 @@ PASSED test_concurrent_claude_code_refresh_recovers_via_credentials_file
# contraste: fonte 'claude_code' SE recupera — prova a assimetria # contraste: fonte 'claude_code' SE recupera — prova a assimetria
``` ```
O teste usa um servidor OAuth fake que impõe corretamente a semântica "single-use" (primeiro a chegar ganha, segundo recebe `invalid_grant`), exatamente como o comportamento real documentado da Anthropic, e dispara dois `CredentialPool._refresh_entry()` concorrentes via `threading.Thread` simulando dois processos Hermes distintos. O teste original usava um servidor OAuth fake que impunha corretamente a semântica "single-use" (primeiro a chegar ganha, segundo recebe `invalid_grant`) e disparava dois `CredentialPool._refresh_entry()` concorrentes via `threading.Thread`. A cobertura atual acrescenta processo independente, perfis distintos e contagem exata do POST; ver a seção 5.
### Correção recomendada ### Correção recomendada
@@ -229,34 +233,35 @@ Verificado especificamente para OAuth da Anthropic:
--- ---
## 5. Como rodar os testes ## 5. Como validar a implementação atual
```bash ```bash
python -m pytest tests/hermes_cli/test_anthropic_dashboard_pkce_csrf.py tests/agent/test_credential_pool_anthropic_refresh_race.py -v scripts/run_tests.sh tests/hermes_cli/test_web_oauth_dispatch.py tests/agent/test_credential_pool_anthropic_refresh_race.py tests/agent/test_anthropic_oauth_stress.py tests/agent/test_credential_pool_oauth_writethrough.py tests/hermes_cli/test_auth_store_lock_concurrent.py -q
``` ```
Resultado atual (código não corrigido): **7 failed, 1 passed** — as 7 falhas são a evidência dos bugs 1, 2 e 3; o único teste que passa (`test_concurrent_claude_code_refresh_recovers_via_credentials_file`) prova a assimetria descrita no Bug 3 (fonte `claude_code` se recupera, `hermes_pkce`/dashboard não). O teste `test_distinct_profiles_share_one_claude_refresh_without_duplicate_post` usa dois processos independentes e exige um único POST de `stale-rt`; `test_hermes_pkce_refresh_writes_back_to_singleton` confirma que a rotação sobrevive a um `load_pool()` novo; `test_concurrent_refreshes_use_one_shared_credentials_lock` cobre o resolver direto; e os testes marcados `windows_only` exercitam a implementação `msvcrt` no host Windows. A cobertura frontend correspondente é `web/src/lib/api.test.ts`.
## 6. Arquivos e linhas de referência ## 6. Arquivos e linhas de referência
| Arquivo | Linhas relevantes | | Arquivo | Linhas relevantes |
|---|---| |---|---|
| `hermes_cli/web_server.py` | 10637-10661 (`_start_anthropic_pkce`), 10664-10728 (`_submit_anthropic_pkce`) | | `hermes_cli/web_server.py` | Catálogo `anthropic` como `flow: "external"`; dispatcher rejeita `start`/`submit` |
| `agent/credential_pool.py` | 1307-1344 (`_refresh_entry`), 855-905 (`_sync_anthropic_entry_from_credentials_file`), 1442-1483 (fallback de recuperação), 1705 (`_mark_exhausted`) | | `agent/credential_pool.py` | `_refresh_entry`, `_sync_anthropic_entry_from_pool_store`, lock compartilhado Claude Code e recuperação |
| `agent/anthropic_adapter.py` | 1125-1186 (`refresh_anthropic_oauth_pure`), 1189-1239 (`_refresh_oauth_token`, docstring sobre single-use), 1531-1658 (`run_hermes_oauth_login_pure`, fluxo CLI correto) | | `agent/anthropic_adapter.py` | `claude_code_credentials_path`, refresh puro e fluxo CLI PKCE separado |
| `tests/agent/test_anthropic_oauth_pkce.py` | Regressão histórica do bug 1/2 no fluxo CLI (já corrigido lá) | | `web/src/lib/api.ts` | `/api/providers/oauth` incluído no escopo de perfil |
--- ---
## 7. Validação manual pós-fix (2026-08-17) ## 7. Validação manual pós-fix (2026-08-17)
Além da suíte automatizada (seção 5), o fix do Bug 3 (API-key shadowing) foi Antes da remoção do fluxo dashboard, o shadowing de API key foi validado
validado manualmente com um teste A/B real — mesma cena, duas versões do manualmente com um teste A/B real — mesma cena, duas versões do código, sem
código, sem mocks: mocks. Este registro é histórico e não deve ser interpretado como prova de
que o endpoint dashboard ainda existe:
1. `.env` de teste com `ANTHROPIC_API_KEY` obsoleta (simulando um setup antigo 1. `.env` de teste com `ANTHROPIC_API_KEY` obsoleta (simulando um setup antigo
esquecido) + chamada real a `_save_anthropic_oauth_creds` (a função esquecido) + chamada real a `_save_anthropic_oauth_creds` (a função que
disparada pelo login OAuth do dashboard), em `HERMES_HOME` isolado. existia no login OAuth do dashboard), em `HERMES_HOME` isolado.
2. **Sem o fix** (`main` @ `8c8d55b`, app instalado do usuário): depois do 2. **Sem o fix** (`main` @ `8c8d55b`, app instalado do usuário): depois do
login OAuth, o `.env` continuou com a key obsoleta e login OAuth, o `.env` continuou com a key obsoleta e
`resolve_anthropic_token()` seguiu retornando a API key `resolve_anthropic_token()` seguiu retornando a API key
@@ -266,12 +271,7 @@ código, sem mocks:
`resolve_anthropic_token()` passou a retornar o token OAuth `resolve_anthropic_token()` passou a retornar o token OAuth
(`sk-ant-oat...`, `is_oauth_token=True`). (`sk-ant-oat...`, `is_oauth_token=True`).
Em seguida, o usuário rodou o `hermes chat` real no terminal (CLI, não O fluxo dashboard foi removido depois dessa validação. O que permanece
dashboard) com o binário instalado apontando para esta branch via verificável localmente é o fluxo de terminal e a ausência das rotas dashboard;
`PYTHONPATH`, contra seu `HERMES_HOME` real, e confirmou visualmente que o uma validação em uma instalação publicada não foi executada nesta revisão e
app sobe e opera normalmente sob o código corrigido. depende de distribuir uma versão que contenha a cabeça final do PR.
**Ação pendente:** o app instalado do usuário (`%LOCALAPPDATA%\hermes\hermes-agent`)
ainda está em `main` sem este commit — precisa dar merge no PR #87891 e
atualizar (`hermes update`) para o fix valer em produção, não só na branch de
teste.
+100 -32
View File
@@ -1254,41 +1254,66 @@ def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:
Only fall back to refreshing ourselves when no fresh credential is found. Only fall back to refreshing ourselves when no fresh credential is found.
""" """
# Claude Code may have already refreshed — adopt its token rather than # Claude Code may have already refreshed — adopt its token rather than
# racing it with our (possibly already-rotated) refresh token. Only adopt # racing it with our (possibly already-rotated) refresh token. The read,
# when the live re-read produced a DIFFERENT token with a real future # decision, POST, and write-back all belong to the shared credentials
# expiry: re-adopting the same credential we were just handed would be a # source, so hold the same path-keyed cross-process lock used by the pool.
# no-op, and a 0/absent ``expiresAt`` means "managed key / unknown expiry" # Without this direct resolver path, two profiles can still spend one
# (see is_claude_code_token_valid) which must NOT be treated as a fresh # single-use refresh token even though CredentialPool is serialized.
# refresh here.
current = read_claude_code_credentials()
if current:
current_token = current.get("accessToken", "")
current_exp = current.get("expiresAt", 0) or 0
if (
current_token
and current_token != creds.get("accessToken", "")
and current_exp > 0
and is_claude_code_token_valid(current)
):
logger.debug("Adopted Claude Code's already-refreshed OAuth token")
return current_token
refresh_token = (current or {}).get("refreshToken", "") or creds.get("refreshToken", "")
if not refresh_token:
logger.debug("No refresh token available — cannot refresh")
return None
try: try:
refreshed = refresh_anthropic_oauth_pure(refresh_token, use_json=False) from hermes_cli.auth import AUTH_LOCK_TIMEOUT_SECONDS, _auth_store_lock, env_float
_write_claude_code_credentials(
refreshed["access_token"], refresh_timeout_seconds = env_float(
refreshed["refresh_token"], "HERMES_ANTHROPIC_REFRESH_TIMEOUT_SECONDS", 20
refreshed["expires_at_ms"],
) )
logger.debug("Successfully refreshed Claude Code OAuth token") lock_timeout_seconds = max(
return refreshed["access_token"] float(AUTH_LOCK_TIMEOUT_SECONDS),
float(refresh_timeout_seconds) + 5.0,
)
with _auth_store_lock(
timeout_seconds=lock_timeout_seconds,
target_path=claude_code_credentials_path(),
):
# Only adopt when the live re-read produced a DIFFERENT token with
# a real future expiry: re-adopting the same credential we were
# just handed would be a no-op, and a 0/absent ``expiresAt`` means
# "managed key / unknown expiry" (see is_claude_code_token_valid).
current = read_claude_code_credentials()
if current:
current_token = current.get("accessToken", "")
current_exp = current.get("expiresAt", 0) or 0
if (
current_token
and current_token != creds.get("accessToken", "")
and current_exp > 0
and is_claude_code_token_valid(current)
):
logger.debug("Adopted Claude Code's already-refreshed OAuth token")
return current_token
refresh_token = (
(current or {}).get("refreshToken", "")
or creds.get("refreshToken", "")
)
if not refresh_token:
logger.debug("No refresh token available — cannot refresh")
return None
try:
refreshed = refresh_anthropic_oauth_pure(refresh_token, use_json=False)
_write_claude_code_credentials(
refreshed["access_token"],
refreshed["refresh_token"],
refreshed["expires_at_ms"],
)
logger.debug("Successfully refreshed Claude Code OAuth token")
return refreshed["access_token"]
except Exception as e:
logger.debug("Failed to refresh Claude Code token: %s", e)
return None
except Exception as e: except Exception as e:
logger.debug("Failed to refresh Claude Code token: %s", e) # Lock acquisition/read failures should preserve the resolver's
# existing fail-soft contract rather than taking down agent startup.
logger.debug("Failed to acquire Claude Code refresh lock: %s", e)
return None return None
@@ -1724,6 +1749,49 @@ def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]:
return None return None
def _write_hermes_oauth_credentials(
access_token: str,
refresh_token: Optional[str],
expires_at_ms: Optional[int],
) -> None:
"""Write refreshed hermes_pkce tokens back to ~/.hermes/.anthropic_oauth.json.
Without this, a successful pool-level refresh of a ``hermes_pkce``-sourced
entry is invisible to this singleton file. The next ``load_pool()`` call
runs ``_seed_from_singletons()``, which reads the stale file and
overwrites the freshly-rotated pool entry with the pre-refresh (and, for
single-use Anthropic refresh tokens, already-consumed) token pair.
"""
oauth_file = _get_hermes_oauth_file()
try:
oauth_data = {
"accessToken": access_token,
"refreshToken": refresh_token,
"expiresAt": expires_at_ms,
}
oauth_file.parent.mkdir(parents=True, exist_ok=True)
_tmp_oauth = oauth_file.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
try:
fd = os.open(
str(_tmp_oauth),
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
stat.S_IRUSR | stat.S_IWUSR,
)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(oauth_data, fh, indent=2)
fh.flush()
os.fsync(fh.fileno())
os.replace(_tmp_oauth, oauth_file)
except OSError:
try:
_tmp_oauth.unlink(missing_ok=True)
except OSError:
pass
raise
except (OSError, IOError) as e:
logger.debug("Failed to write refreshed Hermes OAuth credentials: %s", e)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Message / tool / response format conversion # Message / tool / response format conversion
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+18
View File
@@ -1548,6 +1548,24 @@ class CredentialPool:
) )
except Exception as wexc: except Exception as wexc:
logger.debug("Failed to write refreshed token to credentials file: %s", wexc) logger.debug("Failed to write refreshed token to credentials file: %s", wexc)
# Same rationale for the singleton source hermes_pkce:
# _seed_from_singletons() reads ~/.hermes/.anthropic_oauth.json
# on every load_pool() and will re-seed the pre-refresh (and
# already-consumed, single-use) token pair over this fresh one
# unless the singleton is updated in step with the pool entry.
# Do not use endswith here: manual:hermes_pkce is already
# pool-owned, and creating a singleton for it would introduce
# a second authority for the same refresh-token family.
elif entry.source == "hermes_pkce":
try:
from agent.anthropic_adapter import _write_hermes_oauth_credentials
_write_hermes_oauth_credentials(
refreshed["access_token"],
refreshed["refresh_token"],
refreshed["expires_at_ms"],
)
except Exception as wexc:
logger.debug("Failed to write refreshed token to Hermes OAuth file: %s", wexc)
elif self.provider == "openai-codex": elif self.provider == "openai-codex":
# Adopt fresher tokens from auth.json before spending the # Adopt fresher tokens from auth.json before spending the
# refresh_token — single-use tokens consumed by another Hermes # refresh_token — single-use tokens consumed by another Hermes
+22 -14
View File
@@ -10777,11 +10777,12 @@ async def test_messaging_platform(platform_id: str, profile: Optional[str] = Non
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# #
# Phase 1 surfaces *which OAuth providers exist* and whether each is # Phase 1 surfaces *which OAuth providers exist* and whether each is
# connected, plus a disconnect button. The actual login flow (PKCE for # connected, plus a disconnect button. Anthropic subscription OAuth is
# Anthropic, device-code for Nous/Codex) still runs in the CLI for now; # deliberately delegated away from the dashboard: its card is external and
# Phase 2 will add in-browser flows. For unconnected providers we return # points to the supported terminal path. Phase 2 adds in-browser device-code
# the canonical ``hermes auth add <provider>`` command so the dashboard # flows for providers that support them. For unconnected providers we return
# can surface a one-click copy. # the canonical ``hermes auth add <provider>`` command so the dashboard can
# surface a one-click copy.
def _truncate_token(value: Optional[str], visible: int = 6) -> str: def _truncate_token(value: Optional[str], visible: int = 6) -> str:
@@ -10813,8 +10814,8 @@ def _anthropic_oauth_status() -> Dict[str, Any]:
"""Status for the "Anthropic API Key" catalog entry. """Status for the "Anthropic API Key" catalog entry.
Two sources, in priority order: Two sources, in priority order:
1. ``~/.hermes/.anthropic_oauth.json`` — Hermes-managed PKCE flow (what 1. ``~/.hermes/.anthropic_oauth.json`` — Hermes-managed terminal PKCE
this entry's Connect button writes) credentials (the dashboard no longer has a Connect button for this)
2. ``ANTHROPIC_API_KEY`` → ``ANTHROPIC_TOKEN`` → ``CLAUDE_CODE_OAUTH_TOKEN`` 2. ``ANTHROPIC_API_KEY`` → ``ANTHROPIC_TOKEN`` → ``CLAUDE_CODE_OAUTH_TOKEN``
env vars (registry order) — from ``.env``, the shell, or an external env vars (registry order) — from ``.env``, the shell, or an external
secret source like Bitwarden (whose keys are injected into the process secret source like Bitwarden (whose keys are injected into the process
@@ -10931,12 +10932,11 @@ def _copilot_acp_status() -> Dict[str, Any]:
# which unions them with every accounts-tab provider in ``provider_catalog()`` # which unions them with every accounts-tab provider in ``provider_catalog()``
# so newly-added OAuth/external providers appear automatically (no hand edit). # so newly-added OAuth/external providers appear automatically (no hand edit).
# This tuple also still includes two entries that are NOT catalog providers but # This tuple also still includes two entries that are NOT catalog providers but
# must show on the Accounts tab: the api-key Anthropic PKCE card and the # must show on the Accounts tab: the Anthropic credential-status card and the
# synthetic ``claude-code`` subscription row. # synthetic ``claude-code`` subscription row.
# ``flow`` describes the OAuth shape so the modal can pick the right UI: # ``flow`` describes the account-management shape so the UI can pick the right
# ``pkce`` = open URL + paste callback code, ``device_code`` = show code + # behavior: ``device_code`` = show code + verification URL + poll, and
# verification URL + poll, ``external`` = read-only (delegated to a third-party # ``external`` = read-only/delegated to a terminal or third-party CLI.
# CLI like Claude Code or Qwen).
_OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = ( _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
{ {
"id": "nous", "id": "nous",
@@ -11222,7 +11222,7 @@ async def list_oauth_providers(profile: Optional[str] = None):
Response shape (per provider): Response shape (per provider):
id stable identifier (used in DELETE path) id stable identifier (used in DELETE path)
name human label name human label
flow "pkce" | "device_code" | "external" flow "device_code" | "external"
cli_command fallback CLI command for users to run manually cli_command fallback CLI command for users to run manually
disconnect_command shell command that clears an external provider's disconnect_command shell command that clears an external provider's
creds (run in the embedded terminal), else null creds (run in the embedded terminal), else null
@@ -12100,12 +12100,16 @@ async def poll_oauth_session(
Each surfaces progress through the same background-worker-updated Each surfaces progress through the same background-worker-updated
``status`` field, so a single poll endpoint serves them all. ``status`` field, so a single poll endpoint serves them all.
""" """
_validate_oauth_profile(profile)
requested_profile = _oauth_profile_name(profile)
with _oauth_sessions_lock: with _oauth_sessions_lock:
sess = _oauth_sessions.get(session_id) sess = _oauth_sessions.get(session_id)
if not sess: if not sess:
raise HTTPException(status_code=404, detail="Session not found or expired") raise HTTPException(status_code=404, detail="Session not found or expired")
if sess["provider"] != provider_id: if sess["provider"] != provider_id:
raise HTTPException(status_code=400, detail="Provider mismatch for session") raise HTTPException(status_code=400, detail="Provider mismatch for session")
if sess.get("profile") != requested_profile:
raise HTTPException(status_code=400, detail="OAuth session profile mismatch")
return { return {
"session_id": session_id, "session_id": session_id,
"status": sess["status"], "status": sess["status"],
@@ -12129,11 +12133,15 @@ async def cancel_oauth_session(
user believed it was aborted. user believed it was aborted.
""" """
_require_token(request) _require_token(request)
_validate_oauth_profile(profile)
requested_profile = _oauth_profile_name(profile)
with _oauth_sessions_lock: with _oauth_sessions_lock:
sess = _oauth_sessions.get(session_id) sess = _oauth_sessions.get(session_id)
if sess is not None: if sess is not None:
if sess.get("profile") != requested_profile:
raise HTTPException(status_code=400, detail="OAuth session profile mismatch")
sess["cancelled"] = True sess["cancelled"] = True
_oauth_sessions.pop(session_id, None) _oauth_sessions.pop(session_id, None)
if sess is None: if sess is None:
return {"ok": False, "message": "session not found"} return {"ok": False, "message": "session not found"}
return {"ok": True, "session_id": session_id} return {"ok": True, "session_id": session_id}
+86 -2
View File
@@ -1,6 +1,8 @@
"""Tests for Bug #12905 fixes in agent/anthropic_adapter.py — macOS Keychain support.""" """Tests for Bug #12905 fixes in agent/anthropic_adapter.py — macOS Keychain support."""
import json import json
import threading
import time
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import pytest import pytest
@@ -204,10 +206,14 @@ class TestRefreshOAuthTokenAdoptsFreshCredential:
_FRESH = 9_999_999_999_999 _FRESH = 9_999_999_999_999
def test_adopts_already_refreshed_token_without_posting(self, monkeypatch): def test_adopts_already_refreshed_token_without_posting(self, tmp_path, monkeypatch):
"""When a live source already holds a valid token, return it and skip """When a live source already holds a valid token, return it and skip
the network refresh entirely. the network refresh entirely.
""" """
monkeypatch.setattr(
"agent.anthropic_adapter.claude_code_credentials_path",
lambda: tmp_path / ".claude" / ".credentials.json",
)
fresh = { fresh = {
"accessToken": "already-refreshed-token", "accessToken": "already-refreshed-token",
"refreshToken": "live-refresh", "refreshToken": "live-refresh",
@@ -231,10 +237,14 @@ class TestRefreshOAuthTokenAdoptsFreshCredential:
result = _refresh_oauth_token({"refreshToken": "stale", "expiresAt": 1}) result = _refresh_oauth_token({"refreshToken": "stale", "expiresAt": 1})
assert result == "already-refreshed-token" assert result == "already-refreshed-token"
def test_falls_back_to_network_refresh_when_no_fresh_credential(self, monkeypatch): def test_falls_back_to_network_refresh_when_no_fresh_credential(self, tmp_path, monkeypatch):
"""When no live source has a valid token, fall back to refreshing """When no live source has a valid token, fall back to refreshing
ourselves using the freshest available refresh token. ourselves using the freshest available refresh token.
""" """
monkeypatch.setattr(
"agent.anthropic_adapter.claude_code_credentials_path",
lambda: tmp_path / ".claude" / ".credentials.json",
)
# Live read returns an expired credential carrying a refresh token. # Live read returns an expired credential carrying a refresh token.
monkeypatch.setattr( monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials", "agent.anthropic_adapter.read_claude_code_credentials",
@@ -263,3 +273,77 @@ class TestRefreshOAuthTokenAdoptsFreshCredential:
# Prefers the live source's refresh token over the caller's stale copy. # Prefers the live source's refresh token over the caller's stale copy.
assert captured["refresh_token"] == "live-refresh" assert captured["refresh_token"] == "live-refresh"
def test_concurrent_refreshes_use_one_shared_credentials_lock(self, tmp_path, monkeypatch):
"""Direct resolver refreshes must not spend one Claude token twice."""
shared_credentials_path = tmp_path / ".claude" / ".credentials.json"
monkeypatch.setattr(
"agent.anthropic_adapter.claude_code_credentials_path",
lambda: shared_credentials_path,
)
state = {
"accessToken": "stale-access",
"refreshToken": "stale-refresh",
"expiresAt": 1,
}
state_lock = threading.Lock()
calls = []
def read_credentials():
with state_lock:
return dict(state)
def write_credentials(access_token, refresh_token, expires_at_ms, **_kwargs):
with state_lock:
state.update(
accessToken=access_token,
refreshToken=refresh_token,
expiresAt=expires_at_ms,
)
def refresh(refresh_token, **_kwargs):
calls.append(refresh_token)
# Without the production shared lock, both callers read the stale
# pair before either fake network request commits its rotation.
time.sleep(0.05)
with state_lock:
if state["refreshToken"] != refresh_token:
raise ValueError("invalid_grant: refresh token already used")
return {
"access_token": "fresh-access",
"refresh_token": "fresh-refresh",
"expires_at_ms": self._FRESH,
}
monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", read_credentials)
monkeypatch.setattr("agent.anthropic_adapter._write_claude_code_credentials", write_credentials)
monkeypatch.setattr("agent.anthropic_adapter.refresh_anthropic_oauth_pure", refresh)
results = {}
errors = {}
start = threading.Barrier(2)
def run(name):
try:
start.wait(timeout=5)
results[name] = _refresh_oauth_token(
{
"accessToken": "stale-access",
"refreshToken": "stale-refresh",
"expiresAt": 1,
}
)
except BaseException as exc: # pragma: no cover - failure diagnostics
errors[name] = exc
threads = [threading.Thread(target=run, args=(name,)) for name in ("a", "b")]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=5)
assert not [thread for thread in threads if thread.is_alive()]
assert not errors, errors
assert results == {"a": "fresh-access", "b": "fresh-access"}
assert calls == ["stale-refresh"], calls
+229 -9
View File
@@ -1,22 +1,27 @@
"""Load / stress test for the Anthropic OAuth cross-process refresh race fix. """Load / stress test for the Anthropic OAuth cross-process refresh race fix.
Companion to ``tests/agent/test_credential_pool_anthropic_refresh_race.py``, Companion to ``tests/agent/test_credential_pool_anthropic_refresh_race.py``,
which proves the bug in isolation with two racers. This test scales the same which proves the bug in isolation with two racers. This test scales the same scenario up to look for bottlenecks and degradation
scenario up to look for bottlenecks and degradation under real concurrency: under real concurrency. The thread stress case keeps the suite fast while a
many "Hermes processes" (threads, each with its own ``CredentialPool`` separate spawn-based case uses independent interpreters, distinct profile
instance) hammering the same single-use Anthropic refresh token at once, homes, and one shared Claude Code credentials file. Both exercise the REAL
against the REAL cross-process file lock (``_auth_store_lock``) and REAL cross-process file lock (``_auth_store_lock``) and REAL credential-pool
credential-pool persistence (not mocked) under a throwaway ``HERMES_HOME`` -- persistence under throwaway directories — only the network call to Anthropic
only the network call to Anthropic is faked. This checks the fix does not is faked. The process case also counts refresh POSTs and requires exactly one
deadlock, does not lose updates, and does not degrade into a "thundering use of the stale single-use token, so a broken lock cannot remain green merely
herd" of redundant refresh POSTs as concurrency grows. because two in-process mocks happened to finish quickly.
""" """
from __future__ import annotations from __future__ import annotations
import json
import multiprocessing as mp
import os
import queue
import threading import threading
import time import time
from dataclasses import replace as dc_replace from dataclasses import replace as dc_replace
from pathlib import Path
import pytest import pytest
@@ -30,6 +35,94 @@ from agent.credential_pool import (
CONCURRENCY = 20 CONCURRENCY = 20
def _process_claude_code_refresh_worker(
profile_home: str,
shared_credentials_path: str,
server_state_path: str,
start_event,
result_queue,
) -> None:
"""Refresh one shared Claude Code credential from an independent process."""
os.environ["HERMES_HOME"] = profile_home
from agent import anthropic_adapter as anthropic_mod
from agent import credential_pool as credential_pool_mod
from hermes_cli import auth as auth_mod
shared_path = Path(shared_credentials_path)
server_path = Path(server_state_path)
def read_shared_credentials():
data = json.loads(shared_path.read_text(encoding="utf-8"))
oauth = data["claudeAiOauth"]
return {
"accessToken": oauth["accessToken"],
"refreshToken": oauth.get("refreshToken", ""),
"expiresAt": oauth.get("expiresAt", 0),
"source": "claude_code_credentials_file",
}
def write_shared_credentials(access_token, refresh_token, expires_at_ms, **_kwargs):
data = json.loads(shared_path.read_text(encoding="utf-8"))
data["claudeAiOauth"] = {
"accessToken": access_token,
"refreshToken": refresh_token,
"expiresAt": expires_at_ms,
}
shared_path.write_text(json.dumps(data), encoding="utf-8")
def fake_refresh(refresh_token, *, use_json=False):
# The state file models a single-use token endpoint. The lock here
# protects only the fake server's accounting; the production lock is
# what must ensure that the second Hermes process never calls this
# function after the first one has rotated the shared credential.
with auth_mod._auth_store_lock(timeout_seconds=10, target_path=server_path):
state = json.loads(server_path.read_text(encoding="utf-8"))
state["calls"].append(refresh_token)
if refresh_token in state["spent"]:
server_path.write_text(json.dumps(state), encoding="utf-8")
raise ValueError("invalid_grant: refresh token already used")
state["spent"].append(refresh_token)
state["rotation"] += 1
rotation = state["rotation"]
server_path.write_text(json.dumps(state), encoding="utf-8")
# Keep the simulated network operation inside the production shared
# lock long enough for the second profile to prove it waits, then
# re-reads the newly-written shared credentials file.
time.sleep(0.1)
return {
"access_token": f"process-access-{rotation}",
"refresh_token": f"process-refresh-{rotation}",
"expires_at_ms": int(time.time() * 1000) + 3_600_000,
}
# Keep this worker hermetic: each profile has its own auth store, while
# both workers deliberately point at the same Claude credential source.
auth_mod._global_auth_file_path = lambda: None
anthropic_mod.claude_code_credentials_path = lambda: shared_path
anthropic_mod.read_claude_code_credentials = read_shared_credentials
anthropic_mod._write_claude_code_credentials = write_shared_credentials
anthropic_mod.refresh_anthropic_oauth_pure = fake_refresh
result_queue.put({"kind": "ready", "pid": os.getpid()})
if not start_event.wait(timeout=10):
result_queue.put({"kind": "result", "ok": False, "error": "start barrier timeout"})
return
entry = _entry(id="pool-entry", refresh_token="stale-rt", source="claude_code")
pool = credential_pool_mod.CredentialPool("anthropic", [entry])
try:
refreshed = pool._refresh_entry(pool.entries()[0], force=True)
result_queue.put({
"kind": "result",
"ok": refreshed is not None,
"refresh_token": refreshed.refresh_token if refreshed else None,
"pool_refresh_token": pool.entries()[0].refresh_token,
})
except BaseException as exc: # pragma: no cover - failure diagnostics
result_queue.put({"kind": "result", "ok": False, "error": repr(exc)})
def _entry(*, id: str, refresh_token: str, source: str) -> PooledCredential: def _entry(*, id: str, refresh_token: str, source: str) -> PooledCredential:
return PooledCredential( return PooledCredential(
provider="anthropic", provider="anthropic",
@@ -165,3 +258,130 @@ def test_high_concurrency_anthropic_refresh_no_lost_updates_no_deadlock(
f"processes -- expected well under {naive_serial_upper_bound:.2f}s " f"processes -- expected well under {naive_serial_upper_bound:.2f}s "
"if the lock + pool-store adoption path is working efficiently" "if the lock + pool-store adoption path is working efficiently"
) )
@pytest.mark.live_system_guard_bypass
@pytest.mark.windows_only
def test_distinct_profiles_share_one_claude_refresh_without_duplicate_post(
hermes_home,
):
"""Independent profiles must serialize a shared Claude Code refresh.
The profile auth locks intentionally have different paths here; only the
dedicated lock keyed to the shared Claude credentials file can prevent the
second process from POSTing the already-spent refresh token.
"""
shared_credentials_path = hermes_home / "shared-claude-credentials.json"
shared_credentials_path.write_text(
json.dumps({
"claudeAiOauth": {
"accessToken": "stale-at",
"refreshToken": "stale-rt",
"expiresAt": 0,
}
}),
encoding="utf-8",
)
server_state_path = hermes_home / "fake-token-server.json"
server_state_path.write_text(
json.dumps({"calls": [], "spent": [], "rotation": 0}),
encoding="utf-8",
)
profile_homes = [hermes_home / "profile-a", hermes_home / "profile-b"]
for profile_home in profile_homes:
profile_home.mkdir(parents=True)
(profile_home / "auth.json").write_text(
json.dumps({
"version": 1,
"providers": {},
# claude_code is a borrowed source; its raw tokens must not
# be persisted in a profile pool. Each worker constructs the
# runtime entry from the shared credential source below.
"credential_pool": {},
}),
encoding="utf-8",
)
ctx = mp.get_context("spawn")
start_event = ctx.Event()
result_queue = ctx.Queue()
processes = [
ctx.Process(
target=_process_claude_code_refresh_worker,
args=(
str(profile_home),
str(shared_credentials_path),
str(server_state_path),
start_event,
result_queue,
),
)
for profile_home in profile_homes
]
messages = []
try:
for process in processes:
process.start()
ready_deadline = time.monotonic() + 20.0
while len([m for m in messages if m.get("kind") == "ready"]) < len(processes):
remaining = max(0.1, ready_deadline - time.monotonic())
if remaining <= 0.1:
break
try:
messages.append(result_queue.get(timeout=remaining))
except queue.Empty:
break
assert len([m for m in messages if m.get("kind") == "ready"]) == len(processes), (
f"not all refresh workers reached the start barrier: {messages!r}"
)
start_event.set()
for process in processes:
process.join(timeout=30)
assert not [process for process in processes if process.is_alive()], (
"a profile refresh worker did not finish; possible shared-lock deadlock"
)
result_deadline = time.monotonic() + 5.0
results = [m for m in messages if m.get("kind") == "result"]
while len(results) < len(processes) and time.monotonic() < result_deadline:
try:
message = result_queue.get(timeout=0.5)
except queue.Empty:
break
messages.append(message)
if message.get("kind") == "result":
results.append(message)
finally:
start_event.set()
for process in processes:
process.join(timeout=2)
for process in processes:
if process.is_alive():
process.kill()
process.join(timeout=5)
result_queue.close()
result_queue.join_thread()
assert len(results) == len(processes), f"missing process results: {messages!r}"
assert all(result.get("ok") for result in results), results
assert {result.get("refresh_token") for result in results} == {"process-refresh-1"}
assert all((profile_home / "auth.lock").exists() for profile_home in profile_homes)
assert shared_credentials_path.with_suffix(".lock").exists()
server_state = json.loads(server_state_path.read_text(encoding="utf-8"))
assert server_state["calls"] == ["stale-rt"], (
"the shared stale refresh token must be POSTed exactly once across "
f"distinct profiles, got {server_state['calls']!r}"
)
assert server_state["spent"] == ["stale-rt"]
shared_credentials = json.loads(shared_credentials_path.read_text(encoding="utf-8"))
assert shared_credentials["claudeAiOauth"]["refreshToken"] == "process-refresh-1"
for profile_home in profile_homes:
profile_text = (profile_home / "auth.json").read_text(encoding="utf-8")
assert "stale-rt" not in profile_text
assert "process-refresh-1" not in profile_text
@@ -1,7 +1,7 @@
"""Regression tests for cross-process races refreshing Anthropic OAuth tokens. """Regression tests for cross-process races refreshing Anthropic OAuth tokens.
``CredentialPool._refresh_entry`` explicitly documents (see the comment ``CredentialPool._refresh_entry`` explicitly documents (see the comment
above the ``if self.provider in ("openai-codex", "xai-oauth"):`` branch in above the ``if self.provider in ("openai-codex", "xai-oauth", "anthropic"):`` branch in
``agent/credential_pool.py``) that single-use OAuth refresh tokens require ``agent/credential_pool.py``) that single-use OAuth refresh tokens require
the whole sync -> POST -> write-back sequence to be serialized across the whole sync -> POST -> write-back sequence to be serialized across
Hermes *processes* via the cross-process ``_auth_store_lock`` flock, Hermes *processes* via the cross-process ``_auth_store_lock`` flock,
@@ -11,18 +11,21 @@ it, and the loser gets ``refresh_token_reused``".
Anthropic's OAuth refresh tokens have the identical single-use property -- Anthropic's OAuth refresh tokens have the identical single-use property --
``agent.anthropic_adapter._refresh_oauth_token`` says so explicitly: ``agent.anthropic_adapter._refresh_oauth_token`` says so explicitly:
"Claude Code's OAuth refresh tokens are single-use: a successful refresh "Claude Code's OAuth refresh tokens are single-use: a successful refresh
rotates the pair and invalidates the old refresh token." Yet rotates the pair and invalidates the old refresh token." Before the PR, ``"anthropic"`` was absent from the
``"anthropic"`` is absent from the ``("openai-codex", "xai-oauth")`` tuple ``("openai-codex", "xai-oauth")`` tuple that gets the cross-process flock,
that gets the cross-process flock, and the *only* on-failure recovery path and the *only* on-failure recovery path
(``CredentialPool._sync_anthropic_entry_from_credentials_file``) is (``CredentialPool._sync_anthropic_entry_from_credentials_file``) was
hard-scoped to ``entry.source == "claude_code"`` -- entries sourced from hard-scoped to ``entry.source == "claude_code"``. Entries sourced from
Hermes's own PKCE login (``hermes_pkce`` / ``manual:dashboard_pkce``) get Hermes's own PKCE login (``manual:hermes_pkce`` / ``hermes_pkce``) got no
no recovery at all and are marked exhausted on any lost race, even though recovery at all and were marked exhausted on a lost race, even though a
a fresh, valid token pair exists on disk (written by the winner). fresh, valid token pair existed on disk (written by the winner). The old
dashboard source ``manual:dashboard_pkce`` is now retired with the removed
dashboard flow.
These tests reproduce that race deterministically with a fake OAuth server These tests reproduce that race deterministically with a fake OAuth server
that enforces single-use refresh tokens, run two "process-local" pools that enforces single-use refresh tokens, run concurrent pool instances against
concurrently against it, and assert the (currently absent) protection. it, and assert the current protection. The process-level, cross-profile
Claude Code witness lives in ``test_anthropic_oauth_stress.py``.
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,6 +18,7 @@ mocking the save boundary, so they exercise the actual atomic write path.
import json import json
import threading import threading
import time
import pytest import pytest
@@ -26,6 +27,7 @@ from agent.credential_pool import (
AUTH_TYPE_OAUTH, AUTH_TYPE_OAUTH,
CredentialPool, CredentialPool,
PooledCredential, PooledCredential,
load_pool,
) )
from hermes_cli import auth as A from hermes_cli import auth as A
@@ -317,3 +319,111 @@ def test_write_through_fires_on_every_refresh_not_just_first(
) )
assert root_tokens["refresh_token"] == "rf2" assert root_tokens["refresh_token"] == "rf2"
def test_hermes_pkce_refresh_writes_back_to_singleton(tmp_path, monkeypatch):
"""A successful hermes_pkce refresh must update
~/.hermes/.anthropic_oauth.json, or ``_seed_from_singletons()`` on the
next ``load_pool()`` re-seeds the pre-refresh (already-consumed,
single-use) token pair over the freshly rotated one.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr("hermes_cli.auth.is_provider_explicitly_configured", lambda pid: True)
oauth_file = hermes_home / ".anthropic_oauth.json"
oauth_file.write_text(
json.dumps({"accessToken": "sk-ant-oat-rt0", "refreshToken": "rt0", "expiresAt": 0}),
encoding="utf-8",
)
_write_store(hermes_home / "auth.json", {"version": 1, "providers": {}})
monkeypatch.setattr(
"agent.anthropic_adapter.refresh_anthropic_oauth_pure",
lambda refresh_token, use_json=False: {
"access_token": "sk-ant-oat-rt1",
"refresh_token": "rt1",
"expires_at_ms": int(time.time() * 1000) + 3_600_000,
},
)
monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None)
entry = PooledCredential(
provider="anthropic",
id="pool-entry",
label="cred",
auth_type=AUTH_TYPE_OAUTH,
priority=0,
source="hermes_pkce",
access_token="sk-ant-oat-rt0",
refresh_token="rt0",
)
pool = CredentialPool("anthropic", [entry])
updated = pool._refresh_entry(entry, force=True)
assert updated is not None
assert updated.refresh_token == "rt1"
on_disk = json.loads(oauth_file.read_text(encoding="utf-8"))
assert on_disk["refreshToken"] == "rt1", (
"successful hermes_pkce refresh must write back to "
"~/.hermes/.anthropic_oauth.json, or _seed_from_singletons() will "
"revert the pool entry to the pre-refresh (spent) token on next load"
)
reloaded = load_pool("anthropic")
reloaded_entries = [e for e in reloaded.entries() if e.source.endswith("hermes_pkce")]
assert reloaded_entries, "hermes_pkce entry should still be present after reload"
assert reloaded_entries[0].refresh_token == "rt1", (
"regression: fresh load_pool() re-seeded the pre-refresh refresh "
"token from the stale singleton file, reverting a successful "
"rotation and orphaning the already-consumed rt0"
)
def test_manual_hermes_pkce_refresh_does_not_create_duplicate_singleton(
tmp_path, monkeypatch
):
"""A pool-owned manual:hermes_pkce entry must not create a second source."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr("hermes_cli.auth.is_provider_explicitly_configured", lambda pid: True)
monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None)
monkeypatch.setattr(
"agent.anthropic_adapter.refresh_anthropic_oauth_pure",
lambda refresh_token, use_json=False: {
"access_token": "manual-at-1",
"refresh_token": "manual-rt-1",
"expires_at_ms": int(time.time() * 1000) + 3_600_000,
},
)
_write_store(hermes_home / "auth.json", {"version": 1, "providers": {}})
entry = PooledCredential(
provider="anthropic",
id="manual-entry",
label="cred",
auth_type=AUTH_TYPE_OAUTH,
priority=0,
source="manual:hermes_pkce",
access_token="manual-at-0",
refresh_token="manual-rt-0",
expires_at_ms=0,
)
pool = CredentialPool("anthropic", [entry])
refreshed = pool._refresh_entry(entry, force=True)
assert refreshed is not None
assert refreshed.refresh_token == "manual-rt-1"
oauth_file = hermes_home / ".anthropic_oauth.json"
assert not oauth_file.exists(), (
"manual:hermes_pkce is already pool-owned; refreshing it must not "
"create a second hermes_pkce singleton source"
)
reloaded = load_pool("anthropic")
matching = [e for e in reloaded.entries() if e.id == "manual-entry"]
assert len(matching) == 1
assert matching[0].source == "manual:hermes_pkce"
assert matching[0].refresh_token == "manual-rt-1"
@@ -39,6 +39,7 @@ def hermes_home(tmp_path, monkeypatch):
return tmp_path return tmp_path
@pytest.mark.windows_only
def test_many_concurrent_lock_acquisitions_do_not_raise_permission_error(hermes_home): def test_many_concurrent_lock_acquisitions_do_not_raise_permission_error(hermes_home):
"""CONCURRENCY threads race to acquire/release the same auth-store lock. """CONCURRENCY threads race to acquire/release the same auth-store lock.
+90 -10
View File
@@ -5,17 +5,19 @@ flagged ``flow: "pkce"`` — anthropic and minimax-oauth — and the
dispatcher ``start_oauth_login`` hardcoded ``_start_anthropic_pkce()`` dispatcher ``start_oauth_login`` hardcoded ``_start_anthropic_pkce()``
for any pkce-flagged provider. So clicking "Login" next to MiniMax in for any pkce-flagged provider. So clicking "Login" next to MiniMax in
the dashboard's Keys tab silently launched the Anthropic/Claude OAuth the dashboard's Keys tab silently launched the Anthropic/Claude OAuth
flow. flow. The Anthropic dashboard flow was later removed entirely because
Hermes must not mint subscription OAuth tokens from an unattended HTTP
endpoint; only the approved external CLI path remains.
The fix: The fix:
1. Catalog entry for minimax-oauth changed from ``flow: "pkce"`` to 1. Catalog entry for minimax-oauth changed from ``flow: "pkce"`` to
``flow: "device_code"`` (the actual UX is verification URI + user ``flow: "device_code"`` (the actual UX is verification URI + user
code + background poll, with PKCE as a security extension). code + background poll, with PKCE as a security extension).
2. New MiniMax branch added to ``_start_device_code_flow``. 2. New MiniMax branch added to ``_start_device_code_flow``.
3. Dispatcher tightened: pkce branch now requires 3. Anthropic's catalog entry changed to ``flow: "external"`` and its
``provider_id == "anthropic"``, so any future PKCE provider added dashboard start/submit routes now reject the removed flow. Any future
without an explicit branch gets a clean ``400 Unsupported flow`` provider added without an explicit flow gets a clean error instead of
instead of silently launching Anthropic OAuth. silently launching Anthropic OAuth.
These tests pin the corrected behavior. These tests pin the corrected behavior.
""" """
@@ -171,6 +173,48 @@ def test_oauth_start_stores_profile_for_background_completion(tmp_path, monkeypa
ws._oauth_sessions.pop(session_id, None) ws._oauth_sessions.pop(session_id, None)
def test_oauth_session_cannot_be_polled_or_cancelled_from_another_profile(
tmp_path, monkeypatch
):
"""A named-profile OAuth session must reject default-profile retargeting."""
from hermes_cli import web_server as ws
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "profiles" / "worker").mkdir(parents=True)
session_id, _session = ws._new_oauth_session(
"xai-oauth", "device_code", profile="worker"
)
try:
poll_resp = client.get(
f"/api/providers/oauth/xai-oauth/poll/{session_id}",
headers=HEADERS,
)
assert poll_resp.status_code == 400, poll_resp.text
assert "profile" in poll_resp.text.lower()
cancel_resp = client.delete(
f"/api/providers/oauth/sessions/{session_id}",
headers=HEADERS,
)
assert cancel_resp.status_code == 400, cancel_resp.text
assert "profile" in cancel_resp.text.lower()
assert session_id in ws._oauth_sessions
correct_poll = client.get(
f"/api/providers/oauth/xai-oauth/poll/{session_id}?profile=worker",
headers=HEADERS,
)
assert correct_poll.status_code == 200, correct_poll.text
correct_cancel = client.delete(
f"/api/providers/oauth/sessions/{session_id}?profile=worker",
headers=HEADERS,
)
assert correct_cancel.status_code == 200, correct_cancel.text
finally:
ws._oauth_sessions.pop(session_id, None)
def test_codex_dashboard_start_rewords_device_authorization_error(monkeypatch): def test_codex_dashboard_start_rewords_device_authorization_error(monkeypatch):
@@ -271,7 +315,7 @@ def test_codex_dashboard_worker_stops_polling_after_cancel(tmp_path, monkeypatch
) )
saved = [] saved = []
monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _make_profile_home(tmp_path, monkeypatch, profile="coder")
monkeypatch.setattr(httpx, "Client", _Client) monkeypatch.setattr(httpx, "Client", _Client)
monkeypatch.setattr(auth_mod, "_save_codex_tokens", lambda tokens: saved.append(tokens)) monkeypatch.setattr(auth_mod, "_save_codex_tokens", lambda tokens: saved.append(tokens))
@@ -280,7 +324,10 @@ def test_codex_dashboard_worker_stops_polling_after_cancel(tmp_path, monkeypatch
def fake_sleep(_interval): def fake_sleep(_interval):
# Simulate a real concurrent DELETE /api/providers/oauth/sessions/{sid} # Simulate a real concurrent DELETE /api/providers/oauth/sessions/{sid}
# firing while the worker is asleep between polls. # firing while the worker is asleep between polls.
resp = client.delete(f"/api/providers/oauth/sessions/{sid}", headers=HEADERS) resp = client.delete(
f"/api/providers/oauth/sessions/{sid}?profile=coder",
headers=HEADERS,
)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
monkeypatch.setattr(ws.time, "sleep", fake_sleep) monkeypatch.setattr(ws.time, "sleep", fake_sleep)
@@ -369,7 +416,10 @@ def test_codex_worker_final_save_is_atomic_with_cancel_delete(tmp_path, monkeypa
def _fire_delete(): def _fire_delete():
delete_started.set() delete_started.set()
client.delete(f"/api/providers/oauth/sessions/{sid}", headers=HEADERS) client.delete(
f"/api/providers/oauth/sessions/{sid}?profile=coder",
headers=HEADERS,
)
delete_finished.set() delete_finished.set()
monkeypatch.setattr(auth_mod, "_save_codex_tokens", fake_save) monkeypatch.setattr(auth_mod, "_save_codex_tokens", fake_save)
@@ -396,7 +446,7 @@ def test_codex_worker_final_save_is_atomic_with_cancel_delete(tmp_path, monkeypa
assert sid not in ws._oauth_sessions assert sid not in ws._oauth_sessions
def test_cancel_oauth_session_marks_dict_cancelled_before_popping(): def test_cancel_oauth_session_marks_dict_cancelled_before_popping(tmp_path, monkeypatch):
"""The DELETE endpoint must flag the session dict before removing it. """The DELETE endpoint must flag the session dict before removing it.
A background worker holds its own reference to the same dict object; A background worker holds its own reference to the same dict object;
@@ -405,6 +455,7 @@ def test_cancel_oauth_session_marks_dict_cancelled_before_popping():
""" """
from hermes_cli import web_server as ws from hermes_cli import web_server as ws
_make_profile_home(tmp_path, monkeypatch, profile="coder")
session_id = "cancel-flag-test" session_id = "cancel-flag-test"
ws._oauth_sessions[session_id] = { ws._oauth_sessions[session_id] = {
"session_id": session_id, "session_id": session_id,
@@ -418,7 +469,7 @@ def test_cancel_oauth_session_marks_dict_cancelled_before_popping():
worker_ref = ws._oauth_sessions[session_id] worker_ref = ws._oauth_sessions[session_id]
resp = client.delete( resp = client.delete(
f"/api/providers/oauth/sessions/{session_id}", f"/api/providers/oauth/sessions/{session_id}?profile=coder",
headers=HEADERS, headers=HEADERS,
) )
@@ -490,6 +541,35 @@ def test_xai_oauth_listed_as_device_code_flow():
assert "grok" in providers["xai-oauth"]["name"].lower() assert "grok" in providers["xai-oauth"]["name"].lower()
def test_anthropic_dashboard_oauth_is_removed_and_external():
"""Anthropic subscription OAuth is not minted by the dashboard anymore."""
from hermes_cli import web_server as ws
resp = client.get("/api/providers/oauth", headers=HEADERS)
assert resp.status_code == 200, resp.text
providers = {p["id"]: p for p in resp.json()["providers"]}
assert providers["anthropic"]["flow"] == "external"
assert providers["anthropic"]["cli_command"] == "hermes auth add anthropic"
before_sessions = set(ws._oauth_sessions)
start_resp = client.post(
"/api/providers/oauth/anthropic/start",
headers=HEADERS,
)
assert start_resp.status_code == 400, start_resp.text
assert "external CLI" in start_resp.text
assert "claude.ai" not in start_resp.text
submit_resp = client.post(
"/api/providers/oauth/anthropic/submit",
headers=HEADERS,
json={"session_id": "unused", "code": "unused"},
)
assert submit_resp.status_code == 400, submit_resp.text
assert "not supported" in submit_resp.text
assert set(ws._oauth_sessions) == before_sessions
def test_accounts_offers_every_oauth_provider_from_catalog(): def test_accounts_offers_every_oauth_provider_from_catalog():
"""PARITY CONTRACT: every accounts-tab provider in the unified catalog (the """PARITY CONTRACT: every accounts-tab provider in the unified catalog (the
`hermes model` universe) must be offered by /api/providers/oauth. This keeps `hermes model` universe) must be offered by /api/providers/oauth. This keeps
+28 -1
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { api, fetchJSON } from "./api"; import { api, fetchJSON, setManagementProfile } from "./api";
const reloadMocks = vi.hoisted(() => ({ const reloadMocks = vi.hoisted(() => ({
attemptDashboardTokenReloadOnce: vi.fn(() => false), attemptDashboardTokenReloadOnce: vi.fn(() => false),
@@ -33,6 +33,7 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
setManagementProfile("");
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
@@ -172,4 +173,30 @@ describe("api OAuth helpers", () => {
expect((init.headers as Headers).has(SESSION_HEADER)).toBe(false); expect((init.headers as Headers).has(SESSION_HEADER)).toBe(false);
} }
}); });
it("keeps every OAuth operation on the selected management profile", async () => {
vi.stubGlobal("window", {});
const fetchMock = jsonFetchMock({
flow: "device_code",
session_id: "oauth-session",
});
vi.stubGlobal("fetch", fetchMock);
setManagementProfile("worker");
await api.getOAuthProviders();
await api.disconnectOAuthProvider("anthropic");
await api.startOAuthLogin("openai-codex");
await api.submitOAuthCode("anthropic", "oauth-session", "code-123");
await api.pollOAuthSession("anthropic", "oauth-session");
await api.cancelOAuthSession("oauth-session");
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
"/api/providers/oauth?profile=worker",
"/api/providers/oauth/anthropic?profile=worker",
"/api/providers/oauth/openai-codex/start?profile=worker",
"/api/providers/oauth/anthropic/submit?profile=worker",
"/api/providers/oauth/anthropic/poll/oauth-session?profile=worker",
"/api/providers/oauth/sessions/oauth-session?profile=worker",
]);
});
}); });
+4
View File
@@ -79,6 +79,10 @@ const PROFILE_SCOPED_PREFIXES = [
"/api/messaging/platforms", "/api/messaging/platforms",
"/api/messaging/telegram/onboarding", "/api/messaging/telegram/onboarding",
"/api/messaging/whatsapp/onboarding", "/api/messaging/whatsapp/onboarding",
// OAuth/account state is profile-owned too: status, login sessions, polling,
// cancellation, and disconnect must all follow the selected management
// profile rather than silently targeting the dashboard process's profile.
"/api/providers/oauth",
"/api/model/info", "/api/model/info",
"/api/model/set", "/api/model/set",
"/api/model/auxiliary", "/api/model/auxiliary",