Skip to content

fix(sse): fail closed on background token-refresh for dead proxy pools (#13470) - #13793

Merged
diegosouzapw merged 2 commits into
release/v3.8.51from
fix/13470-background-oauth-refresh-bypasses-dead
Sep 16, 2026
Merged

diegosouzapw merged 2 commits into
release/v3.8.51from
fix/13470-background-oauth-refresh-bypasses-dead

Conversation

@diegosouzapw

Copy link
Copy Markdown
Owner

Closes #13470

Root cause (short)

The interactive chat/executor path fails closed via safeResolveProxy +
hasBlockingProxyAssignment (src/sse/handlers/chatHelpers.ts) when a connection's
assigned proxy pool has gone fully dead (the #6246 guard). The background OAuth
token-refresh path never consulted that guard:

  • resolveProxyForCredentials (src/sse/services/tokenRefresh.ts), the shared helper
    behind all 7 exported refresh functions (refreshAccessToken,
    refreshClaudeOAuthToken, refreshGoogleToken, refreshCodexToken,
    refreshQoderToken, refreshGitHubToken, refreshCopilotToken), called
    resolveProxyForConnection directly and fell through to
    resolveProxyForProvider on any non-alive result — including a dead-pool result —
    without ever checking hasBlockingProxyAssignment.
  • src/lib/tokenHealthCheck.ts's two health-check sweep call sites
    (resolveProxyForConnection + extractResolvedProxyConfig) had the same gap.

Net effect: a connection whose assigned proxy pool is entirely dead resolves to
{ proxy: null, level: "direct" } at the DB layer, and only the guarded chat path
treated that as an error. The background refresh paths silently proceeded, sending
the refresh-token exchange out on resolveEnvProxyUrl's fallback or a truly direct
connection — the same class of IP-provenance leak #6246 closed, on a more sensitive
payload (the refresh token itself).

Fix

  • resolveProxyForCredentials now calls hasBlockingProxyAssignment when the DB
    resolution has no live proxy, and throws a PROXY_ASSIGNED_UNAVAILABLE-coded error
    instead of falling through to resolveProxyForProvider — mirroring
    safeResolveProxy's #6246 contract (including the PROXY_FAIL_OPEN escape
    hatch). decideProxyResolutionFailure couldn't be imported from
    chatHelpers.ts without creating an import cycle (chatHelpers.ts already imports
    updateProviderCredentials from this module), so the same policy is duplicated
    verbatim as decideTokenRefreshProxyFailure.
  • The two tokenHealthCheck.ts call sites are replaced with a new shared
    resolveGuardedProxyConfig helper (extracted to a new leaf module,
    src/lib/tokenHealthCheckProxyGuard.ts, to stay under the frozen file-size cap on
    tokenHealthCheck.ts). On a blocked resolution it skips the connection's
    refresh cycle for the current tick (log + return) instead of throwing — this is a
    scheduler sweep over many connections, so one blocked connection must not abort the
    rest.

Not covered here

  • The reporter's DIRECT_PROXY_CONTEXT sentinel suggestion for the legitimate
    direct case (no assignment at all) is not included — the guard added here only
    changes behavior for the dead-pool case the issue is about; a connection with no
    proxy assignment continues to resolve exactly as before.

Regression test (path + RED output excerpt on unfixed code + GREEN excerpt)

tests/unit/issue-13470-token-refresh-proxy-bypass.test.ts — the plan-file's original
repro asserted the buggy asymmetry as a passing test (proving the leak existed); it
is inverted here to assert the fixed contract instead, run against
resolveProxyForCredentials and the new resolveGuardedProxyConfig directly (rather
than through refreshAccessToken's network path, since that helper never throws on
an HTTP-level failure — testing at the network boundary would have hit a real OAuth
endpoint in the test and produced a false negative).

RED (on unfixed origin/release/v3.8.51):

✖ #13470: background token-refresh fails closed for a dead assigned proxy pool, matching the guarded chat path
  AssertionError [ERR_ASSERTION]: Missing expected rejection.
✖ #13470: token-health-check sweep skips a connection whose assigned proxy pool is dead instead of refreshing through direct/env-proxy egress
  TypeError: tokenHealthCheckProxyGuard.resolveGuardedProxyConfig is not a function
ℹ tests 4
ℹ pass 0
ℹ fail 4

GREEN (after the fix):

✔ #13470: background token-refresh fails closed for a dead assigned proxy pool, matching the guarded chat path
✔ #13470: token-refresh proxy resolution stays direct for a connection with no proxy assignment at all (legitimate direct, not a regression)
✔ #13470: token-health-check sweep skips a connection whose assigned proxy pool is dead instead of refreshing through direct/env-proxy egress
✔ #13470: token-health-check sweep is unaffected for a connection with no proxy assignment at all
ℹ tests 4
ℹ pass 4
ℹ fail 0

Gates run

  • npx eslint --suppressions-location config/quality/eslint-suppressions.json <changed files> — clean, 0 new warnings.
  • npm run typecheck:core — clean (0 errors).
  • node scripts/check/check-file-size.mjs — clean on touched files (the new
    resolveGuardedProxyConfig guard was extracted to its own leaf module,
    src/lib/tokenHealthCheckProxyGuard.ts, so tokenHealthCheck.ts stays under its
    frozen 1218-line cap: 1214 lines after the fix). open-sse/utils/stream.ts is
    flagged by this gate but is untouched by this PR — pre-existing base drift
    (3115 lines vs frozen 3098 on origin/release/v3.8.51, confirmed with
    git diff --stat showing no changes to that file).
  • node scripts/check/check-complexity.mjs — OK, 2824 violations (baseline 3218).
  • node scripts/check/check-cognitive-complexity.mjs — OK, 1276 violations (baseline 1437).
  • node scripts/check/check-test-discovery.mjs — OK, new test file discovered, no new orphans.

Existing tests aligned

None needed alignment — tests/unit/proxy-assigned-unavailable-6246.test.ts (the pure
hasBlockingProxyAssignment predicate test) and a focused batch of 11 existing
tokenHealthCheck/token-refresh test files (60 tests total) all pass unchanged
against the fixed code.

Credit

Thanks to @elielsousa-pathbit for the precise write-up in #13470 — the root-cause
citations (line numbers in tokenRefresh.ts, tokenHealthCheck.ts,
proxies/rotation.ts, proxies/guards.ts) all checked out exactly as reported.

diegosouzapw and others added 2 commits September 15, 2026 17:03
#13470)

resolveProxyForCredentials (src/sse/services/tokenRefresh.ts, shared by all
7 exported refresh functions) and tokenHealthCheck.ts's two direct
resolveProxyForConnection call sites resolved a connection with a fully
dead assigned proxy pool to bare direct egress, unlike the interactive
chat/executor path (safeResolveProxy) which already fails closed per the
#6246 guard. This routes both background paths through
hasBlockingProxyAssignment, mirroring the #6246 policy (PROXY_FAIL_OPEN
escape hatch included) so refresh-token traffic never leaks onto a
connection's real IP when its proxy pool goes dark.

Regression test: tests/unit/issue-13470-token-refresh-proxy-bypass.test.ts
@diegosouzapw
diegosouzapw merged commit 5d1f468 into release/v3.8.51 Sep 16, 2026
19 of 21 checks passed
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
diegosouzapw#13470) (diegosouzapw#13793)

Merged in the 2026-09-16 sweep of the maintainer's own open PRs, at the owner's explicit instruction. No push was made to the PR branch: the merge took the head as the owning session left it (verified OPEN, non-draft and MERGEABLE against the release tip immediately before merging).
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.

fix(resilience): Background OAuth refresh leaves without the assigned proxy when the pool is dead, without consulting the flag that governs it

1 participant