refactor(auth): transition Codex OAuth tokens to Hermes auth store
Updated the authentication mechanism to store Codex OAuth tokens in the Hermes auth store located at ~/.hermes/auth.json instead of the previous ~/.codex/auth.json. This change includes refactoring related functions for reading and saving tokens, ensuring better management of authentication states and preventing conflicts between different applications. Adjusted tests to reflect the new storage structure and improved error handling for missing or malformed tokens.
This commit is contained in:
parent
8bc2de4ab6
commit
5e598a588f
7 changed files with 295 additions and 380 deletions
|
|
@ -45,29 +45,42 @@ def codex_auth_dir(tmp_path, monkeypatch):
|
|||
|
||||
|
||||
class TestReadCodexAccessToken:
|
||||
def test_valid_auth_file(self, tmp_path):
|
||||
codex_dir = tmp_path / ".codex"
|
||||
codex_dir.mkdir()
|
||||
auth = codex_dir / "auth.json"
|
||||
auth.write_text(json.dumps({
|
||||
"tokens": {"access_token": "tok-123", "refresh_token": "r-456"}
|
||||
def test_valid_auth_store(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {"access_token": "tok-123", "refresh_token": "r-456"},
|
||||
},
|
||||
},
|
||||
}))
|
||||
with patch("agent.auxiliary_client.Path.home", return_value=tmp_path):
|
||||
result = _read_codex_access_token()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
result = _read_codex_access_token()
|
||||
assert result == "tok-123"
|
||||
|
||||
def test_missing_file_returns_none(self, tmp_path):
|
||||
with patch("agent.auxiliary_client.Path.home", return_value=tmp_path):
|
||||
result = _read_codex_access_token()
|
||||
def test_missing_returns_none(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
result = _read_codex_access_token()
|
||||
assert result is None
|
||||
|
||||
def test_empty_token_returns_none(self, tmp_path):
|
||||
codex_dir = tmp_path / ".codex"
|
||||
codex_dir.mkdir()
|
||||
auth = codex_dir / "auth.json"
|
||||
auth.write_text(json.dumps({"tokens": {"access_token": " "}}))
|
||||
with patch("agent.auxiliary_client.Path.home", return_value=tmp_path):
|
||||
result = _read_codex_access_token()
|
||||
def test_empty_token_returns_none(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {"access_token": " ", "refresh_token": "r"},
|
||||
},
|
||||
},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
result = _read_codex_access_token()
|
||||
assert result is None
|
||||
|
||||
def test_malformed_json_returns_none(self, tmp_path):
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"""Tests for Codex auth — tokens stored in Hermes auth store (~/.hermes/auth.json)."""
|
||||
|
||||
import json
|
||||
import time
|
||||
import base64
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
|
@ -12,32 +12,35 @@ from hermes_cli.auth import (
|
|||
AuthError,
|
||||
DEFAULT_CODEX_BASE_URL,
|
||||
PROVIDER_REGISTRY,
|
||||
_persist_codex_auth_payload,
|
||||
_login_openai_codex,
|
||||
login_command,
|
||||
_read_codex_tokens,
|
||||
_save_codex_tokens,
|
||||
_import_codex_cli_tokens,
|
||||
get_codex_auth_status,
|
||||
get_provider_auth_state,
|
||||
read_codex_auth_file,
|
||||
resolve_codex_runtime_credentials,
|
||||
resolve_provider,
|
||||
)
|
||||
|
||||
|
||||
def _write_codex_auth(codex_home: Path, *, access_token: str = "access", refresh_token: str = "refresh") -> Path:
|
||||
codex_home.mkdir(parents=True, exist_ok=True)
|
||||
auth_file = codex_home / "auth.json"
|
||||
auth_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"auth_mode": "oauth",
|
||||
"last_refresh": "2026-02-26T00:00:00Z",
|
||||
def _setup_hermes_auth(hermes_home: Path, *, access_token: str = "access", refresh_token: str = "refresh"):
|
||||
"""Write Codex tokens into the Hermes auth store."""
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
auth_store = {
|
||||
"version": 1,
|
||||
"active_provider": "openai-codex",
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
"last_refresh": "2026-02-26T00:00:00Z",
|
||||
"auth_mode": "chatgpt",
|
||||
},
|
||||
},
|
||||
}
|
||||
auth_file = hermes_home / "auth.json"
|
||||
auth_file.write_text(json.dumps(auth_store, indent=2))
|
||||
return auth_file
|
||||
|
||||
|
||||
|
|
@ -47,42 +50,49 @@ def _jwt_with_exp(exp_epoch: int) -> str:
|
|||
return f"h.{encoded}.s"
|
||||
|
||||
|
||||
def test_read_codex_auth_file_success(tmp_path, monkeypatch):
|
||||
codex_home = tmp_path / "codex-home"
|
||||
auth_file = _write_codex_auth(codex_home)
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
def test_read_codex_tokens_success(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
_setup_hermes_auth(hermes_home)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
payload = read_codex_auth_file()
|
||||
data = _read_codex_tokens()
|
||||
assert data["tokens"]["access_token"] == "access"
|
||||
assert data["tokens"]["refresh_token"] == "refresh"
|
||||
|
||||
assert payload["auth_path"] == auth_file
|
||||
assert payload["tokens"]["access_token"] == "access"
|
||||
assert payload["tokens"]["refresh_token"] == "refresh"
|
||||
|
||||
def test_read_codex_tokens_missing(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
# Empty auth store
|
||||
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
with pytest.raises(AuthError) as exc:
|
||||
_read_codex_tokens()
|
||||
assert exc.value.code == "codex_auth_missing"
|
||||
|
||||
|
||||
def test_resolve_codex_runtime_credentials_missing_access_token(tmp_path, monkeypatch):
|
||||
codex_home = tmp_path / "codex-home"
|
||||
_write_codex_auth(codex_home, access_token="")
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
hermes_home = tmp_path / "hermes"
|
||||
_setup_hermes_auth(hermes_home, access_token="")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
with pytest.raises(AuthError) as exc:
|
||||
resolve_codex_runtime_credentials()
|
||||
|
||||
assert exc.value.code == "codex_auth_missing_access_token"
|
||||
assert exc.value.relogin_required is True
|
||||
|
||||
|
||||
def test_resolve_codex_runtime_credentials_refreshes_expiring_token(tmp_path, monkeypatch):
|
||||
codex_home = tmp_path / "codex-home"
|
||||
hermes_home = tmp_path / "hermes"
|
||||
expiring_token = _jwt_with_exp(int(time.time()) - 10)
|
||||
_write_codex_auth(codex_home, access_token=expiring_token, refresh_token="refresh-old")
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
_setup_hermes_auth(hermes_home, access_token=expiring_token, refresh_token="refresh-old")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
called = {"count": 0}
|
||||
|
||||
def _fake_refresh(*, payload, auth_path, timeout_seconds, lock_held=False):
|
||||
def _fake_refresh(tokens, timeout_seconds):
|
||||
called["count"] += 1
|
||||
assert auth_path == codex_home / "auth.json"
|
||||
assert lock_held is True
|
||||
return {"access_token": "access-new", "refresh_token": "refresh-new"}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth._refresh_codex_auth_tokens", _fake_refresh)
|
||||
|
|
@ -94,15 +104,14 @@ def test_resolve_codex_runtime_credentials_refreshes_expiring_token(tmp_path, mo
|
|||
|
||||
|
||||
def test_resolve_codex_runtime_credentials_force_refresh(tmp_path, monkeypatch):
|
||||
codex_home = tmp_path / "codex-home"
|
||||
_write_codex_auth(codex_home, access_token="access-current", refresh_token="refresh-old")
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
hermes_home = tmp_path / "hermes"
|
||||
_setup_hermes_auth(hermes_home, access_token="access-current", refresh_token="refresh-old")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
called = {"count": 0}
|
||||
|
||||
def _fake_refresh(*, payload, auth_path, timeout_seconds, lock_held=False):
|
||||
def _fake_refresh(tokens, timeout_seconds):
|
||||
called["count"] += 1
|
||||
assert lock_held is True
|
||||
return {"access_token": "access-forced", "refresh_token": "refresh-new"}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth._refresh_codex_auth_tokens", _fake_refresh)
|
||||
|
|
@ -113,98 +122,71 @@ def test_resolve_codex_runtime_credentials_force_refresh(tmp_path, monkeypatch):
|
|||
assert resolved["api_key"] == "access-forced"
|
||||
|
||||
|
||||
def test_resolve_codex_runtime_credentials_uses_file_lock_on_refresh(tmp_path, monkeypatch):
|
||||
codex_home = tmp_path / "codex-home"
|
||||
_write_codex_auth(codex_home, access_token="access-current", refresh_token="refresh-old")
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
|
||||
lock_calls = {"enter": 0, "exit": 0}
|
||||
|
||||
@contextmanager
|
||||
def _fake_lock(auth_path, timeout_seconds=15.0):
|
||||
assert auth_path == codex_home / "auth.json"
|
||||
lock_calls["enter"] += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
lock_calls["exit"] += 1
|
||||
|
||||
refresh_calls = {"count": 0}
|
||||
|
||||
def _fake_refresh(*, payload, auth_path, timeout_seconds, lock_held=False):
|
||||
refresh_calls["count"] += 1
|
||||
assert lock_held is True
|
||||
return {"access_token": "access-updated", "refresh_token": "refresh-updated"}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth._codex_auth_file_lock", _fake_lock)
|
||||
monkeypatch.setattr("hermes_cli.auth._refresh_codex_auth_tokens", _fake_refresh)
|
||||
|
||||
resolved = resolve_codex_runtime_credentials(force_refresh=True, refresh_if_expiring=False)
|
||||
|
||||
assert refresh_calls["count"] == 1
|
||||
assert lock_calls["enter"] == 1
|
||||
assert lock_calls["exit"] == 1
|
||||
assert resolved["api_key"] == "access-updated"
|
||||
|
||||
|
||||
def test_resolve_provider_explicit_codex_does_not_fallback(monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
assert resolve_provider("openai-codex") == "openai-codex"
|
||||
|
||||
|
||||
def test_persist_codex_auth_payload_writes_atomically(tmp_path):
|
||||
auth_path = tmp_path / "auth.json"
|
||||
auth_path.write_text('{"stale":true}\n')
|
||||
payload = {
|
||||
"auth_mode": "oauth",
|
||||
"tokens": {
|
||||
"access_token": "next-access",
|
||||
"refresh_token": "next-refresh",
|
||||
},
|
||||
"last_refresh": "2026-02-26T00:00:00Z",
|
||||
}
|
||||
def test_save_codex_tokens_roundtrip(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
_persist_codex_auth_payload(auth_path, payload)
|
||||
_save_codex_tokens({"access_token": "at123", "refresh_token": "rt456"})
|
||||
data = _read_codex_tokens()
|
||||
|
||||
stored = json.loads(auth_path.read_text())
|
||||
assert stored == payload
|
||||
assert list(tmp_path.glob(".auth.json.*.tmp")) == []
|
||||
assert data["tokens"]["access_token"] == "at123"
|
||||
assert data["tokens"]["refresh_token"] == "rt456"
|
||||
|
||||
|
||||
def test_get_codex_auth_status_not_logged_in(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "missing-codex-home"))
|
||||
status = get_codex_auth_status()
|
||||
assert status["logged_in"] is False
|
||||
assert "error" in status
|
||||
def test_import_codex_cli_tokens(tmp_path, monkeypatch):
|
||||
codex_home = tmp_path / "codex-cli"
|
||||
codex_home.mkdir(parents=True, exist_ok=True)
|
||||
(codex_home / "auth.json").write_text(json.dumps({
|
||||
"tokens": {"access_token": "cli-at", "refresh_token": "cli-rt"},
|
||||
}))
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
|
||||
tokens = _import_codex_cli_tokens()
|
||||
assert tokens is not None
|
||||
assert tokens["access_token"] == "cli-at"
|
||||
assert tokens["refresh_token"] == "cli-rt"
|
||||
|
||||
|
||||
def test_login_openai_codex_persists_provider_state(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes-home"
|
||||
codex_home = tmp_path / "codex-home"
|
||||
_write_codex_auth(codex_home)
|
||||
def test_import_codex_cli_tokens_missing(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "nonexistent"))
|
||||
assert _import_codex_cli_tokens() is None
|
||||
|
||||
|
||||
def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch):
|
||||
"""Verify Hermes never writes to ~/.codex/auth.json."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
codex_home = tmp_path / "codex-cli"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
codex_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
# Mock input() to accept existing credentials
|
||||
monkeypatch.setattr("builtins.input", lambda _: "y")
|
||||
|
||||
_login_openai_codex(SimpleNamespace(), PROVIDER_REGISTRY["openai-codex"])
|
||||
_save_codex_tokens({"access_token": "hermes-at", "refresh_token": "hermes-rt"})
|
||||
|
||||
state = get_provider_auth_state("openai-codex")
|
||||
assert state is not None
|
||||
assert state["source"] == "codex-auth-json"
|
||||
assert state["auth_file"].endswith("auth.json")
|
||||
# ~/.codex/auth.json should NOT exist
|
||||
assert not (codex_home / "auth.json").exists()
|
||||
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config = yaml.safe_load(config_path.read_text())
|
||||
assert config["model"]["provider"] == "openai-codex"
|
||||
assert config["model"]["base_url"] == DEFAULT_CODEX_BASE_URL
|
||||
# Hermes auth store should have the tokens
|
||||
data = _read_codex_tokens()
|
||||
assert data["tokens"]["access_token"] == "hermes-at"
|
||||
|
||||
|
||||
def test_login_command_shows_deprecation(monkeypatch, capsys):
|
||||
"""login_command is deprecated and directs users to hermes model."""
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
login_command(SimpleNamespace())
|
||||
assert exc_info.value.code == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "hermes model" in captured.out
|
||||
def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
_setup_hermes_auth(hermes_home)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
creds = resolve_codex_runtime_credentials()
|
||||
assert creds["source"] == "hermes-auth-store"
|
||||
assert creds["provider"] == "openai-codex"
|
||||
assert creds["base_url"] == DEFAULT_CODEX_BASE_URL
|
||||
|
|
|
|||
|
|
@ -10,42 +10,41 @@ from hermes_cli.auth import detect_external_credentials
|
|||
|
||||
|
||||
class TestDetectCodexCLI:
|
||||
def test_detects_valid_codex_auth(self, tmp_path):
|
||||
def test_detects_valid_codex_auth(self, tmp_path, monkeypatch):
|
||||
codex_dir = tmp_path / ".codex"
|
||||
codex_dir.mkdir()
|
||||
auth = codex_dir / "auth.json"
|
||||
auth.write_text(json.dumps({
|
||||
"tokens": {"access_token": "tok-123", "refresh_token": "ref-456"}
|
||||
}))
|
||||
with patch("hermes_cli.auth.resolve_codex_home_path", return_value=codex_dir):
|
||||
result = detect_external_credentials()
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_dir))
|
||||
result = detect_external_credentials()
|
||||
codex_hits = [c for c in result if c["provider"] == "openai-codex"]
|
||||
assert len(codex_hits) == 1
|
||||
assert "Codex CLI" in codex_hits[0]["label"]
|
||||
assert str(auth) == codex_hits[0]["path"]
|
||||
|
||||
def test_skips_codex_without_access_token(self, tmp_path):
|
||||
def test_skips_codex_without_access_token(self, tmp_path, monkeypatch):
|
||||
codex_dir = tmp_path / ".codex"
|
||||
codex_dir.mkdir()
|
||||
(codex_dir / "auth.json").write_text(json.dumps({"tokens": {}}))
|
||||
with patch("hermes_cli.auth.resolve_codex_home_path", return_value=codex_dir):
|
||||
result = detect_external_credentials()
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_dir))
|
||||
result = detect_external_credentials()
|
||||
assert not any(c["provider"] == "openai-codex" for c in result)
|
||||
|
||||
def test_skips_missing_codex_dir(self, tmp_path):
|
||||
with patch("hermes_cli.auth.resolve_codex_home_path", return_value=tmp_path / "nonexistent"):
|
||||
result = detect_external_credentials()
|
||||
def test_skips_missing_codex_dir(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "nonexistent"))
|
||||
result = detect_external_credentials()
|
||||
assert not any(c["provider"] == "openai-codex" for c in result)
|
||||
|
||||
def test_skips_malformed_codex_auth(self, tmp_path):
|
||||
def test_skips_malformed_codex_auth(self, tmp_path, monkeypatch):
|
||||
codex_dir = tmp_path / ".codex"
|
||||
codex_dir.mkdir()
|
||||
(codex_dir / "auth.json").write_text("{bad json")
|
||||
with patch("hermes_cli.auth.resolve_codex_home_path", return_value=codex_dir):
|
||||
result = detect_external_credentials()
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_dir))
|
||||
result = detect_external_credentials()
|
||||
assert not any(c["provider"] == "openai-codex" for c in result)
|
||||
|
||||
def test_returns_empty_when_nothing_found(self, tmp_path):
|
||||
with patch("hermes_cli.auth.resolve_codex_home_path", return_value=tmp_path / ".codex"):
|
||||
result = detect_external_credentials()
|
||||
def test_returns_empty_when_nothing_found(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "nonexistent"))
|
||||
result = detect_external_credentials()
|
||||
assert result == []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue