From 10ab7402b7b0d18721ac41731ac8a9a2cc015a8a Mon Sep 17 00:00:00 2001 From: SmokeDev Date: Thu, 3 Sep 2026 16:24:03 -0700 Subject: [PATCH] fix(grok): read the xai-oauth login from the host credential pool `talk doctor` reported a working Grok lane as unconfigured. The read-only parse of the host store knew only one of the two shapes a Hermes `xai-oauth` login lives in -- `providers["xai-oauth"]["tokens"]` -- and a current host writes a device-code login into `credential_pool["xai-oauth"]` instead: a list whose rows carry `access_token` / `refresh_token` FLAT rather than nested under `tokens`. On a box whose `providers` block is empty, the parse returned `missing` and doctor printed [FAIL] auth: no usable Grok authentication lane was found receipt: winner=none, xai-oauth=missing, preference=absent while the live probe in the same process resolved that exact login and got a 200 from `/v1/realtime/client_secrets` and a 101 on the socket. Only the diagnostic was blind; the call itself always worked. `_inspect_store` now mirrors the host's own resolver end to end -- `hermes_cli.auth._xai_oauth_state_from_store` (hermes_cli/auth.py:5287-5321), the function behind `resolve_xai_oauth_runtime_credentials`, which is the resolver `_resolve_via_host` calls -- so the diagnostic predicts the lane it is describing: * `providers` first, then the pool, in the host's order (auth.py:5289-5320); * both tokens required on either shape (auth.py:5291-5294, :5307-5311) -- the same pair check that rejects a quarantined login, since the host quarantines by POPPING both tokens (auth.py:7891-7896); * pool rows walked in stored order, no `priority` sort and no `last_status` read, because the host's resolver does neither there; * a non-list pool slice yields nothing, matching the host's own type check (auth.py:2277-2282, :5303) rather than claiming a login it would refuse. Every existing state string (`missing` / `invalid` / `expired` / `valid`) and the read-only contract are unchanged: no network, no refresh, no write to any auth store. A non-dict `providers` block is no longer a dead end -- it now falls through to the pool instead of short-circuiting to `invalid`. The receipt gained `xai_oauth_source` (`providers` / `credential_pool` / `None`) and `talk doctor` prints `xai-oauth=valid (via credential_pool)`, so the next person to debug this does not have to read the store by hand. Siblings checked, not assumed: `talk_auth.py` resolves `$CODEX_HOME/auth.json` (`_codex_auth_path`, talk_auth.py:116-121), a different file, and `talk_core_provider.py` delegates to it -- neither is affected. `talk_grok_auth.py` is the only reader of `HERMES_HOME/auth.json`. Verified on the live box: same store, `xai_oauth` goes `missing` -> `valid`, `winning_lane` `null` -> `xai-oauth`, `configured` false -> true. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 17 +++ docs/OPERATING.md | 8 +- talk_diagnostics.py | 1 + talk_doctor.py | 4 + talk_grok_auth.py | 137 +++++++++++++++++----- tests/test_doctor.py | 56 ++++++++- tests/test_grok_auth.py | 243 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 435 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd0483..696753a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -170,6 +170,23 @@ named rather than smoothed. `docs/OPERATING.md`'s Audio table. ### Fixed +- `talk doctor` no longer reports a working Grok lane as unconfigured. The + read-only parse of the host store knew one of the two shapes a Hermes + `xai-oauth` login lives in — a `providers` block with the tokens nested + under `tokens` — and a current host writes a device-code login into + `credential_pool` instead, as a list whose rows carry the tokens FLAT. So + every operator who logged in on a current Hermes was told + `no usable Grok authentication lane was found` while the lane resolved and + connected fine; only the read-only diagnostic was blind, never the call. + The parse now mirrors the host's own resolver + (`hermes_cli.auth._xai_oauth_state_from_store`) end to end: `providers` + first, then the pool in stored order, both tokens required on either — the + same pair check that rejects a quarantined login, since the host + quarantines by popping the tokens. A non-list pool slice still yields + nothing, because the host would not read one either. The receipt gained + `xai_oauth_source`, and `talk doctor` now prints + `xai-oauth=valid (via credential_pool)`, so the next person to debug this + can tell an empty store from an unread one. - Linux terminal calls now route default audio through PulseAudio's WebRTC echo canceller and noise suppressor. Echo-cancelled input bypasses the fallback amplitude/VAD gate so barge-in does not clip quiet words. diff --git a/docs/OPERATING.md b/docs/OPERATING.md index 5069de1..522898e 100644 --- a/docs/OPERATING.md +++ b/docs/OPERATING.md @@ -446,8 +446,14 @@ Grok (`TALK_PROVIDER=grok`) resolves its own bearer the same shape: `TALK_PREFER_XAI_OAUTH` → `TALK_XAI_API_KEY` → `XAI_API_KEY` → the host `xai-oauth` login. When the host is importable its resolver owns refresh and quarantine; otherwise `HERMES_HOME/auth.json` is parsed read-only. +That parse reads both shapes the host keeps a login in, in the host's own +order: a `providers` block with the tokens under `tokens`, then the +`credential_pool` list — where a current host writes a device-code login, +with the tokens flat on the row. Both tokens are required either way, which +is also what rejects a quarantined login. Talk never writes either store. Doctor's auth check names the winning lane -(`xai-oauth=valid|expired|invalid|missing`) without refreshing anything; +(`xai-oauth=valid|expired|invalid|missing`, plus `(via providers)` or +`(via credential_pool)` when a login was found) without refreshing anything; `hermes talk doctor --probe` is the one opt-in network call — a `POST /v1/realtime/client_secrets` plus a socket handshake against `api.x.ai` that prints status codes and the first event type, never the token. diff --git a/talk_diagnostics.py b/talk_diagnostics.py index 2ff58d8..b2a3af4 100644 --- a/talk_diagnostics.py +++ b/talk_diagnostics.py @@ -99,6 +99,7 @@ "preference": "token", "codex_oauth": "token", "xai_oauth": "token", + "xai_oauth_source": "token", "host_refresh_available": "bool", "metered_key_present": "bool", "metered_key_wins_over_codex": "bool", diff --git a/talk_doctor.py b/talk_doctor.py index 6d86fa0..a9fb6d7 100644 --- a/talk_doctor.py +++ b/talk_doctor.py @@ -866,6 +866,10 @@ def render_human(report: dict[str, Any]) -> str: if check["id"] == "auth": if "xai_oauth" in details: oauth = f"xai-oauth={details['xai_oauth']}" + # Name the store shape that answered, so a "missing" verdict + # can be told apart from a login the parse never reached. + if details.get("xai_oauth_source"): + oauth += f" (via {details['xai_oauth_source']})" else: oauth = f"codex={details['codex_oauth']}" lines.append( diff --git a/talk_grok_auth.py b/talk_grok_auth.py index 3791984..698ed8f 100644 --- a/talk_grok_auth.py +++ b/talk_grok_auth.py @@ -10,6 +10,12 @@ hermes-talk never implements OAuth and never writes an auth store. When the host is importable its resolver owns refresh and quarantine under its own lock; otherwise the store is parsed read-only. + +The host keeps an ``xai-oauth`` login in either of two shapes, and the +read-only parse has to know both: a ``providers`` block with the tokens nested +under ``tokens``, and — what current hosts write for a device-code login — a +``credential_pool`` list whose rows carry the tokens FLAT. See +:func:`_inspect_store`. """ from __future__ import annotations @@ -37,6 +43,12 @@ SOURCE_ENV = talk_auth.SOURCE_ENV SOURCE_XAI_OAUTH = "xai-oauth" +#: Where in the host store a login was found. The host reads two shapes for +#: ``xai-oauth`` and Talk reports which one answered, so an operator debugging +#: a "no login" verdict can tell an empty store from an unread one. +STORE_PROVIDERS = "providers" +STORE_CREDENTIAL_POOL = "credential_pool" + PREFERENCE_ENV = "TALK_PREFER_XAI_OAUTH" RELOGIN_COMMAND = "hermes auth add xai-oauth" OAUTH_DETAIL = "Hermes xAI OAuth login (SuperGrok / X Premium+ subscription)" @@ -106,48 +118,112 @@ def _store_path(hermes_home: Path | None) -> Path: return home / "auth.json" -def _inspect_store(hermes_home: Path | None) -> tuple[str, str | None, int | None]: - """Read-only look at the host store's ``xai-oauth`` entry. +def _usable_access_token(tokens: object) -> str | None: + """The access token from a mapping the host would accept, else ``None``. + + The host requires BOTH tokens on every candidate it considers, in either + store shape (``hermes_cli/auth.py:5291-5295`` for ``providers``, + ``:5307-5311`` for the pool). That pair check is also what rejects a + quarantined login: the host quarantines by POPPING both tokens off the + state it persists (``hermes_cli/auth.py:7891-7896``), so a quarantined + entry arrives here with nothing to read rather than with a status flag. + """ + + if not isinstance(tokens, Mapping): + return None + access = tokens.get("access_token") + refresh = tokens.get("refresh_token") + if not isinstance(access, str) or not access.strip(): + return None + if not isinstance(refresh, str) or not refresh.strip(): + return None + return access.strip() + + +def _classify_token(access: str) -> tuple[str, str, int | None]: + expires_s = talk_auth._decode_jwt_expiry_s(access) + if expires_s is not None and expires_s <= int(time.time()) + _EXPIRY_MARGIN_S: + return "expired", access, expires_s + return "valid", access, expires_s - Returns ``(state, access_token, expires_s)`` with ``state`` one of + +def _pool_rows(data: Mapping) -> list: + """``credential_pool["xai-oauth"]`` rows, in the order the host reads them. + + A LIST is the only shape the host accepts here — both its pool reader + (``hermes_cli/auth.py:2277-2282``) and its xAI resolver + (``:5303``) type-check for one and ignore anything else — so a non-list + slice yields no candidates rather than a token the host would never use. + """ + + pool = data.get("credential_pool") + if not isinstance(pool, Mapping): + return [] + rows = pool.get(SOURCE_XAI_OAUTH) + return list(rows) if isinstance(rows, list) else [] + + +def _inspect_store(hermes_home: Path | None) -> tuple[str, str | None, int | None, str | None]: + """Read-only look at the host store's ``xai-oauth`` login. + + Returns ``(state, access_token, expires_s, store)`` with ``state`` one of ``missing`` (no entry), ``invalid`` (unreadable, or an entry the host - itself would refuse — both tokens are required), ``expired``, ``valid``. + itself would refuse — both tokens are required), ``expired``, ``valid``, + and ``store`` naming which shape answered (:data:`STORE_PROVIDERS` or + :data:`STORE_CREDENTIAL_POOL`), or ``None`` when nothing was usable. + + Mirrors ``hermes_cli.auth._xai_oauth_state_from_store`` + (``hermes_cli/auth.py:5287-5321``) — the function behind + ``resolve_xai_oauth_runtime_credentials``, which is the resolver + :func:`_resolve_via_host` calls, so predicting it is what makes this + diagnostic agree with the live lane. Its order is ``providers`` FIRST, + then the pool; current hosts write device-code logins into the pool with + the tokens FLAT on the row rather than nested under ``tokens``. """ path = _store_path(hermes_home) try: raw = path.read_text(encoding="utf-8-sig") except FileNotFoundError: - return "missing", None, None + return "missing", None, None, None except OSError as exc: _log.debug("xai-oauth store unreadable: %s", type(exc).__name__) - return "invalid", None, None + return "invalid", None, None, None try: data = json.loads(raw) except ValueError: - return "invalid", None, None + return "invalid", None, None, None if not isinstance(data, dict): - return "invalid", None, None + return "invalid", None, None, None + + # An ``xai-oauth`` login exists in some shape but none of it was usable. + # Separates the host's ``xai_auth_missing`` (fall through to other lanes) + # from its shape/token complaints (refuse and ask for a re-login). + present = False + + # Leg 1 — ``providers["xai-oauth"]["tokens"]`` (hermes_cli/auth.py:5289-5295). providers = data.get("providers") - if not isinstance(providers, dict): - return "invalid", None, None - entry = providers.get(SOURCE_XAI_OAUTH) - if entry is None: - return "missing", None, None - tokens = entry.get("tokens") if isinstance(entry, dict) else None - if not isinstance(tokens, dict): - return "invalid", None, None - access = tokens.get("access_token") - refresh = tokens.get("refresh_token") - if not isinstance(access, str) or not access.strip(): - return "invalid", None, None - if not isinstance(refresh, str) or not refresh.strip(): - return "invalid", None, None - access = access.strip() - expires_s = talk_auth._decode_jwt_expiry_s(access) - if expires_s is not None and expires_s <= int(time.time()) + _EXPIRY_MARGIN_S: - return "expired", access, expires_s - return "valid", access, expires_s + entry = providers.get(SOURCE_XAI_OAUTH) if isinstance(providers, Mapping) else None + if entry is not None: + present = True + tokens = entry.get("tokens") if isinstance(entry, Mapping) else None + access = _usable_access_token(tokens) + if access is not None: + return (*_classify_token(access), STORE_PROVIDERS) + + # Leg 2 — ``credential_pool["xai-oauth"]`` (hermes_cli/auth.py:5297-5320). + # The host walks the list in stored order and takes the first row carrying + # both tokens; it does not sort by ``priority`` or read ``last_status`` + # here, so neither does this. + for row in _pool_rows(data): + if not isinstance(row, Mapping): + continue + present = True + access = _usable_access_token(row) + if access is not None: + return (*_classify_token(access), STORE_CREDENTIAL_POOL) + + return ("invalid" if present else "missing"), None, None, None def _host_refresh_available() -> bool: @@ -218,7 +294,7 @@ def _resolve_xai_oauth(hermes_home: Path | None) -> TalkAuth | None: if _host_refresh_available(): # Host importable and it said "no login"; do not second-guess it. return None - state, token, _expires_s = _inspect_store(hermes_home) + state, token, _expires_s, _store = _inspect_store(hermes_home) if state == "missing": return None if state == "invalid": @@ -312,7 +388,7 @@ def grok_auth_diagnostic( scoped_state = _key_state(env, "TALK_XAI_API_KEY") shared_state = _key_state(env, "XAI_API_KEY") metered_key_present = "present" in (scoped_state, shared_state) - oauth_state, _token, expires_s = _inspect_store(hermes_home) + oauth_state, _token, expires_s, oauth_store = _inspect_store(hermes_home) if oauth_state == "valid" and expires_s is not None and expires_s <= now + _EXPIRY_MARGIN_S: oauth_state = "expired" host_refresh = _host_refresh_available() @@ -347,6 +423,7 @@ def grok_auth_diagnostic( "winning_lane": winning_lane, "preference": preference, "xai_oauth": oauth_state, + "xai_oauth_source": oauth_store, "host_refresh_available": host_refresh, "metered_key_present": metered_key_present, "metered_key_wins_over_oauth": ( @@ -403,6 +480,8 @@ def grok_auth_status( "SOURCE_CONFIGURED", "SOURCE_ENV", "SOURCE_XAI_OAUTH", + "STORE_CREDENTIAL_POOL", + "STORE_PROVIDERS", "TalkAuth", "TalkAuthError", "grok_auth_diagnostic", diff --git a/tests/test_doctor.py b/tests/test_doctor.py index a227560..5184131 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -713,6 +713,36 @@ def _write_xai_oauth(home: Path, *, access: str, refresh: str = XAI_REFRESH) -> return path +def _write_xai_pool(home: Path, *, access: str, refresh: str = XAI_REFRESH) -> Path: + """The other shape: a device-code login in the pool, tokens flat on the row.""" + + home.mkdir(parents=True, exist_ok=True) + path = home / "auth.json" + path.write_text( + json.dumps( + { + "providers": {}, + "credential_pool": { + "xai-oauth": [ + { + "id": "cce4f6", + "label": "xai-oauth-oauth-1", + "auth_type": "oauth", + "priority": 0, + "source": "manual:device_code", + "access_token": access, + "refresh_token": refresh, + "base_url": "https://api.x.ai/v1", + } + ] + }, + } + ), + encoding="utf-8", + ) + return path + + @pytest.fixture def _no_host(monkeypatch): monkeypatch.setitem(sys.modules, "hermes_cli", None) @@ -797,7 +827,31 @@ def test_human_report_renders_the_xai_oauth_receipt(monkeypatch, tmp_path, _no_h rendered = talk_doctor.render_human(talk_doctor.collect_report()) assert "[PASS] auth: xai-oauth is the winning auth lane" in rendered - assert "receipt: winner=xai-oauth, xai-oauth=valid, preference=absent" in rendered + assert ( + "receipt: winner=xai-oauth, xai-oauth=valid (via providers), preference=absent" + in rendered + ) + assert XAI_ACCESS not in rendered + + +def test_human_report_names_the_credential_pool_as_the_source(monkeypatch, tmp_path, _no_host): + """A device-code login lives in the pool; the receipt says so. + + Before the pool was read at all this store rendered + ``xai-oauth=missing`` while the lane worked. + """ + + monkeypatch.setenv("TALK_PROVIDER", "grok") + _write_xai_pool(tmp_path / "hermes", access=_jwt_with_exp(time.time() + 6 * 3600)) + monkeypatch.setattr(talk_doctor.talk_audio, "audio_available", lambda: True) + + rendered = talk_doctor.render_human(talk_doctor.collect_report()) + + assert "[PASS] auth: xai-oauth is the winning auth lane" in rendered + assert ( + "receipt: winner=xai-oauth, xai-oauth=valid (via credential_pool), preference=absent" + in rendered + ) assert XAI_ACCESS not in rendered diff --git a/tests/test_grok_auth.py b/tests/test_grok_auth.py index d629199..80983b5 100644 --- a/tests/test_grok_auth.py +++ b/tests/test_grok_auth.py @@ -338,6 +338,249 @@ def test_file_fallback_reads_a_bom_prefixed_store(tmp_path): assert talk_grok_auth.resolve_grok_auth(env={}, hermes_home=tmp_path).token == token +# -- the credential pool: where a current host writes a device-code login ------- +# +# The host reads two shapes for xai-oauth (hermes_cli/auth.py:5287-5321): +# providers["xai-oauth"]["tokens"] first, then credential_pool["xai-oauth"] -- +# a LIST whose rows carry the tokens FLAT. These tests pin the second shape and +# the order between them. + + +def _pool_row(access: str, refresh: str = REFRESH, **overrides) -> dict: + """One pool row shaped the way the host writes a device-code login.""" + + row = { + "id": "cce4f6", + "label": "xai-oauth-oauth-1", + "auth_type": "oauth", + "priority": 0, + "source": "manual:device_code", + "access_token": access, + "refresh_token": refresh, + "last_status": None, + "last_error_code": None, + "last_error_reason": None, + "base_url": "https://api.x.ai/v1", + "last_refresh": "2026-09-03T23:11:21.553382Z", + "request_count": 0, + } + row.update(overrides) + return row + + +def _write_pool_store(home: Path, pool, *, providers=None) -> Path: + """A store whose xai-oauth login lives in the credential pool.""" + + body = { + "version": 1, + "providers": {} if providers is None else providers, + "credential_pool": {"xai-oauth": pool}, + } + return _write_xai_store(home, access="ignored", raw=json.dumps(body)) + + +def test_pool_only_login_is_valid_and_names_the_pool(tmp_path): + """The regression: providers is empty, the login is in the pool. + + Against the pre-fix parse this store read as ``missing`` and doctor told + the operator to log in again while the lane worked. + """ + + token = _jwt(time.time() + 7200) + path = _write_pool_store(tmp_path, [_pool_row(token)]) + snap = _Snapshot(path) + + receipt = talk_grok_auth.grok_auth_diagnostic(env={}, hermes_home=tmp_path) + + assert receipt["xai_oauth"] == "valid" + assert receipt["xai_oauth_source"] == talk_grok_auth.STORE_CREDENTIAL_POOL + assert receipt["winning_lane"] == talk_grok_auth.SOURCE_XAI_OAUTH + assert receipt["configured"] is True + assert receipt["blocked_by"] is None + snap.assert_untouched() + + +def test_pool_only_login_resolves_without_touching_the_store(tmp_path): + token = _jwt(time.time() + 7200) + path = _write_pool_store(tmp_path, [_pool_row(token)]) + snap = _Snapshot(path) + + auth = talk_grok_auth.resolve_grok_auth(env={}, hermes_home=tmp_path) + + assert auth.token == token + assert auth.source == talk_grok_auth.SOURCE_XAI_OAUTH + snap.assert_untouched() + + +def test_legacy_providers_login_still_wins_and_names_providers(tmp_path): + """Older hosts keep the login under ``providers``; that path is unchanged.""" + + token = _jwt(time.time() + 7200) + _write_xai_store(tmp_path, access=token) + + receipt = talk_grok_auth.grok_auth_diagnostic(env={}, hermes_home=tmp_path) + + assert receipt["xai_oauth"] == "valid" + assert receipt["xai_oauth_source"] == talk_grok_auth.STORE_PROVIDERS + + +def test_providers_is_read_before_the_pool_like_the_host(tmp_path): + """Both shapes present: the host takes providers first (auth.py:5289-5295).""" + + legacy = _jwt(time.time() + 7200, marker="legacy-provider-token") + pooled = _jwt(time.time() + 7200, marker="pooled-token") + _write_pool_store( + tmp_path, + [_pool_row(pooled)], + providers={"xai-oauth": {"tokens": {"access_token": legacy, "refresh_token": REFRESH}}}, + ) + + receipt = talk_grok_auth.grok_auth_diagnostic(env={}, hermes_home=tmp_path) + auth = talk_grok_auth.resolve_grok_auth(env={}, hermes_home=tmp_path) + + assert receipt["xai_oauth_source"] == talk_grok_auth.STORE_PROVIDERS + assert auth.token == legacy + + +def test_unusable_providers_entry_falls_through_to_the_pool(tmp_path): + """A providers block the host would refuse must not mask a usable pool row.""" + + token = _jwt(time.time() + 7200) + _write_pool_store( + tmp_path, + [_pool_row(token)], + providers={"xai-oauth": {"tokens": {"access_token": "", "refresh_token": ""}}}, + ) + + auth = talk_grok_auth.resolve_grok_auth(env={}, hermes_home=tmp_path) + + assert auth.token == token + + +def test_empty_pool_is_missing_not_invalid(tmp_path): + """No login at all: fall through to the other lanes, do not refuse.""" + + path = _write_pool_store(tmp_path, []) + snap = _Snapshot(path) + + receipt = talk_grok_auth.grok_auth_diagnostic(env={}, hermes_home=tmp_path) + assert receipt["xai_oauth"] == "missing" + assert receipt["xai_oauth_source"] is None + + with pytest.raises(talk_auth.TalkAuthError) as info: + talk_grok_auth.resolve_grok_auth(env={}, hermes_home=tmp_path) + assert str(info.value) == talk_grok_auth.GROK_AUTH_REQUIRED_MESSAGE + snap.assert_untouched() + + +def test_quarantined_pool_row_is_invalid(tmp_path): + """The host quarantines by popping both tokens (auth.py:7891-7896). + + A row with the tokens gone is a login that exists and is unusable, which + is the ``invalid`` verdict -- not ``missing``. + """ + + row = _pool_row("unused", last_status="dead", last_error_reason="token_revoked") + row.pop("access_token") + row.pop("refresh_token") + path = _write_pool_store(tmp_path, [row]) + snap = _Snapshot(path) + + receipt = talk_grok_auth.grok_auth_diagnostic(env={}, hermes_home=tmp_path) + assert receipt["xai_oauth"] == "invalid" + assert receipt["xai_oauth_source"] is None + + with pytest.raises(talk_auth.TalkAuthError, match="unreadable"): + talk_grok_auth.resolve_grok_auth(env={}, hermes_home=tmp_path) + snap.assert_untouched() + + +def test_expired_pool_token_is_expired(tmp_path): + path = _write_pool_store(tmp_path, [_pool_row(_jwt(time.time() - 10))]) + snap = _Snapshot(path) + + receipt = talk_grok_auth.grok_auth_diagnostic(env={}, hermes_home=tmp_path) + assert receipt["xai_oauth"] == "expired" + assert receipt["xai_oauth_source"] == talk_grok_auth.STORE_CREDENTIAL_POOL + + with pytest.raises(talk_auth.TalkAuthError, match="has expired"): + talk_grok_auth.resolve_grok_auth(env={}, hermes_home=tmp_path) + snap.assert_untouched() + + +def test_malformed_rows_are_skipped_for_the_first_usable_one(tmp_path): + """The host skips non-dict rows and rows missing either token (auth.py:5304-5311).""" + + token = _jwt(time.time() + 7200) + _write_pool_store( + tmp_path, + [ + "not-a-row", + None, + _pool_row("has-access", refresh=" "), + _pool_row(" ", refresh=REFRESH), + _pool_row(token), + ], + ) + + receipt = talk_grok_auth.grok_auth_diagnostic(env={}, hermes_home=tmp_path) + auth = talk_grok_auth.resolve_grok_auth(env={}, hermes_home=tmp_path) + + assert receipt["xai_oauth"] == "valid" + assert auth.token == token + + +def test_pool_row_without_a_refresh_token_is_refused(tmp_path): + """Both tokens are required on a pool row, exactly as on a providers block.""" + + _write_pool_store(tmp_path, [_pool_row(_jwt(time.time() + 7200), refresh="")]) + + receipt = talk_grok_auth.grok_auth_diagnostic(env={}, hermes_home=tmp_path) + assert receipt["xai_oauth"] == "invalid" + + +def test_a_non_list_pool_slice_yields_no_login_like_the_host(tmp_path): + """The host type-checks for a list (auth.py:5303) and ignores anything else.""" + + body = { + "providers": {}, + "credential_pool": {"xai-oauth": _pool_row(_jwt(time.time() + 7200))}, + } + _write_xai_store(tmp_path, access="ignored", raw=json.dumps(body)) + + receipt = talk_grok_auth.grok_auth_diagnostic(env={}, hermes_home=tmp_path) + assert receipt["xai_oauth"] == "missing" + assert receipt["xai_oauth_source"] is None + + +def test_a_pool_login_survives_a_non_dict_providers_block(tmp_path): + """``providers`` of the wrong type is not a reason to skip the pool.""" + + body = { + "providers": ["not", "a", "mapping"], + "credential_pool": {"xai-oauth": [_pool_row(_jwt(time.time() + 7200))]}, + } + _write_xai_store(tmp_path, access="ignored", raw=json.dumps(body)) + + receipt = talk_grok_auth.grok_auth_diagnostic(env={}, hermes_home=tmp_path) + assert receipt["xai_oauth"] == "valid" + assert receipt["xai_oauth_source"] == talk_grok_auth.STORE_CREDENTIAL_POOL + + +def test_the_pool_receipt_never_carries_the_token(tmp_path): + token = _jwt(time.time() + 7200) + _write_pool_store(tmp_path, [_pool_row(token)]) + + for receipt in ( + talk_grok_auth.grok_auth_diagnostic(env={}, hermes_home=tmp_path), + talk_grok_auth.grok_auth_status(env={}, hermes_home=tmp_path), + ): + blob = json.dumps(receipt, default=str) + assert token not in blob + assert ACCESS not in blob + assert REFRESH not in blob + + # -- the read-only diagnostic ---------------------------------------------------