Skip to content

fix(grok): read the xai-oauth login from the host credential pool - #115

Merged
TheSmokeDev merged 1 commit into
mainfrom
fix/grok-auth-credential-pool
Sep 3, 2026
Merged

fix(grok): read the xai-oauth login from the host credential pool#115
TheSmokeDev merged 1 commit into
mainfrom
fix/grok-auth-credential-pool

Conversation

@TheSmokeDev

Copy link
Copy Markdown
Owner

The bug

hermes talk doctor reported a working Grok lane as unconfigured:

[FAIL] provider: provider grok is selected but no xAI key or xAI OAuth login is configured
[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 connected:

Hermes Talk probe (live: two calls to api.x.ai)
  auth lane: xai-oauth, model: grok-voice-latest
  POST /v1/realtime/client_secrets -> 200
  WS /v1/realtime -> 101, first event: session.created
Probe: PASS

Only the read-only diagnostic was blind. The call itself always worked — which is the
worst shape for this bug, because the receipt is the thing an operator trusts when the
lane doesn't work.

Root cause

talk_grok_auth._inspect_store knew one of the two shapes a Hermes xai-oauth
login lives in: providers["xai-oauth"]["tokens"]. A current host writes a device-code
login into credential_pool["xai-oauth"] instead — a list whose rows carry
access_token / refresh_token flat, not nested under tokens.

On this box providers is {} and the pool holds openai-api, copilot, gemini, openai-codex, xai-oauth. So the parse fell off providers.get("xai-oauth") is None and
returned missing — for every operator who logged in on a current Hermes.

The host rules I mirrored (read, not guessed)

_inspect_store now mirrors hermes_cli.auth._xai_oauth_state_from_store
(hermes_cli/auth.py:5287-5321) end to end. That is the right function to mirror
because it is what resolve_xai_oauth_runtime_credentials reads
(auth.py:5846), and that resolver is what _resolve_via_host() calls — so the
diagnostic now predicts the lane it is describing.

Rule Host source What this PR does
providers block is read first, pool second auth.py:5289-5295 then :5297-5320 same order
Both access_token and refresh_token required, .strip()-non-blank auth.py:5291-5294 (providers), :5307-5311 (pool) same, one shared helper
Quarantine is expressed by popping both tokens, not by a status flag auth.py:7891-7896 the pair check is what rejects it — no separate status logic
Pool rows walked in stored order; no priority sort, no last_status read auth.py:5303-5320 same
Non-dict rows skipped auth.py:5305-5306 same
Pool slice must be a list; anything else is ignored auth.py:5303, and the pool reader :2277-2282 same — a non-list slice yields no login

Two places I deliberately did not follow the brief, and why

  1. Order is providers → pool, not pool → providers. The host has two entry
    points and they disagree: its status helper get_xai_oauth_auth_status
    (auth.py:7828-7845) is pool-first via load_pool(), but the resolver Talk
    actually calls is providers-first. Since this diagnostic exists to predict
    _resolve_via_host(), I followed the resolver. (On the live box both orders give
    the same answer — providers is empty.)

  2. A single-dict pool slice is not tolerated. The brief asked for it; the host
    refuses it in both readers (isinstance(entries, list)). Tolerating it would
    report valid for a shape the host will never read — a false positive, which is
    strictly worse than the bug being fixed here. Test:
    test_a_non_list_pool_slice_yields_no_login_like_the_host.

Contract preserved

  • Every state string is unchanged: missing / invalid / expired / valid.
  • Read-only, still: no network, no refresh, no write to any auth store. Nine of the
    new tests assert byte-and-mtime identity of the store via the existing _Snapshot.
  • JWT expiry + _EXPIRY_MARGIN_S logic untouched.
  • One deliberate behavioral repair: a non-dict providers block no longer
    short-circuits to invalid — it falls through to the pool
    (test_a_pool_login_survives_a_non_dict_providers_block).

Where the login was found is now in the receipt

New field xai_oauth_sourceproviders / credential_pool / null, allowlisted in
talk_diagnostics.py. talk doctor renders:

receipt: winner=xai-oauth, xai-oauth=valid (via credential_pool), preference=absent

So the next person debugging this can tell an empty store from an unread one without
opening auth.json.

Before / after on the live box

Same store, same process, tokens never read into the output — grok_auth_diagnostic()
returns no token-bearing keys, and the assertion below is on state strings only.

Before (origin/main's talk_grok_auth.py, loaded against the live store):

{
  "configured": false,
  "winning_lane": null,
  "preference": "absent",
  "xai_oauth": "missing",
  "host_refresh_available": false,
  "metered_key_present": false,
  "refresh_required": false,
  "blocked_by": "no-usable-auth"
}

After (this branch):

{
  "configured": true,
  "winning_lane": "xai-oauth",
  "preference": "absent",
  "xai_oauth": "valid",
  "xai_oauth_source": "credential_pool",
  "host_refresh_available": false,
  "metered_key_present": false,
  "refresh_required": false,
  "blocked_by": null
}

hermes talk doctor --probe was not re-run — it makes live calls and had already
passed.

Tests

13 new in tests/test_grok_auth.py (62 in that file, up from 49) + 1 new in
tests/test_doctor.py, and 1 existing doctor test updated for the new receipt suffix.

Test Pins
test_pool_only_login_is_valid_and_names_the_pool the regression — the live store shape
test_pool_only_login_resolves_without_touching_the_store resolve path + read-only
test_legacy_providers_login_still_wins_and_names_providers old hosts unbroken
test_providers_is_read_before_the_pool_like_the_host host order, both shapes present
test_unusable_providers_entry_falls_through_to_the_pool a refused providers block can't mask a good pool row
test_empty_pool_is_missing_not_invalid empty pool → fall through, don't refuse
test_quarantined_pool_row_is_invalid tokens popped → present-but-unusable
test_expired_pool_token_is_expired expiry still applies in the pool
test_malformed_rows_are_skipped_for_the_first_usable_one non-dict / half-token rows
test_pool_row_without_a_refresh_token_is_refused pair check on pool rows
test_a_non_list_pool_slice_yields_no_login_like_the_host host type check
test_a_pool_login_survives_a_non_dict_providers_block leg 1 can't block leg 2
test_the_pool_receipt_never_carries_the_token no secret in either receipt
test_human_report_names_the_credential_pool_as_the_source doctor's rendered line

12 of the 13 fail against origin/main — including the headline
assert 'missing' == 'valid'. (The 13th, the no-token-in-receipt check, is a safety
invariant that correctly holds on both.)

Mutation spot-check — 3 mutants, all killed:

Mutant Killed by
M1 — drop the refresh_token requirement test_pool_row_without_a_refresh_token_is_refused, test_malformed_rows_are_skipped_for_the_first_usable_one, test_file_fallback_blank_refresh_token_is_invalid
M2 — report present-but-unusable as missing test_quarantined_pool_row_is_invalid, test_pool_row_without_a_refresh_token_is_refused, test_file_fallback_blank_refresh_token_is_invalid
M3 — tolerate a non-list pool slice test_a_non_list_pool_slice_yields_no_login_like_the_host

Gates

  • uv run --extra dev pytest -q1651 passed, 40 skipped, 5 xfailed, 0 failed.
    Note: the expected baseline of 12 failures in test_capabilities.py / test_cli.py
    (test: host-summary and capabilities tests fail on any box where hermes-agent is importable #93) did not reproduce on this box — and this diff touches neither file.
  • uv run --extra dev ruff check .All checks passed!
  • git diff --stat == git diff --ignore-all-space --stat (byte-identical; no line-ending drift).

Sibling scan — checked by reading, not assumed

git grep '"providers"' over the repo returns exactly one production reader of the
Hermes auth store: talk_grok_auth.py. The others are not affected:

  • talk_auth.py reads $CODEX_HOME/auth.json (_codex_auth_path,
    talk_auth.py:116-121) — a different file with a different schema. Confirmed by
    reading the path builder, not by the module name.
  • talk_core_provider.py:497 only calls talk_auth.auth_diagnostic() (that same Codex
    lane); its auth.json mention is a docstring.
  • talk_vault.py:87 is getattr(manager, "providers", ()) — a memory-manager
    attribute, unrelated to any auth store.

One deviation from the brief worth flagging

The CHANGELOG entry went under the existing ## [Unreleased]### Fixed. There is no
## [0.17.0] — 2026-09-03 section on main (9f39e46) — the top section is
[Unreleased] and the repo is still at 0.16.0 in both pyproject.toml and
plugin.yaml. I did not invent a version heading, to avoid colliding with the release
tagging. Move it if 0.17.0 is cut from this branch.

🤖 Generated with Claude Code

`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) <noreply@anthropic.com>
@TheSmokeDev
TheSmokeDev merged commit 73a3670 into main Sep 3, 2026
11 checks passed
@TheSmokeDev
TheSmokeDev deleted the fix/grok-auth-credential-pool branch September 3, 2026 23:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant