fix(auth): align Codex OAuth persistence paths (#37517)
* fix(desktop): codex OAuth onboarding now resolves on fresh install The desktop codex device-code worker persisted tokens with a hand-rolled pool.add_entry(), writing only credential_pool.openai-codex. It never set active_provider, so on a fresh install the onboarding setup.runtime_check resolved provider "auto", couldn't detect the Codex OAuth session, and raised "No inference provider configured" — while setup.status (which sniffs the pool) reported configured. The disagreement surfaced as the onboarding banner "Connected, but Hermes still cannot resolve a usable provider." Use the canonical _save_codex_tokens() instead, matching the CLI's `hermes auth add openai-codex` path and the Nous/MiniMax dashboard workers. It writes the providers.openai-codex singleton (setting active_provider) and syncs the pool. * fix(auth): align Codex OAuth persistence paths Ensure desktop and CLI Codex OAuth logins both write the canonical provider state so fresh installs resolve a usable runtime provider. --------- Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
This commit is contained in:
@ -1891,6 +1891,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
|||||||
# via `hermes auth openai-codex`.
|
# via `hermes auth openai-codex`.
|
||||||
if isinstance(tokens, dict) and tokens.get("access_token"):
|
if isinstance(tokens, dict) and tokens.get("access_token"):
|
||||||
active_sources.add("device_code")
|
active_sources.add("device_code")
|
||||||
|
custom_label = str(state.get("label") or "").strip()
|
||||||
changed |= _upsert_entry(
|
changed |= _upsert_entry(
|
||||||
entries,
|
entries,
|
||||||
provider,
|
provider,
|
||||||
@ -1902,7 +1903,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
|||||||
"refresh_token": tokens.get("refresh_token"),
|
"refresh_token": tokens.get("refresh_token"),
|
||||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||||
"last_refresh": state.get("last_refresh"),
|
"last_refresh": state.get("last_refresh"),
|
||||||
"label": label_from_token(tokens.get("access_token", ""), "device_code"),
|
"label": custom_label or label_from_token(tokens.get("access_token", ""), "device_code"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -3370,7 +3370,7 @@ def _sync_codex_pool_entries(
|
|||||||
entry["last_error_reset_at"] = None
|
entry["last_error_reset_at"] = None
|
||||||
|
|
||||||
|
|
||||||
def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None:
|
def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label: str = None) -> None:
|
||||||
"""Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json)."""
|
"""Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json)."""
|
||||||
if last_refresh is None:
|
if last_refresh is None:
|
||||||
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
@ -3380,6 +3380,8 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None
|
|||||||
state["tokens"] = tokens
|
state["tokens"] = tokens
|
||||||
state["last_refresh"] = last_refresh
|
state["last_refresh"] = last_refresh
|
||||||
state["auth_mode"] = "chatgpt"
|
state["auth_mode"] = "chatgpt"
|
||||||
|
if label and str(label).strip():
|
||||||
|
state["label"] = str(label).strip()
|
||||||
_save_provider_state(auth_store, "openai-codex", state)
|
_save_provider_state(auth_store, "openai-codex", state)
|
||||||
_sync_codex_pool_entries(auth_store, tokens, last_refresh)
|
_sync_codex_pool_entries(auth_store, tokens, last_refresh)
|
||||||
_save_auth_store(auth_store)
|
_save_auth_store(auth_store)
|
||||||
|
|||||||
@ -307,28 +307,20 @@ def auth_add_command(args) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if provider == "openai-codex":
|
if provider == "openai-codex":
|
||||||
# Clear any existing suppression marker so a re-link after `hermes auth
|
|
||||||
# remove openai-codex` works without the new tokens being skipped.
|
|
||||||
auth_mod.unsuppress_credential_source(provider, "device_code")
|
|
||||||
creds = auth_mod._codex_device_code_login()
|
creds = auth_mod._codex_device_code_login()
|
||||||
label = (getattr(args, "label", None) or "").strip() or label_from_token(
|
label = (getattr(args, "label", None) or "").strip() or label_from_token(
|
||||||
creds["tokens"]["access_token"],
|
creds["tokens"]["access_token"],
|
||||||
_oauth_default_label(provider, len(pool.entries()) + 1),
|
_oauth_default_label(provider, len(pool.entries()) + 1),
|
||||||
)
|
)
|
||||||
entry = PooledCredential(
|
auth_mod._save_codex_tokens(
|
||||||
provider=provider,
|
creds["tokens"],
|
||||||
id=uuid.uuid4().hex[:6],
|
|
||||||
label=label,
|
|
||||||
auth_type=AUTH_TYPE_OAUTH,
|
|
||||||
priority=0,
|
|
||||||
source=f"{SOURCE_MANUAL}:device_code",
|
|
||||||
access_token=creds["tokens"]["access_token"],
|
|
||||||
refresh_token=creds["tokens"].get("refresh_token"),
|
|
||||||
base_url=creds.get("base_url"),
|
|
||||||
last_refresh=creds.get("last_refresh"),
|
last_refresh=creds.get("last_refresh"),
|
||||||
|
label=label,
|
||||||
)
|
)
|
||||||
pool.add_entry(entry)
|
pool = load_pool(provider)
|
||||||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
entry = next((item for item in pool.entries() if item.source == "device_code"), None)
|
||||||
|
shown_label = entry.label if entry is not None else label
|
||||||
|
print(f'Saved {provider} OAuth device-code credentials: "{shown_label}"')
|
||||||
return
|
return
|
||||||
|
|
||||||
if provider == "xai-oauth":
|
if provider == "xai-oauth":
|
||||||
|
|||||||
@ -3729,31 +3729,12 @@ def _codex_full_login_worker(session_id: str) -> None:
|
|||||||
if not access_token:
|
if not access_token:
|
||||||
raise RuntimeError("token exchange did not return access_token")
|
raise RuntimeError("token exchange did not return access_token")
|
||||||
|
|
||||||
# Persist via credential pool — same shape as auth_commands.add_command
|
from hermes_cli.auth import _save_codex_tokens
|
||||||
from agent.credential_pool import (
|
|
||||||
PooledCredential,
|
_save_codex_tokens({
|
||||||
load_pool,
|
"access_token": access_token,
|
||||||
AUTH_TYPE_OAUTH,
|
"refresh_token": refresh_token,
|
||||||
SOURCE_MANUAL,
|
})
|
||||||
)
|
|
||||||
import uuid as _uuid
|
|
||||||
pool = load_pool("openai-codex")
|
|
||||||
base_url = (
|
|
||||||
os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/")
|
|
||||||
or DEFAULT_CODEX_BASE_URL
|
|
||||||
)
|
|
||||||
entry = PooledCredential(
|
|
||||||
provider="openai-codex",
|
|
||||||
id=_uuid.uuid4().hex[:6],
|
|
||||||
label="dashboard device_code",
|
|
||||||
auth_type=AUTH_TYPE_OAUTH,
|
|
||||||
priority=0,
|
|
||||||
source=f"{SOURCE_MANUAL}:dashboard_device_code",
|
|
||||||
access_token=access_token,
|
|
||||||
refresh_token=refresh_token,
|
|
||||||
base_url=base_url,
|
|
||||||
)
|
|
||||||
pool.add_entry(entry)
|
|
||||||
with _oauth_sessions_lock:
|
with _oauth_sessions_lock:
|
||||||
sess["status"] = "approved"
|
sess["status"] = "approved"
|
||||||
_log.info("oauth/device: openai-codex login completed (session=%s)", session_id)
|
_log.info("oauth/device: openai-codex login completed (session=%s)", session_id)
|
||||||
|
|||||||
@ -303,9 +303,11 @@ def test_auth_add_codex_oauth_persists_pool_entry(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||||
entries = payload["credential_pool"]["openai-codex"]
|
entries = payload["credential_pool"]["openai-codex"]
|
||||||
entry = next(item for item in entries if item["source"] == "manual:device_code")
|
entry = next(item for item in entries if item["source"] == "device_code")
|
||||||
|
assert payload["active_provider"] == "openai-codex"
|
||||||
|
assert payload["providers"]["openai-codex"]["tokens"]["access_token"] == token
|
||||||
assert entry["label"] == "codex@example.com"
|
assert entry["label"] == "codex@example.com"
|
||||||
assert entry["source"] == "manual:device_code"
|
assert entry["source"] == "device_code"
|
||||||
assert entry["refresh_token"] == "refresh-token"
|
assert entry["refresh_token"] == "refresh-token"
|
||||||
assert entry["base_url"] == "https://chatgpt.com/backend-api/codex"
|
assert entry["base_url"] == "https://chatgpt.com/backend-api/codex"
|
||||||
|
|
||||||
@ -1129,10 +1131,6 @@ def test_auth_remove_codex_manual_source_suppresses_reseed(tmp_path, monkeypatch
|
|||||||
def test_auth_add_codex_clears_suppression_marker(tmp_path, monkeypatch):
|
def test_auth_add_codex_clears_suppression_marker(tmp_path, monkeypatch):
|
||||||
"""Re-linking codex via `hermes auth add openai-codex` must clear any suppression marker."""
|
"""Re-linking codex via `hermes auth add openai-codex` must clear any suppression marker."""
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||||
monkeypatch.setattr(
|
|
||||||
"agent.credential_pool._seed_from_singletons",
|
|
||||||
lambda provider, entries: (False, set()),
|
|
||||||
)
|
|
||||||
hermes_home = tmp_path / "hermes"
|
hermes_home = tmp_path / "hermes"
|
||||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@ -1171,7 +1169,8 @@ def test_auth_add_codex_clears_suppression_marker(tmp_path, monkeypatch):
|
|||||||
assert "openai-codex" not in payload.get("suppressed_sources", {})
|
assert "openai-codex" not in payload.get("suppressed_sources", {})
|
||||||
# New pool entry must be present
|
# New pool entry must be present
|
||||||
entries = payload["credential_pool"]["openai-codex"]
|
entries = payload["credential_pool"]["openai-codex"]
|
||||||
assert any(e["source"] == "manual:device_code" for e in entries)
|
assert any(e["source"] == "device_code" for e in entries)
|
||||||
|
assert payload["active_provider"] == "openai-codex"
|
||||||
|
|
||||||
|
|
||||||
def test_seed_from_singletons_respects_codex_suppression(tmp_path, monkeypatch):
|
def test_seed_from_singletons_respects_codex_suppression(tmp_path, monkeypatch):
|
||||||
|
|||||||
@ -146,6 +146,67 @@ def test_nous_dashboard_device_flow_does_not_retry_legacy_scope_on_invoke_refusa
|
|||||||
assert requested_scopes == [auth_mod.DEFAULT_NOUS_SCOPE]
|
assert requested_scopes == [auth_mod.DEFAULT_NOUS_SCOPE]
|
||||||
|
|
||||||
|
|
||||||
|
def test_codex_dashboard_worker_persists_runtime_provider(tmp_path, monkeypatch):
|
||||||
|
from hermes_cli import web_server as ws
|
||||||
|
from hermes_cli.auth import get_active_provider
|
||||||
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||||
|
|
||||||
|
access_token = "h.eyJleHAiOjk5OTk5OTk5OTl9.s"
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def __init__(self, status_code, payload):
|
||||||
|
self.status_code = status_code
|
||||||
|
self._payload = payload
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def post(self, url, **kwargs):
|
||||||
|
if url.endswith("/deviceauth/usercode"):
|
||||||
|
return _Resp(200, {
|
||||||
|
"device_auth_id": "device-auth-id",
|
||||||
|
"interval": 3,
|
||||||
|
"user_code": "CODEX-1234",
|
||||||
|
})
|
||||||
|
if url.endswith("/deviceauth/token"):
|
||||||
|
return _Resp(200, {
|
||||||
|
"authorization_code": "authorization-code",
|
||||||
|
"code_verifier": "code-verifier",
|
||||||
|
})
|
||||||
|
return _Resp(200, {
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": "codex-refresh",
|
||||||
|
})
|
||||||
|
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
monkeypatch.setattr(httpx, "Client", _Client)
|
||||||
|
monkeypatch.setattr(ws.time, "sleep", lambda _: None)
|
||||||
|
|
||||||
|
sid, _ = ws._new_oauth_session("openai-codex", "device_code")
|
||||||
|
try:
|
||||||
|
ws._codex_full_login_worker(sid)
|
||||||
|
|
||||||
|
assert ws._oauth_sessions[sid]["status"] == "approved"
|
||||||
|
assert get_active_provider() == "openai-codex"
|
||||||
|
|
||||||
|
runtime = resolve_runtime_provider(requested=None)
|
||||||
|
assert runtime["provider"] == "openai-codex"
|
||||||
|
assert runtime["api_key"] == access_token
|
||||||
|
assert runtime["api_mode"] == "codex_responses"
|
||||||
|
finally:
|
||||||
|
ws._oauth_sessions.pop(sid, None)
|
||||||
|
|
||||||
|
|
||||||
def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(monkeypatch):
|
def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(monkeypatch):
|
||||||
from hermes_cli import auth as auth_mod
|
from hermes_cli import auth as auth_mod
|
||||||
from hermes_cli import web_server as ws
|
from hermes_cli import web_server as ws
|
||||||
|
|||||||
Reference in New Issue
Block a user