Skip to content

fix(copilot): recover from stale/degraded token 400 AND expired IDE-token 401 (salvage of #58743) - #75864

Merged
teknium1 merged 3 commits into
mainfrom
hermes/hermes-340f0122
Aug 1, 2026
Merged

fix(copilot): recover from stale/degraded token 400 AND expired IDE-token 401 (salvage of #58743)#75864
teknium1 merged 3 commits into
mainfrom
hermes/hermes-340f0122

Conversation

@teknium1

@teknium1 teknium1 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

fix(copilot): recover from stale/degraded token 400 AND expired IDE-token 401 (salvage of #58743)

Salvage of #58743 by @dstkwll (authorship preserved via cherry-pick), rebased onto current main with follow-up hardening.

Summary

GitHub Copilot degrades in two related ways that both abort a turn as a non-retryable error and only clear on a full restart. This PR prevents the degraded state and self-heals from both at runtime — fixing the widely-reported "most advertised models return HTTP 400 while gpt-4.1/gpt-4o work" failure.

Bug 1 — HTTP 400 model_not_available_for_integrator / model_not_supported. When the raw→API token exchange fails (startup racing network readiness is the common trigger), get_copilot_api_token() silently falls back to the raw ghu_ OAuth token and that degraded state is cached for the process lifetime. A raw token routes requests to the restricted copilot-language-server integrator whose model allowlist omits most models. Because it's a 400 — not a 401 — no refresh path ever fired.

Bug 2 — HTTP 401 IDE token expired. The short-TTL exchanged token expires mid-turn; the 401 refresh path re-resolved the (stable) raw token and rebuilt the client but never evicted the cached exchanged JWT, so the retry replayed the same expired token.

Changes

Contributor commit (@dstkwll):

  • hermes_cli/copilot_auth.py: exchange retry-with-backoff (3 attempts); persist last-good exchanged JWT to disk (.copilot_jwt.json, 0600, profile-aware, expired entries pruned); evict_cached_exchanged_token() drops both cache tiers.
  • agent/conversation_loop.py + agent/turn_retry_state.py + run_agent.py: narrow 400 classifier (status 400 AND integrator/model_not_supported marker) → single-shot copilot-scoped force-re-exchange + client rebuild + retry; 401 recovery now evicts + re-exchanges before rebuild.
  • agent/agent_runtime_helpers.py: defense-in-depth Copilot header guard at the create_openai_client() chokepoint (fills missing headers only, never overrides).
  • agent/credential_pool.py: WARNING when the seed degrades to the raw token; skip the generic env-seeding loop for copilot (its dedicated branch is authoritative).

Follow-ups (ours):

  • All on-disk JWT store reads now go through one bounded _read_jwt_store() helper (load, eviction, save-merge) — the 1 MiB cap previously covered only the load path (sweeper finding).
  • Fix the class: recovery gates checked the literal provider == "copilot" while /model and profile configs can leave github-copilot/github alias spellings in place (the reporter's own log shows both spellings in one session). Single owner AIAgent._is_copilot_provider() (aliases + Copilot base-URL fallback) used by all four gate/guard sites.
  • Regression tests: bounded-store behavior at all three sites, alias-gate coverage, TurnRetryState contract updated; salvaged 401 test updated to current main's client-retirement contract.

Validation

Check Result
Targeted suites (codex_responses, copilot_token_exchange, copilot_auth, turn_retry_state) 62/62 green
E2E (real imports, temp HERMES_HOME) disk round-trip + 0600 perms, no-network disk reuse, two-tier eviction, oversized-store bound at all 3 sites, 400-classifier positive/negative
Stale-base gate 0 commits behind; diff = only these 10 files

Fixes the failure reported in Discord (GitHub Copilot OAuth: most advertised models 400). Closes #58743.

Infographic

Provider credential self-heal infographic

dstkwll and others added 3 commits July 31, 2026 22:00
…oken 401

Copilot degrades in two related ways that both abort a turn as non-retryable
and only clear on a gateway restart (a cold process re-runs the token exchange):

1. HTTP 400 model_not_available_for_integrator / model_not_supported — a
   raw/degraded token routes to the restricted copilot-language-server
   integrator whose allowlist omits enterprise-only models (e.g.
   claude-opus-4.8). Because it is a 400 (not 401), the existing 401 refresh
   path never fired. Prevented (retry-with-backoff exchange + on-disk JWT
   persistence + header guard at the client chokepoint) and self-healed at
   runtime (single-shot forced re-exchange + client rebuild + retry before
   fallback).

2. HTTP 401 'IDE token expired: unauthorized: token expired' — the short-TTL
   *exchanged* IDE token expires mid-turn. The clean-401 path DID fire and call
   _try_refresh_copilot_client_credentials(), but that method only re-resolved
   the stable raw ghu_ token and rebuilt the client — it never evicted the
   cached exchanged JWT or forced a fresh exchange, so the retry put the SAME
   expired token back on the wire, 401'd again, and the single-shot guard
   aborted the turn. Fix: force a fresh IDE-token exchange (evict cached JWT via
   evict_cached_exchanged_token + re-mint via get_copilot_api_token) before the
   client rebuild, mirroring the merged auxiliary-path recovery (#59837) and the
   400 recovery in this same PR. Graceful fallback to the resolved token if the
   exchange endpoint is unreachable; picks up the enterprise base_url on
   re-exchange.

Brings main-loop clean-401 recovery to parity with the merged auxiliary path
(#59837), using the newer on-disk-aware evict helper. Companion context: #58743
(this PR, expanded), #51313, #63204 (which assumed the 401 path already
recovered — it reached the method but the method was too weak).

Tests: exchange retry/persist round-trip, restart-blip disk reuse, stale-cred
400 classifier, 400 recovery, and 3 new 401 cases (fresh exchanged token on the
wire; network-blip fallback to resolved token). 58 copilot tests green on
current main.
- Bound ALL reads of the on-disk JWT store through one _read_jwt_store()
  helper (load, eviction, save-merge) — the 1 MiB cap previously only
  covered the load path; eviction and save could still parse an
  oversized/corrupt store and rewrite it back out (sweeper finding).
- Fix the class, not the site: the recovery gates checked the literal
  provider == "copilot" while /model and profile configs can leave the
  alias spelling in place (the reporter's own log shows provider=copilot
  AND provider=github-copilot in one session — the aliased turns would
  have silently skipped recovery). Single owner:
  AIAgent._is_copilot_provider() (slug aliases + Copilot base-URL
  fallback), used by both run_agent recovery methods and both
  conversation_loop gates.
- Update the salvaged 401 test to current main's client-retirement
  contract (release deferred to GC — no synchronous .close()).
- Add copilot_stale_cred_retry_attempted to the TurnRetryState field
  contract test; add bounded-store and alias-gate regression tests.
Matches the repo-wide convention (c89481d); unblocks ruff enforcement
and the Windows footgun checker on the salvaged JWT persistence code.
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on bdf4027

ℹ️ Info

Desktop E2E visual evidence · View test artifacts · View job

3 visual diffs.

inline evidence upload failed.

Failed to upload diff-1508682a2ae8-boot-ready-diff.png with gh image (exit code 1): Error uploading /home/runner/work/_temp/e2e-evidence/diff-1508682a2ae8-boot-ready-diff.png: step 0 (get upload token): uploadToken not found on repo page — do you have write access to NousResearch/hermes-agent? (or, if NousResearch enforces SAML SSO, authorize at https://github.com/orgs/NousResearch/sso)

@teknium1
teknium1 merged commit 2f2d903 into main Aug 1, 2026
38 checks passed
@teknium1
teknium1 deleted the hermes/hermes-340f0122 branch August 1, 2026 05:31
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.

2 participants