Skip to content

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

Closed
dstkwll wants to merge 1 commit into
NousResearch:mainfrom
dstkwll:fix/copilot-integrator-400-hardening
Closed

fix(copilot): recover from stale/degraded token 400 AND expired IDE-token 401#58743
dstkwll wants to merge 1 commit into
NousResearch:mainfrom
dstkwll:fix/copilot-integrator-400-hardening

Conversation

@dstkwll

@dstkwll dstkwll commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Summary

GitHub Copilot degrades in two related ways that both abort a turn as a non-retryable error and only clear on a gateway restart (a cold process re-runs the token exchange). This PR prevents the degraded state and self-heals from both at runtime. It has been rebased onto current main and its scope expanded from the original 400-only fix to also cover the clean-401 case.

Bug 1 — HTTP 400 model_not_available_for_integrator / model_not_supported

A raw/degraded token routes the request to the restricted copilot-language-server integrator, whose allowlist omits Enterprise-only models (most visibly claude-opus-4.8). Because it is a 400, not a 401, the existing Copilot 401 credential-refresh path never fired and the turn aborted.

Deterministic truth table (verified live against the API) — varying only the token on the wire and whether Copilot-Integration-Id: vscode-chat is present:

Token on wire vscode-chat header Result
exchanged (~437-char API token) present OK
raw ghu_ (40-char OAuth) present OK
raw ghu_ (40-char OAuth) absent copilot-language-server 400 ← the bug
exchanged (~437-char) absent 400 missing Editor-Version (different error)

The fallback integrator is only reached when a raw token and a missing integration header coincide. Two mechanisms produce that state: (1) the token exchange degrades silently to the raw token on any exception and — because the exchanged-JWT cache was in-process only — sticks for the whole process lifetime; (2) two client-rebuild paths (primary_recovery, restore_primary) reconstruct the client from the _primary_runtime snapshot without re-applying Copilot headers.

Bug 2 — HTTP 401 IDE token expired: unauthorized: token expired

The short-TTL exchanged IDE token (minted from the stable raw ghu_ token) expires mid-turn. A heavy/long turn whose request straddles that expiry gets a clean 401. The clean-401 path did fire and call _try_refresh_copilot_client_credentials() — but that method only re-resolved the stable raw token and rebuilt the client; it never evicted the cached exchanged JWT or forced a fresh exchange. So the retry put the same expired IDE token back on the wire, 401'd again, and the single-shot guard aborted the turn as non-retryable. Only a gateway restart helped, because a cold process re-runs the exchange.

