The independent-account test drives the real load_pool() -> _refresh_entry() path and asserts the second account POSTs its own refresh token and keeps its principal (red on base: the row silently became account A). The alias test pins both halves of the same-account rule: never fall back onto an older singleton, always follow a newer re-auth.
107 lines
5.0 KiB
Python
107 lines
5.0 KiB
Python
"""Codex pool entries and the auth.json singleton: who may adopt whose tokens.
|
|
|
|
``manual:device_code`` is ambiguous — a legacy alias of the singleton or an independent account
|
|
added with ``hermes auth add openai-codex``. Adopting the singleton into an independent account
|
|
silently turned two logins into one (both then hit the same usage limit; salvaged from #100423 and
|
|
#106788, cluster #92198 / #95297, issue #106705).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import time
|
|
|
|
import pytest
|
|
|
|
import hermes_cli.auth as auth_mod
|
|
from agent.credential_pool import load_pool
|
|
|
|
|
|
def _jwt(account: str, sub: str, exp: float) -> str:
|
|
def seg(obj: dict) -> str:
|
|
return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode()
|
|
|
|
payload = {"exp": int(exp), "sub": sub, "https://api.openai.com/auth": {"chatgpt_account_id": account}}
|
|
return f"{seg({'alg': 'none'})}.{seg(payload)}.sig"
|
|
|
|
|
|
def _iso(ts: float) -> str:
|
|
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts))
|
|
|
|
|
|
def _write_store(home, singleton_tokens: dict, singleton_last_refresh: str, manual: dict) -> None:
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
(home / "auth.json").write_text(json.dumps({
|
|
"version": 1,
|
|
"active_provider": "openai-codex",
|
|
"providers": {"openai-codex": {
|
|
"tokens": singleton_tokens, "last_refresh": singleton_last_refresh, "auth_mode": "chatgpt"}},
|
|
"credential_pool": {"openai-codex": [
|
|
{"id": "seeded", "label": "device_code", "auth_type": "oauth", "priority": 0,
|
|
"source": "device_code", **singleton_tokens, "last_refresh": singleton_last_refresh},
|
|
{"id": "manual", "label": "second", "auth_type": "oauth", "priority": 1,
|
|
"source": "manual:device_code", **manual},
|
|
]},
|
|
}), encoding="utf-8")
|
|
|
|
|
|
@pytest.fixture
|
|
def home(tmp_path, monkeypatch):
|
|
home = tmp_path / "hermes"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
return home
|
|
|
|
|
|
def _stub_refresh(monkeypatch, minted: str, minted_rt: str, posted: list) -> None:
|
|
def fake(access_token, refresh_token):
|
|
posted.append(refresh_token)
|
|
return {"access_token": minted, "refresh_token": minted_rt, "last_refresh": _iso(time.time())}
|
|
|
|
monkeypatch.setattr(auth_mod, "refresh_codex_oauth_pure", fake)
|
|
|
|
|
|
def test_independent_manual_account_refreshes_with_its_own_pair(home, monkeypatch):
|
|
"""An independent second account never adopts the singleton: its refresh POSTs its OWN refresh
|
|
token, and the persisted row still identifies the second principal afterwards."""
|
|
now = time.time()
|
|
a_at = _jwt("acct-A", "user-A", now + 8 * 3600)
|
|
b_at = _jwt("acct-B", "user-B", now + 60) # expiring → the pool defers it to _refresh_entry()
|
|
_write_store(home, {"access_token": a_at, "refresh_token": "rt-A"}, _iso(now - 3600),
|
|
{"access_token": b_at, "refresh_token": "rt-B", "last_refresh": _iso(now - 7200)})
|
|
posted: list = []
|
|
b_new = _jwt("acct-B", "user-B", now + 8 * 3600)
|
|
_stub_refresh(monkeypatch, b_new, "rt-B2", posted)
|
|
|
|
pool = load_pool("openai-codex")
|
|
refreshed = pool._refresh_entry(next(e for e in pool.entries() if e.id == "manual"), force=False)
|
|
|
|
assert posted == ["rt-B"]
|
|
assert refreshed is not None and refreshed.refresh_token == "rt-B2"
|
|
on_disk = json.loads((home / "auth.json").read_text(encoding="utf-8"))
|
|
manual = next(e for e in on_disk["credential_pool"]["openai-codex"] if e["id"] == "manual")
|
|
assert (manual["access_token"], manual["refresh_token"]) == (b_new, "rt-B2")
|
|
# The singleton (account A) is untouched by account B's rotation.
|
|
assert on_disk["providers"]["openai-codex"]["tokens"] == {"access_token": a_at, "refresh_token": "rt-A"}
|
|
|
|
|
|
def test_same_account_alias_adopts_only_a_newer_singleton(home, monkeypatch):
|
|
"""A legacy alias (same principal) must follow a singleton that was re-authed AFTER it, but must
|
|
not fall back onto a singleton older than its own rotation — that replays a consumed token."""
|
|
now = time.time()
|
|
alias_at = _jwt("acct-A", "user-A", now + 8 * 3600)
|
|
stale_singleton_at = _jwt("acct-A", "user-A", now + 3600)
|
|
_write_store(home, {"access_token": stale_singleton_at, "refresh_token": "rt-consumed"}, _iso(now - 7200),
|
|
{"access_token": alias_at, "refresh_token": "rt-alias", "last_refresh": _iso(now - 60)})
|
|
pool = load_pool("openai-codex")
|
|
alias = next(e for e in pool.entries() if e.id == "manual")
|
|
|
|
synced = pool._sync_entry_from_auth_store(alias)
|
|
assert (synced.access_token, synced.refresh_token) == (alias_at, "rt-alias")
|
|
|
|
# The user re-authenticates the singleton (newer stamp, same principal): the alias follows.
|
|
fresh_at = _jwt("acct-A", "user-A", now + 9 * 3600)
|
|
_write_store(home, {"access_token": fresh_at, "refresh_token": "rt-fresh"}, _iso(now + 5),
|
|
{"access_token": alias_at, "refresh_token": "rt-alias", "last_refresh": _iso(now - 60)})
|
|
synced = load_pool("openai-codex")._sync_entry_from_auth_store(alias)
|
|
assert (synced.access_token, synced.refresh_token) == (fresh_at, "rt-fresh")
|