fix(oauth): GHE Copilot OAuth lifecycle — connect, manual refresh, proactive refresh - #8970
Merged
diegosouzapw merged 6 commits intoAug 6, 2026
Conversation
hppsc1215
force-pushed
the
fix/ghe-copilot-oauth-gheurl-poll
branch
from
August 6, 2026 09:36
18ea44c to
ecfdae6
Compare
added 6 commits
August 6, 2026 12:27
…eachable The ghe-copilot branch in the poll handler (intended to thread gheUrl via extraData into pollForToken -> postExchange -> mapTokens) was unreachable dead code: ghe-copilot was listed in NO_PKCE_DEVICE_CODE_PROVIDERS, and the set-based check ran first, calling pollForToken without extraData. The caller (pollForToken) read normalizeGheUrl(extraData?.gheUrl || config.gheUrl) and threw 'gheUrl is required for GHE Copilot OAuth' because neither source was populated. Swap the order: check provider === "ghe-copilot" first, then fall through to the NO_PKCE set check for the remaining providers (github, kimi-coding, ...). The device-code handler (lines 214-221) already uses the correct pattern: it gates on the set but special-cases ghe-copilot inside the block.
…fresh The reactive 401 refresh (GheCopilotExecutor) already handled GHE, but the manual 'Refresh' button and the background health-check sweep run through a separate generic OAuth-refresh system that only ever special-cased plain 'github'. Every manual refresh click surfaced 'Token refresh failed — provider returned no new token'. Root cause: GHE Copilot's device-code flow never yields a refresh_token — only a GitHub access token plus a short-lived Copilot sub-token. The manual refresh route went through getAccessToken(), which returns null immediately when credentials.refreshToken is missing. Four sites wired: 1. open-sse/services/tokenRefresh/providers/copilot.ts — refreshCopilotToken() gains an optional baseUrl (default api.github.com) so it can target a GHE host's <gheUrl>/api/v3 Copilot token endpoint. 2. src/sse/services/tokenRefresh.ts — wrapper threads baseUrl through; new resolveCopilotTokenBaseUrl() derives it from providerSpecificData.gheUrl. checkAndRefreshToken() (runs before EVERY chat request) now covers ghe-copilot alongside github. 3. src/lib/tokenHealthCheck.ts — GITHUB_ACCESS_TOKEN_ONLY_PROVIDERS set replaces the bare provider === 'github' check; getCopilotTokenBaseUrl() picks the enterprise host for the health-check refresh. 4. src/app/api/providers/[id]/refresh/route.ts — dedicated branch for github/ghe-copilot connections without a refresh_token: refreshes the Copilot sub-token directly instead of going through getAccessToken(). Ported from the original fork branch onto v3.8.50; two of the four anchors had drifted (refreshCopilotToken was extracted into its own module, and tokenHealthCheck.ts now has a single consolidated call site instead of two).
…t did work The access-token-only branch of checkConnection() logged an unconditional 'has no refresh token but has a GitHub access token; keeping connection active' line on every sweep tick. That path runs once per TICK_MS (60s) for every github / ghe-copilot connection, so it emitted ~1440 identical entries per day per connection, all reporting that nothing had changed. The line predates this branch (v3.8.43, b729a8f) but ghe-copilot only started reaching it once the provider was added to GITHUB_ACCESS_TOKEN_ONLY_PROVIDERS earlier in this PR, which is what made the volume noticeable. Gate it on copilotAboutToExpire — the same flag that already drives the surrounding status/error writes — so the line appears only when the sweep actually attempted a Copilot sub-token refresh, and state whether that attempt succeeded or failed. A genuine refresh failure therefore stays visible instead of being buried in steady-state noise. Applies to github connections as well, not just ghe-copilot: it is the same shared code path and the same per-tick volume.
…-only gate tests/unit/oauth-providers-error-handling.test.ts asserted that src/lib/tokenHealthCheck.ts contains a literal `toLowerCase() === "github"`. Earlier in this PR that gate became a Set membership test, because GHE Copilot has the exact same shape as github.com Copilot (GitHub-style access token, no refresh_token, short-lived Copilot sub-token) and both must take the branch: GITHUB_ACCESS_TOKEN_ONLY_PROVIDERS.has(String(conn?.provider || "").toLowerCase()) The assertion is a structural grep over source text, so it failed on the new syntax even though the behaviour it guards is intact and now strictly broader. Widen it to accept either the literal comparison or a lowercase-normalized *_PROVIDERS Set lookup. The invariant under test — the diegosouzapw#6947 case-normalization guard, so a stored 'Github' still matches — is preserved: a Set lookup passing bare `conn.provider` without .toLowerCase() still fails the assertion, as does a literal comparison without normalization. Both negative cases were verified against fixtures, and the widened pattern was confirmed to still match upstream/release/v3.8.50's literal form, so this is not a one-way narrowing to our branch's syntax. The sibling diegosouzapw#6947 tests (ROTATING_REFRESH_PROVIDERS, and the sub-token guard in tokenHealthCheckCopilot.ts) are untouched and stayed green.
…ve its call tests/unit/codex-manual-refresh-rotating-guard.test.ts locates the generic access-token helper call with a plain substring search and asserts the openai-auth0 rotation guard precedes it. The explanatory comment added earlier in this PR named that helper with a trailing paren, so the first substring hit moved from the real call site to the comment — the ordering assertion still passed, but it was anchored on prose instead of code. Reword the comment to describe the helper without reproducing the searched token, and note the constraint inline so the next editor does not reintroduce it. No behaviour change.
hppsc1215
force-pushed
the
fix/ghe-copilot-oauth-gheurl-poll
branch
from
August 6, 2026 10:29
a298a41 to
ccf664d
Compare
diegosouzapw
merged commit Aug 6, 2026
2745144
into
diegosouzapw:release/v3.8.50
4 of 5 checks passed
This was referenced Aug 7, 2026
Merged
muhamadgalihsaputra
pushed a commit
to niyatna/NiyatnaRoute
that referenced
this pull request
Sep 27, 2026
…oactive refresh (diegosouzapw#8970) Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
GHE Copilot's OAuth lifecycle was broken end to end: an account could not be connected, and once connected its token could not be refreshed. Three related fixes, all in provider-specific branches that
ghe-copilotwas left out of when the provider was introduced.1. Connecting an account failed with
gheUrl is required for GHE Copilot OAuth.The poll handler's
ghe-copilotbranch was unreachable dead code. The provider is listed inNO_PKCE_DEVICE_CODE_PROVIDERS, and that set-based check runs first — sopollForToken()was called without theextraDatacarryinggheUrl, andnormalizeGheUrl(extraData?.gheUrl || config.gheUrl)threw because neither source was populated. Fixed by checkingprovider === "ghe-copilot"before falling through to the set check. The device-code handler in the same file already used the correct pattern (gate on the set, special-caseghe-copilotinside the block).2. Every manual
Refreshclick returnedToken refresh failed — provider returned no new token, and the proactive refresh never fired.GHE Copilot's device-code flow never yields a
refresh_token— only a GitHub access token plus a short-lived (~30 min) Copilot sub-token. The manual refresh route went throughgetAccessToken(), which returnsnullimmediately whencredentials.refreshTokenis missing. The health-check sweep andcheckAndRefreshToken()(which runs before every chat request) had the same bareprovider === "github"gate.refreshCopilotToken()now takes an optionalbaseUrlso it can target a GHE host's<gheUrl>/api/v3Copilot token endpoint instead ofapi.github.com, which never issues a token scoped to a GHE account.ghe-copilotis wired in alongsidegithubat all four sites, withresolveCopilotTokenBaseUrl()/getCopilotTokenBaseUrl()deriving the host fromproviderSpecificData.gheUrl.3. The health-check sweep logged one noise line per tick.
The access-token-only branch logged
has no refresh token but has a GitHub access token; keeping connection activeunconditionally. That path runs once perTICK_MS(60 s) for everygithub/ghe-copilotconnection — ~1440 identical entries per day per connection, all reporting that nothing changed. The line predates this branch (v3.8.43,b729a8f27), butghe-copilotonly started reaching it once the provider was added toGITHUB_ACCESS_TOKEN_ONLY_PROVIDERSin fix 2, which is what made the volume visible. It is now gated oncopilotAboutToExpire— the same flag that already drives the surrounding status/error writes — and states whether the refresh succeeded or failed, so a genuine failure stays visible instead of being buried.Related Issues
Validation
Verified live against a real GHE Copilot connection on a Docker build of this branch, not only by reading the code:
gheUrl,copilotApiUrlandcopilotTokenall populated. NogheUrl is requirederror.Refreshbutton completes without theprovider returned no new tokenerror.copilotTokenExpiresAtadvanced ~10 h 05 m over a 10-hour window (~20 refresh cycles) whiletest_statusstayedactive. The sub-token lives ~30 min, so without a working proactive path the connection would have gone dead after the first expiry.Copilot token refreshed (no refresh token; connection stays active)line appeared at the moment the 5-minute expiry buffer was crossed and the sweep actually refreshed the token.node --experimental-strip-types --test tests/unit/oauth-providers-error-handling.test.ts→ 18 tests, 17 pass, 1 fail. The one failure isP0: gitlab-duo is registered in providerRegistry, which throwsERR_MODULE_NOT_FOUND: Cannot find package '@/lib'— the@/path alias needs thetsxloader, which this checkout has nonode_modulesfor. That test is green in this PR's CI run, and two sibling health-check test files fail locally with the same alias error on a pristineupstream/release/v3.8.50worktree, so it is a local-runner artifact rather than a regression.P1: tokenHealthCheck checks copilotTokenExpiresAt before refreshingfails; with the widened assertion it passes. The widened pattern was also confirmed to still match upstream's literal form, and to still reject both a Set lookup and a literal comparison that omit.toLowerCase()— so the fix(tokenHealthCheck): normalize provider case in rotating/copilot checks #6947 case-normalization guard is not silently retired.tests/unit/codex-manual-refresh-rotating-guard.test.ts→ 3/3 pass (this PR touches the file it asserts over).node scripts/check/check-file-size.mjs— green (121 frozen files, cap 1000); no rebaseline needed.node scripts/check/check-changelog-integrity.mjs— green.npm run lint,check:complexity,check:cognitive-complexity, vitest and e2e suites — not run locally: they neednode_modules, which this checkout does not have. Left to CI.CI status on this branch
check:agent-skills-syncreportsomni-auth,omni-usage-logsandomni-inferenceas stale, and the generator reads onlysrc/lib/agentSkills/— none of the files in this PR. chore(skills): regenerate SKILL.md for routes shipped in v3.8.49 #8954 appears to be regenerating those already.The runner has received a shutdown signal, i.e. an infrastructure cancellation.Tests Added Or Updated
tests/unit/oauth-providers-error-handling.test.ts— widened one existing assertion. It required the literaltoLowerCase() === "github"insrc/lib/tokenHealthCheck.ts; that gate is now a lowercase-normalized*_PROVIDERSSet lookup sogithubandghe-copilotboth take the branch. The assertion accepts either form, and the surrounding invariant is unchanged (see the red-green evidence above). The sibling #6947 tests overROTATING_REFRESH_PROVIDERSandtokenHealthCheckCopilot.tsare untouched and stayed green.No new test files. The remaining changes are provider-gating and call-site wiring whose behaviour depends on a live GHE enterprise host (per-enterprise Copilot token endpoint + device-code flow), so they were validated end to end against a real connection as listed above. Happy to add a unit test around
resolveCopilotTokenBaseUrl()(pure string derivation, trivially testable) or a fetch-mocked case for the manual-refresh branch if a maintainer would prefer that here.Coverage Notes
Touches
src/andopen-sse/:src/app/api/oauth/[provider]/[action]/route.tsopen-sse/services/tokenRefresh/providers/copilot.tsbaseUrlparametersrc/sse/services/tokenRefresh.tsbaseUrl; newresolveCopilotTokenBaseUrl();ghe-copilotincheckAndRefreshToken()src/lib/tokenHealthCheck.tsGITHUB_ACCESS_TOKEN_ONLY_PROVIDERSset,getCopilotTokenBaseUrl(), log gatingNo coverage regression: no existing branch was removed or narrowed.
githubkeeps its exact previous behaviour —resolveCopilotTokenBaseUrl()returnsundefinedfor any provider other thanghe-copilot, and the wrapper then calls the 3-argument form, so theapi.github.comdefault path is byte-identical to before.Reviewer Notes
refreshCopilotTokenwas extracted out ofopen-sse/services/tokenRefresh.tsintotokenRefresh/providers/copilot.ts, andsrc/lib/tokenHealthCheck.tsnow has a single consolidatedrefreshCopilotToken()call site instead of two (the secondprovider === "github"check no longer exists). Worth knowing if this is cross-referenced against the older fork history.resolveCopilotTokenBaseUrl()is additive and inert for every provider exceptghe-copilot.NO_PKCE_DEVICE_CODE_PROVIDERSstill containsghe-copilotand should — GHE genuinely does not use PKCE. The set was never wrong; only the evaluation order was.has no refresh token but has a GitHub access tokenno longer appears.src/app/api/providers/[id]/refresh/route.tsdeliberately avoids writing the generic access-token helper's name followed by(.tests/unit/codex-manual-refresh-rotating-guard.test.tslocates that call with a plain substring search and asserts theopenai-auth0rotation guard precedes it, so a textual mention above the call site silently becomes the match instead. An earlier revision of this PR did exactly that; the assertion still passed but was anchored on prose. The constraint is noted inline at the comment.