This is the same failure class the merged auxiliary-path fix (#59837, closing #20832/#20837/#23379) already solved for compression/title-generation — but the main conversation loop was left with the weaker refresh. Note #63204 was closed implemented_on_main on the assumption that "conversation_loop.py routes Copilot 401 recovery through _try_refresh_copilot_client_credentials()" — it reaches the method, but the method was too weak to actually refresh the expired exchanged JWT. This PR makes the method do what that review already assumed it did.

The fix (layered — prevent + recover)

hermes_cli/copilot_auth.py

  • exchange_copilot_token(): retry-with-backoff (3 attempts) instead of failing on the first blip.
  • Persist the last-good exchanged JWT to disk (~/.hermes/.copilot_jwt.json, 0o600, profile-aware, expired entries pruned, read bounded to 1 MiB). A fresh process reuses the still-valid ~30-min token before any network call.
  • evict_cached_exchanged_token(): drop both cache tiers so a recovery can force a fresh mint.

agent/credential_pool.py — WARNING when the copilot seed degrades to the raw token, so a recurrence is visible instead of silent.

agent/agent_runtime_helpers.py — defense-in-depth header guard at create_openai_client() (the documented single chokepoint every primary client passes through). For githubcopilot.com hosts it fills any missing Copilot headers (never overrides caller-set ones; operates on the local per-call dict copy, so it never mutates _client_kwargs and can't break prompt caching).

agent/conversation_loop.py + agent/turn_retry_state.py + run_agent.py

  • 400 recovery: classify the integrator / model_not_supported 400 as a refreshable stale-credential error (narrow match: 400 and a specific body marker), then — copilot-scoped and single-shot — force a fresh exchange, rebuild the client, and retry once on the same provider before falling through to the fallback chain. Aborts cleanly if the exchange stays degraded, so a genuinely unavailable model can't loop.
  • 401 recovery (new): _try_refresh_copilot_client_credentials() now evicts the cached exchanged JWT and forces a fresh exchange (via evict_cached_exchanged_token + get_copilot_api_token) before rebuilding the client, so the retry carries a valid IDE token. Falls back to the resolved token if the exchange endpoint is unreachable; picks up the Enterprise base_url on re-exchange. The clean-401 path is already single-shot-guarded (copilot_auth_retry_attempted).

Prior art appropriated (credit)

Testing

  • Targeted assertions: exchange retry/persist round-trip, restart-blip disk reuse (zero network), bounded oversized-file read, the stale-credential classifier (integrator-400 & model_not_supported → true; wrong-model 400, 401, 500 → false), 400 recovery (aborts when still degraded; rebuilds + re-exchanges on success), and 3 new 401 cases (fresh exchanged token — not the raw token — goes on the wire; network-blip fallback to the resolved token; token-unchanged still rebuilds).
  • 58 copilot tests green on current main; existing suites unaffected (test_copilot_token_exchange, test_copilot_auth, test_credential_pool, test_create_openai_client_reuse, test_turn_retry_state).

No new HERMES_* config env vars; no change-detector tests; prompt caching, role alternation, and the caller's _client_kwargs are all preserved.

Related

@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard provider/copilot GitHub Copilot (ACP + Chat) area/auth Authentication, OAuth, credential pools P2 Medium — degraded but workaround exists labels Jul 5, 2026
@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 15, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tracing the 400 path. The premise is present on current main: agent/conversation_loop.py:2851-2859 only refreshes Copilot on 401, while hermes_cli/copilot_auth.py:415-434 falls back to the raw token when exchange fails.

Problems

  • hermes_cli/copilot_auth.py:338 and :399 parse the persisted JWT cache with unbounded read_text(). The proposed 1 MiB check exists only in _load_jwt_from_disk() at :371-374, so recovery eviction and a later save can still load an oversized cache.
  • The PR changes no test file (gh api repos/NousResearch/hermes-agent/pulls/58743/files?per_page=100), leaving the new persistence and recovery paths without committed regression coverage.

Suggested changes

  • Use one bounded cache-read helper for load, eviction, and save; discard oversized/malformed content before parsing or rewriting.
  • Add focused network-free tests for retry/disk reuse, oversized-cache handling, 400 classification, and both recovery outcomes.

Automated hermes-sweeper review.

if not path or not path.exists():
return
try:
store = json.loads(path.read_text())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 1 MiB guard is only in _load_jwt_from_disk(). Recovery reaches this unbounded read_text() path, so an oversized cache can still be fully loaded; share a bounded-read helper with eviction and _save_jwt_to_disk().

…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 (NousResearch#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
(NousResearch#59837), using the newer on-disk-aware evict helper. Companion context: NousResearch#58743
(this PR, expanded), NousResearch#51313, NousResearch#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.
@dstkwll
dstkwll force-pushed the fix/copilot-integrator-400-hardening branch from 11b7c45 to 8d3c0d4 Compare July 24, 2026 02:03
@dstkwll dstkwll changed the title fix(copilot): recover from model_not_available_for_integrator 400 (stale/degraded token) fix(copilot): recover from stale/degraded token 400 AND expired IDE-token 401 Jul 24, 2026
@dstkwll

dstkwll commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (was ~89 commits behind; now MERGEABLE) and expanded scope to also fix the clean HTTP 401 IDE token expired case in the main conversation loop.

The 401 path already reached _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 IDE token back on the wire and the turn aborted as non-retryable. It now evicts + re-exchanges before rebuild, bringing the main loop to parity with the merged auxiliary-path fix (#59837). 3 new 401 tests added; 58 copilot tests green on current main.

teknium1 pushed a commit that referenced this pull request Aug 1, 2026
…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.
teknium1 added a commit that referenced this pull request Aug 1, 2026
- 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.
@teknium1

teknium1 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Merged via PR #75864 (#75864) — your commit was cherry-picked onto current main with your authorship preserved in git log (7779409). Thanks for the deterministic truth-table investigation and the layered prevent+recover design; we added small follow-ups on top (one bounded reader for all JWT-store reads, alias-spelling coverage for the provider gates, UTF-8 encoding per a new repo-wide lint rule). This fixes the widely-reported "most Copilot models return HTTP 400 while gpt-4.1/gpt-4o work" failure. Great work!

randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…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 (NousResearch#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
(NousResearch#59837), using the newer on-disk-aware evict helper. Companion context: NousResearch#58743
(this PR, expanded), NousResearch#51313, NousResearch#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.
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
- 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists provider/copilot GitHub Copilot (ACP + Chat) sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants