Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion docs/OPERATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions talk_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions talk_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
137 changes: 108 additions & 29 deletions talk_grok_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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": (
Expand Down Expand Up @@ -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",
Expand Down
56 changes: 55 additions & 1 deletion tests/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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


Expand Down
Loading