Files
OmniRoute/src/lib/tokenHealthCheck.ts
Will Gordon 57744aeb14 feat(cursor): proactively renews Cursor sessions and fixes manual refresh (#9173)
* refactor(cursor): extracts token extraction into shared lib

Moves tryIdeAuth/tryAgentAuth and supporting helpers out of the
auto-import route into src/lib/cursor/tokenExtractor.ts, and adds
an agent-cli-state.json fallback candidate path to tryAgentAuth
(alongside the existing auth.json candidate) so the extraction
logic can be reused by the upcoming renewal orchestrator.

* feat(cursor): adds cursor-agent-backed token renewal orchestrator

Builds the renewal orchestrator in src/lib/cursor/renewal.ts: a
bounded, unattended-safe --list-models nudge, a side-effect-free
status availability check, an in-flight spawn lock keyed by
command, and renewCursorConnection() which nudges cursor-agent
then independently re-scrapes the IDE and cursor-agent credential
sources to detect whichever refreshed. Extends cursorAgent.ts's
binary resolution and spawn helper with fixed-paths-only mode and
a SIGKILL follow-up for background use. Adds a generic keyed-mutex
utility (src/shared/utils/keyedMutex.ts) for serializing a
connection's renew-then-persist cycle, and forwards a busy-timeout
through driverFactory's node:sqlite fallback path.

* feat(cursor): proactively renews Cursor sessions in the sweep

Adds src/lib/tokenHealthCheckCursor.ts, sweep-side glue that calls
the renewal orchestrator and persists the result, wired into
tokenHealthCheck.ts's checkConnection() via a new Cursor-specific
branch placed ahead of the generic no-refresh-token fallthrough.
Carves out a non-terminal exception for a Cursor connection that
already landed at testStatus "expired" via the request-time 401
path, excluding permanently-dead account_deactivated connections.
Extends buildRefreshFailureUpdate() with an overrides param so
Cursor's failure path can use a distinct, non-terminal errorCode
instead of the generic refresh_failed/expired taxonomy.

* feat(cursor): adds local-only manual refresh route

Adds POST /api/providers/[id]/refresh-cursor, a dedicated
loopback-only route that calls the renewal orchestrator on demand
for a single Cursor connection, bounded by a 30s per-connection
cooldown. Classifies the new route in LOCAL_ONLY_API_PATTERNS and
closes the manage-scope-bypass gap for dynamic-segment spawn-capable
routes under /api/providers/ via a new SPAWN_CAPABLE_PATTERNS /
SPAWN_CAPABLE_PATTERN_ANCESTORS mechanism, which also retroactively
covers the pre-existing /login route. The existing shared
/api/providers/[id]/refresh route is untouched and stays
remote-reachable for every other provider.

* feat(cursor): surfaces a dismissible cursor-agent nudge

Adds GET /api/providers/cursor/agent-availability, a credential-free
LOCAL_ONLY route returning only { cursorAgentAvailable: boolean },
backed by a 5-minute cached wrapper around the renewal orchestrator's
existing availability check. Surfaces a dismissible dashboard banner
on the Cursor provider page suggesting cursor-agent installation
when it isn't detected, following the existing dismissible-banner
convention. Also fixes a pre-existing bracket character in a
routeGuard.ts comment that was silently truncating
check-openapi-security-tiers.mjs's view of LOCAL_ONLY_API_PREFIXES.

* fix(cursor): wires manual refresh button to the new route

Branches handleRefreshToken to call the dedicated Cursor refresh
route instead of the generic /refresh route, which silently 502s
for Cursor connections today since they carry no refresh token.
Every other provider's refresh behavior is unaffected. Adds the
cursorSessionUnchanged i18n key and syncs it (plus a pre-existing,
unrelated 28-key backlog) across all 42 locale files.

* fix(cursor): addresses Phase 4/4.5 review findings

Restores the legacy stdout/stderr auth-pattern fallback in
checkCursorAgentAvailability() that the plan's Task 2 Step 4
required but the implementation had dropped. Threads an optional
deps parameter through checkCursorConnectionIfNeeded() so its
error branch is reachable in tests, and switches both it and the
manual-refresh route to exhaustive switch statements over the
renewal result. Adds a short-lived host-keyed dedup cache around
tryIdeAuth() so multiple due Cursor connections sharing a host
don't each open the same state.vscdb file in one sweep tick.
Adds opportunistic eviction to the manual-refresh cooldown map,
an outer try/catch to the availability route for defense-in-depth
consistency with the plan's other routes, and corrects a stale
JSDoc claim about the /login route's auth check. Documents the
now-empirically-confirmed agent-cli-state.json schema mismatch
found while validating against a real cursor-agent install.

* docs(cursor): adds changelog fragments for the renewal plan

Adds one fragment per user-facing outcome per changelog.d/README.md's
convention for a PR that both fixes and adds. PR number placeholder
to be filled in once the PR is opened.

* fix(i18n): translates the new Cursor keys into Vietnamese

The i18n:sync-ui run in an earlier commit left __MISSING__
sentinels for the 4 new Cursor keys in every locale, but
Vietnamese has a dedicated completeness test requiring zero
internal missing markers. Provides real translations for
cursorSessionUnchanged, cursorAgentNudgeTitle,
cursorAgentNudgeBody, and cursorAgentNudgeDismiss.

* fix(cursor): addresses quality-gate Layer 1.5 findings

Restores a comment that misrepresented execFile's actual argv shape
after an earlier bracket-removal fix, this time avoiding literal
closing-bracket characters entirely so the openapi checker's naive
array parser can't be broken by either version. Bounds the sweep-
and manual-route-triggered tryIdeAuth() busy-timeout to 250ms
(down from the interactive auto-import path's 2000ms), since both
share the main event loop with all other in-flight requests and
should fail fast on a WAL-lock collision rather than block the
whole instance for up to ~4s. Has the manual refresh route bypass
the sweep's IDE-auth dedup cache so a click always sees a fresh
read, consistent with this plan's existing "manual actions never
see stale cached data" convention. Documents the previously-missing
agent-availability route in ROUTE_GUARD_TIERS.md's spawn-capable
table.

* fix(cursor): adds SIGKILL follow-up to the status-check spawn

Matches the nudge spawn's existing SIGTERM+SIGKILL pattern so an
unresponsive cursor-agent status check can't leak a lingering
process if it ignores SIGTERM.

* docs(cursor): fills in the PR number for changelog fragments

Renames the 3 changelog.d fragments to their PR-numbered filenames and replaces the (#PR) placeholder with #9173, now that the PR exists.

* fix(cursor): corrects changelog fragments to reference PR #9173

The prior commit only staged the git mv rename — a git add invocation with a stale (pre-rename) pathspec aborted before the actual (#PR) -> (#9173) content edit was staged, so the rename landed without the fix it was meant to carry. This captures the actual content change.

* docs(cursor): regenerates the agent-skills catalog for the new route

check:agent-skills-sync (CI's Merge integrity gate) requires SKILL.md files to stay in sync with the live route catalog. Adding /api/providers/cursor/agent-availability in an earlier commit needed a regen this branch never ran.

* chore(quality): rebaselines file-size caps grown by agentrouter merges

Two already-merged agentrouter commits (564c204ef, ec150a006) on release/v3.8.50 grew open-sse/executors/base.ts, open-sse/handlers/chatCore.ts, and tests/unit/chatcore-translation-paths.test.ts past their frozen caps before this PR branched — unrelated to the Cursor renewal changes here. No PR branch is left to fix the growth in-place, so the caps are bumped to the current real sizes, following the existing release-green rebaseline precedent in this file.

* fix(sse): imports getModel helpers from db/models, not localDb

A recently-merged agentrouter commit added a @/lib/localDb import in chatCore.ts, violating the no-restricted-imports rule (Hard Rule #2 — never barrel-import from localDb.ts). Points the import at the owning module, src/lib/db/models.ts, where both functions are actually defined, and prunes the now-stale suppression entry.

* fix(sse): scopes CC-relay anthropic-beta to its own requestDefaults

Two already-merged agentrouter commits widened usesClaudeCodeProtocol()'s native-Claude system-transform block (billing header + selectBetaFlags-derived anthropic-beta) to also run for generic CC-compatible relay connections, not just real claude traffic and agentrouter's own wire-image mimicry. selectBetaFlags() has no visibility into a relay's own providerSpecificData.requestDefaults, so its header replacement silently wiped out an earlier context-1m append and force-included redact-thinking regardless of the relay's own opt-in. Restores both for plain CC-compatible relays only; real claude/agentrouter traffic is unaffected.

Also bumps four stale hardcoded Codex/Claude Code CLI version-string test assertions (0.144.1->0.146.0, 2.1.219->2.1.220) that drifted when the same two commits bumped the version constants without updating their tests, and rebaselines base.ts's frozen file-size cap for this fix's own +35 lines.

* fix(sse): preserves bare CC-relay native treatment and context-1m

The previous commit's fix was too broad in one direction: excluding ALL CC-compatible relays from the native-Claude header block broke two pre-existing tests (cc-compatible-provider.test.ts, v3.6.6) that rely on that treatment for a 'vanilla' relay with no providerSpecificData.requestDefaults configured.

Refines the gate to this whole native-Claude header-replacement block: replace headers for real claude traffic, agentrouter's wire-image mimicry, OR a CC-relay with no requestDefaults at all — only a relay with EXPLICIT requestDefaults (context1m/redactThinking/summarizeThinking) gets to keep buildHeaders()'s own correctly-computed header set. A redact-thinking-beta strip (unconditional, a no-op when native treatment didn't apply) covers the one remaining gap: selectBetaFlags() force-includes it for a bare relay's opaque client, which a bare relay never explicitly opted into.

Verified against all three previously-conflicting pre-existing tests simultaneously: executor-default-base.test.ts's '1M beta' test, both cc-compatible-provider.test.ts SSE-forcing tests, and provider-request-failure-pipeline.test.ts's 'keeps request beta headers' test (the last of which was already broken by the raw agentrouter merge, confirmed via direct comparison against that exact commit).

* fix(sse): fills in remaining stale CLI version literals

The same two agentrouter commits bumped Codex/Claude Code CLI version constants (0.144.1->0.146.0, 2.1.219->2.1.220) without updating every hardcoded test assertion. This round covers the ones the previous version-string commit missed: the anthropic-cache-fingerprint billing-version constant, a cc-bridge-transforms body assertion, the UI-mirror parity test's own snapshot plus its RoutingTab.tsx source of truth, an integration test's User-Agent assertion (inconsistent with its own dynamic Version assertion two lines up), and the translate-path golden snapshot. Also updates a stale doc comment referencing the old literal by value instead of by constant name.

* fix(cursor): imports from db/ modules, not the localDb barrel

Both files violated Hard Rule #2 (never barrel-import from localDb.ts) — a genuine lint error that had gone uncaught locally. refresh-cursor/route.ts imported getCachedProviderConnectionById from @/lib/localDb instead of its owning module, @/lib/db/readCache. tokenHealthCheckCursor.ts copied the same pattern from its sibling tokenHealthCheckCopilot.ts (an existing, already-suppressed violation) for updateProviderConnection; imports it from @/lib/db/providers instead, with no circular-import fallout (verified via the existing token-health-check-cursor and refresh-cursor-route test suites).

* fix(db): removes stale raw-SQL allowlist entry for cursor route

The cursor auto-import route no longer contains raw SQL — that query
now lives in src/lib/cursor/tokenExtractor.ts, outside the
route/handler scope check-db-rules scans. The allowlist entry was
stale, tripping the stale-enforcement gate.

* fix(test): registers cursor test files in stryker tap.testFiles

Three unit test files covering mutation-tested modules
(route-guard-cursor-agent-availability, route-guard-cursor-refresh,
cursor-renewal) were missing from stryker.conf.json's tap.testFiles,
tripping the mutation-test-coverage gate's drift detection.

* chore(ci): retriggers checks (stuck GH Actions runner on shard 2/4)

* fix(sse): restores CC-relay context1m/redact-thinking test coverage

Rebasing onto release/v3.8.50's new tip (35405be60, an unrelated
agentrouter protocol-inference commit) silently flipped two assertions
this branch's own earlier fix (687fbda62) depends on, in the same test
files that commit touched for other reasons:

- executor-default-base.test.ts: calls[0] (a bare CC-relay with no
  requestDefaults) expected redact-thinking-beta absent; flipped to
  present. calls[1] (context1m+redactThinking requestDefaults) expected
  the context-1m beta preserved; flipped to absent.
- provider-request-failure-pipeline.test.ts: expected Accept:
  text/event-stream and the context-1m beta present for a relay with
  explicit requestDefaults; flipped to application/json and absent.

35405be60 did not touch open-sse/executors/base.ts at all, so these
were test-only edits made without visibility into the still-unmerged
CC-relay header-preservation fix on this branch — they quietly matched
the assertions back to the pre-fix (buggy) behavior instead. Restores
the original, validated expectations; all three interdependent test
files (executor-default-base, cc-compatible-provider,
provider-request-failure-pipeline) verified passing together again.

* ci: re-trigger checks after GitHub Actions incident (2026-08-07, resolved)

* ci: re-trigger checks (previous push event was dropped)

* fix(quality): restore dropped vi.json cursor-renewal keys + rebaseline test growth

vi.json was missing 4 keys (cursorSessionUnchanged, cursorAgentNudgeTitle/Body/Dismiss) that this PR's own pre-merge branch had translated -- the original merge's 'git checkout --theirs' resolution for the 7 conflicted locale files discarded them since upstream's vi.json has no cursor-token-renewal feature. Restored from pre-merge tip a38003e30. Also rebaselines combo-routing-engine.test.ts (3457->3464) for the comment growth from the ALL_ACCOUNTS_INACTIVE fix, caught by CI's PR-mode check:file-size.

* chore(tests): drop explanatory comments on ALL_TARGETS_SKIPPED assertions

Kept the assertion value fix (ALL_ACCOUNTS_INACTIVE -> ALL_TARGETS_SKIPPED); the comments were unnecessary. Reverts the file-size baseline bump these comments caused (combo-routing-engine.test.ts back to its original 3457).
2026-08-11 04:31:24 -03:00

1101 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @ts-nocheck
/**
* Proactive Token Health Check Scheduler
*
* Background job that periodically refreshes OAuth tokens before they expire.
* Each connection can configure its own `healthCheckInterval` (minutes).
* Default: 60 minutes. 0 = disabled.
*
* The scheduler runs a lightweight sweep every TICK_MS (60 s).
* For each eligible connection it calls the provider-specific refresh function,
* updates the DB, and logs the result.
*/
import {
getProviderConnections,
getCachedProviderConnectionById,
updateProviderConnection,
getSettings,
resolveProxyForConnection,
} from "@/lib/localDb";
import {
getAccessToken,
getDeprecationNotice,
supportsTokenRefresh,
isUnrecoverableRefreshError,
refreshCopilotToken,
} from "@omniroute/open-sse/services/tokenRefresh.ts";
import { pickMaskedDisplayValue } from "@/shared/utils/maskEmail";
import { isAutomatedTestProcess } from "@/shared/utils/testProcess";
import { refreshGithubCopilotSubTokenIfNeeded } from "@/lib/tokenHealthCheckCopilot";
import { checkCursorConnectionIfNeeded } from "@/lib/tokenHealthCheckCursor";
const LOG_PREFIX = "[HealthCheck]";
const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
const TICK_MS = 60 * 1000; // sweep interval: every 60 seconds (restored — #7719 dropped the const but kept two call sites)
const DEFAULT_BATCH_SIZE = 20;
const DEFAULT_HEALTH_CHECK_INTERVAL_MIN = 60; // default per-connection interval
function isBuildProcess(): boolean {
return typeof process !== "undefined" && process.env.NEXT_PHASE === "phase-production-build";
}
function getConnectionLogLabel(conn: { name?: string; email?: string; id?: string }): string {
return pickMaskedDisplayValue([conn.name, conn.email], conn.id || "-");
}
export function extractResolvedProxyConfig(resolvedProxy: unknown) {
if (
resolvedProxy &&
typeof resolvedProxy === "object" &&
!Array.isArray(resolvedProxy) &&
"proxy" in resolvedProxy
) {
return (resolvedProxy as { proxy?: unknown }).proxy ?? null;
}
return resolvedProxy ?? null;
}
function getEffectiveTokenExpiryIso(conn: any): string | null {
if (!conn || typeof conn !== "object") return null;
return conn.tokenExpiresAt || conn.expiresAt || null;
}
function getEffectiveTokenExpiryMs(conn: any): number {
const effectiveExpiry = getEffectiveTokenExpiryIso(conn);
if (!effectiveExpiry) return 0;
const expiryMs = new Date(effectiveExpiry).getTime();
return Number.isFinite(expiryMs) ? expiryMs : 0;
}
const TOKEN_EXPIRY_BUFFER = 5 * 60 * 1000; // 5 minutes
function getCopilotTokenExpiryMs(expiresAt: unknown): number {
if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) {
return expiresAt < 1e12 ? expiresAt * 1000 : expiresAt;
}
if (typeof expiresAt === "string" && expiresAt.trim()) {
const parsed = new Date(expiresAt).getTime();
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
// Providers whose OAuth flow yields only a GitHub-style access token (no
// refresh_token) plus a short-lived Copilot sub-token: github.com Copilot and
// GHE Copilot (device-code flow against the enterprise host) both fit this
// shape. Keep both in sync — adding a github-token-only provider elsewhere
// (e.g. new GHE-flavored Copilot variant) must also list it here.
const GITHUB_ACCESS_TOKEN_ONLY_PROVIDERS = new Set(["github", "ghe-copilot"]);
function isGitHubAccessTokenOnlyConnection(conn: any): boolean {
return (
GITHUB_ACCESS_TOKEN_ONLY_PROVIDERS.has(String(conn?.provider || "").toLowerCase()) &&
typeof conn?.accessToken === "string" &&
conn.accessToken.trim().length > 0
);
}
/**
* Resolve the Copilot token endpoint base URL for a connection. github.com
* Copilot always uses api.github.com; GHE Copilot uses its own per-enterprise
* host stored in providerSpecificData.gheUrl at connect time.
*/
function getCopilotTokenBaseUrl(conn: any): string {
if (String(conn?.provider || "").toLowerCase() === "ghe-copilot") {
const gheUrl = conn?.providerSpecificData?.gheUrl;
if (typeof gheUrl === "string" && gheUrl.trim().length > 0) {
return `${gheUrl.trim().replace(/\/+$/, "")}/api/v3`;
}
}
return "https://api.github.com";
}
function canClearGitHubNoRefreshTokenState(conn: any): boolean {
return (
!conn?.testStatus ||
conn.testStatus === "active" ||
(conn.testStatus === "expired" && conn.errorCode === "no_refresh_token")
);
}
// ── Refresh circuit breaker ───────────────────────────────────────────────
// A refresh that returns null (network blip, dead proxy, unclassified error)
// leaves the connection active, so the next 60s sweep retries immediately —
// the production refresh loop (claude/aa5dd5cf 1352×, kimi 270×). We track
// consecutive failures and back off exponentially so a stuck connection stops
// hammering the upstream (and stops flooding the logs) instead of looping.
const REFRESH_CIRCUIT_BASE_MIN = 5;
const REFRESH_CIRCUIT_MAX_MIN = 240; // cap at 4h
const TRANSIENT_REFRESH_RETRY_MIN = 2; // flat 2-minute retry for network/timeout errors
export function getRefreshBackoffUntil(streak: number, now: string): string {
const steps = Math.max(0, streak - 1);
const backoffMin = Math.min(REFRESH_CIRCUIT_BASE_MIN * 2 ** steps, REFRESH_CIRCUIT_MAX_MIN);
return new Date(new Date(now).getTime() + backoffMin * 60 * 1000).toISOString();
}
export function isInRefreshBackoff(conn: any, nowMs: number): boolean {
const until = conn?.providerSpecificData?.refreshCircuit?.until;
if (typeof until !== "string") return false;
const untilMs = new Date(until).getTime();
return Number.isFinite(untilMs) && untilMs > nowMs;
}
export function buildRefreshFailureUpdate(
conn: any,
now: string,
overrides?: {
errorCode?: string;
lastError?: string;
lastErrorType?: string;
testStatus?: string;
}
) {
const wasExpired = conn.testStatus === "expired";
const retryCount = (conn.expiredRetryCount ?? 0) + (wasExpired ? 1 : 0);
// Circuit breaker: increment the consecutive-failure streak and set an
// exponential backoff window so the next sweep skips this connection instead
// of retrying every 60s. Cleared by a successful refresh (clearRefreshCircuit).
// Guard: providerSpecificData may be a primitive or null - treat as empty.
const psd =
typeof conn.providerSpecificData === "object" && conn.providerSpecificData !== null
? conn.providerSpecificData
: {};
const prevStreak = psd.refreshCircuit?.streak ?? 0;
const streak = prevStreak + 1;
return {
lastHealthCheckAt: now,
// A failed background refresh should not evict otherwise healthy accounts
// from request routing. Keep non-expired connections active and only persist
// the refresh error metadata for observability.
testStatus: wasExpired ? "expired" : "active",
lastError: "Health check: token refresh failed",
lastErrorAt: now,
lastErrorType: "token_refresh_failed",
lastErrorSource: "oauth",
errorCode: "refresh_failed",
providerSpecificData: {
...psd,
refreshCircuit: { streak, until: getRefreshBackoffUntil(streak, now), lastFailAt: now },
},
...(wasExpired ? { expiredRetryCount: retryCount, expiredRetryAt: now } : {}),
...(overrides || {}),
};
}
/**
* Build a flat-retry update for a transient refresh failure (network timeout,
* connection reset, DNS failure). Unlike buildRefreshFailureUpdate, this does
* NOT increment the exponential streak -- transient errors should not
* accumulate into a 4-hour backoff. Uses the longer of the existing backoff
* and a flat 2-minute transient window: a longer permanent backoff (e.g. 4h
* from exponential) is preserved to avoid prematurely shortening the circuit
* breaker, while a shorter or absent backoff is extended to the transient
* window.
*/
export function buildTransientRefreshRetryUpdate(conn: any, now: string) {
const wasExpired = conn.testStatus === "expired";
const retryCount = (conn.expiredRetryCount ?? 0) + (wasExpired ? 1 : 0);
// Preserve existing streak from any prior permanent failures so a transient
// error does not reset the exponential backoff ladder.
// Guard: providerSpecificData may be a primitive or null - treat as empty.
const psd =
typeof conn.providerSpecificData === "object" && conn.providerSpecificData !== null
? conn.providerSpecificData
: {};
const existingCircuit = psd.refreshCircuit;
const existingStreak = existingCircuit?.streak ?? 0;
const parsedExistingUntil = existingCircuit?.until
? new Date(existingCircuit.until).getTime()
: 0;
// Guard against NaN from malformed date strings - treat as no existing backoff.
const existingUntil = Number.isFinite(parsedExistingUntil) ? parsedExistingUntil : 0;
const transientUntil = new Date(now).getTime() + TRANSIENT_REFRESH_RETRY_MIN * 60 * 1000;
// Use the longer of the two: preserve an existing permanent backoff
// (e.g. 4h from exponential) or extend to the transient window.
const useTransient = existingUntil <= transientUntil;
const until = useTransient
? new Date(transientUntil).toISOString()
: (existingCircuit?.until ?? new Date(transientUntil).toISOString());
return {
lastHealthCheckAt: now,
testStatus: wasExpired ? "expired" : "active",
lastError: "Health check: token refresh transient error (network/timeout)",
lastErrorAt: now,
lastErrorType: "token_refresh_transient",
lastErrorSource: "oauth",
errorCode: "refresh_transient",
providerSpecificData: {
...psd,
refreshCircuit: {
streak: existingStreak,
until,
lastFailAt: now,
// Always set the transient flag for observability. When the existing
// backoff is longer (useTransient=false), the transient error occurred
// but the permanent backoff was preserved - flag it as false so
// observers can distinguish this from a pure transient retry.
transient: useTransient,
},
},
...(wasExpired ? { expiredRetryCount: retryCount, expiredRetryAt: now } : {}),
};
}
/**
* Strip the refresh circuit breaker state from providerSpecificData after a
* successful refresh, so the streak/backoff resets cleanly.
*/
export function clearRefreshCircuit(
providerSpecificData: Record<string, unknown> | null | undefined
): Record<string, unknown> | undefined {
if (!providerSpecificData || typeof providerSpecificData !== "object") return undefined;
if (!("refreshCircuit" in providerSpecificData)) return undefined;
const next = { ...providerSpecificData };
delete next.refreshCircuit;
return next;
}
/**
* Concurrent-check batch size for the sweep, read per-call (not at module
* load) so tests — and operators — can override it via HEALTHCHECK_BATCH_SIZE
* without restarting the process. #7719 hardcoded this to a module-level
* `const BATCH_SIZE = 20`, silently dropping the configurability restored
* here (#7875). Falls back to DEFAULT_BATCH_SIZE on a missing/invalid value.
*/
function getConfiguredBatchSize(): number {
const configured = parseInt(process.env.HEALTHCHECK_BATCH_SIZE || "", 10);
return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_BATCH_SIZE;
}
function isEnvFlagEnabled(name: string): boolean {
const value = process.env[name];
if (!value) return false;
return TRUE_ENV_VALUES.has(value.trim().toLowerCase());
}
export function isHealthCheckDisabled(): boolean {
return (
isEnvFlagEnabled("OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK") ||
isBuildProcess() ||
isAutomatedTestProcess()
);
}
/**
* Providers excluded from the PROACTIVE sweep, comma-separated, case-insensitive
* (e.g. "codex,openai"). Targeted alternative to OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK:
* keeps rotating-token cascade providers (Codex/OpenAI share one Auth0 family) on the
* reactive 401 path WITHOUT starving short-TTL providers (Kimi-coding) sweep-wide.
*/
function getHealthCheckSkipProviders(): Set<string> {
const raw = process.env.OMNIROUTE_HEALTHCHECK_SKIP_PROVIDERS || "";
return new Set(
raw
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean)
);
}
// ── Logging helper ───────────────────────────────────────────────────────────
let cachedHideLogs: boolean | null = null;
let cacheTimestamp = 0;
let pendingHideLogs: Promise<boolean> | null = null;
const CACHE_TTL = 30_000; // Cache settings for 30 seconds
export async function shouldHideLogs(): Promise<boolean> {
if (
isEnvFlagEnabled("OMNIROUTE_HIDE_HEALTHCHECK_LOGS") ||
isBuildProcess() ||
isAutomatedTestProcess()
) {
return true;
}
const now = Date.now();
// Return cached value if valid
if (cachedHideLogs !== null && now - cacheTimestamp < CACHE_TTL) {
return cachedHideLogs;
}
// Return pending promise if a query is already in progress (request coalescing)
if (pendingHideLogs !== null) {
return pendingHideLogs;
}
// Create new promise for DB query
pendingHideLogs = (async () => {
try {
const settings = await getSettings();
cachedHideLogs = settings.hideHealthCheckLogs === true;
cacheTimestamp = now;
return cachedHideLogs;
} catch {
return false;
} finally {
pendingHideLogs = null;
}
})();
return pendingHideLogs;
}
function log(message: string, ...args: any[]) {
shouldHideLogs().then((hide) => {
if (!hide) console.log(message, ...args);
});
}
function logWarn(message: string, ...args: any[]) {
shouldHideLogs().then((hide) => {
if (!hide) console.warn(message, ...args);
});
}
function logError(message: string, ...args: any[]) {
shouldHideLogs().then((hide) => {
if (!hide) console.error(message, ...args);
});
}
/**
* Clear the cached hideLogs setting (call when settings are updated).
*/
export function clearHealthCheckLogCache() {
cachedHideLogs = null;
cacheTimestamp = 0;
}
// ── Singleton guard (globalThis survives HMR re-evaluation) ─────────────────
declare global {
var __omnirouteTokenHC:
| { initialized: boolean; interval: ReturnType<typeof setInterval> | null; sweeping: boolean }
| undefined;
}
function getHCState() {
if (!globalThis.__omnirouteTokenHC) {
globalThis.__omnirouteTokenHC = {
initialized: false,
interval: null,
initTimeout: null,
sweeping: false,
};
}
return globalThis.__omnirouteTokenHC;
}
/**
* Start the health-check scheduler (idempotent).
*/
export function initTokenHealthCheck() {
const state = getHCState();
if (state.initialized || isHealthCheckDisabled()) return;
state.initialized = true;
log(`${LOG_PREFIX} Starting proactive token health-check (tick every ${TICK_MS / 1000}s)`);
const timer = setTimeout(() => {
state.initTimeout = null;
sweep();
state.interval = setInterval(sweep, TICK_MS);
if (state.interval && typeof state.interval === "object" && "unref" in state.interval) {
(state.interval as { unref?: () => void }).unref?.();
}
}, 10_000);
state.initTimeout = timer;
if (timer && typeof timer === "object" && "unref" in timer) {
(timer as { unref?: () => void }).unref?.();
}
}
/**
* Stop the scheduler (useful for tests / hot-reload).
*/
export function stopTokenHealthCheck() {
const state = getHCState();
if (state.initTimeout) {
clearTimeout(state.initTimeout);
state.initTimeout = null;
}
if (state.interval) {
clearInterval(state.interval);
state.interval = null;
}
state.initialized = false;
}
// ── Core sweep (batch concurrent) ──────────────────────────────────────────
/** Returns the number of connections swept, which the job registry records. */
export async function sweep(): Promise<number> {
const state = getHCState();
if (state.sweeping) {
log(`${LOG_PREFIX} Sweep skipped — previous sweep still in progress`);
return 0;
}
state.sweeping = true;
try {
const connections = await getProviderConnections({ authType: "oauth" });
if (!connections || connections.length === 0) return 0;
const staggerMs = parseInt(process.env.HEALTHCHECK_STAGGER_MS || "3000", 10);
const total = connections.length;
// Process connections in concurrent batches. Within a single batch
// connections are checked concurrently (same-epoch start) so the array
// is drained faster and the event loop can service requests between
// batches. The inter-batch stagger preserves the original burst-
// prevention intent (Issue #1220) while reducing total sweep time from
// O(total × staggerMs) to O(total ÷ batchSize × staggerMs).
const batchSize = Math.min(getConfiguredBatchSize(), total);
for (let offset = 0; offset < total; offset += batchSize) {
const batchEnd = Math.min(offset + batchSize, total);
const batch: Array<Promise<void>> = [];
for (let i = offset; i < batchEnd; i++) {
const conn = connections[i];
batch.push(
checkConnection(conn).catch((err: Error) => {
logError(`${LOG_PREFIX} Error checking ${conn.name || conn.id}:`, err.message);
})
);
}
await Promise.all(batch);
// Stagger between batches (not between individual connections) to
// prevent sustained bursting while reducing total sweep duration.
if (batchEnd < total) {
if (staggerMs > 0) {
const jitterMin = parseInt(process.env.HEALTHCHECK_JITTER_MIN_MS || "500", 10);
const jitterMax = parseInt(process.env.HEALTHCHECK_JITTER_MAX_MS || "5000", 10);
const jitter = jitterMin + Math.random() * Math.max(0, jitterMax - jitterMin);
await new Promise((resolve) => setTimeout(resolve, staggerMs + jitter));
}
// Yield a microtask so the event loop can service pending I/O
// (DB contention, network responses) before the next batch starts.
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
return total;
} catch (err) {
logError(`${LOG_PREFIX} Sweep error:`, err.message);
return 0;
} finally {
state.sweeping = false;
}
}
/**
* Check a single connection and refresh if due.
*/
export async function checkConnection(conn) {
if (!conn?.id) return;
const latestConnection = (await getCachedProviderConnectionById(conn.id)) || conn;
conn = latestConnection;
// Per-provider opt-out of proactive refresh (e.g. Codex/OpenAI cascade
// providers) — their token stays on the reactive, serialized 401 path while
// other providers keep being refreshed proactively.
if (getHealthCheckSkipProviders().has(String(conn.provider || "").toLowerCase())) {
return;
}
// Determine interval (0 = disabled)
const intervalMin = conn.healthCheckInterval ?? DEFAULT_HEALTH_CHECK_INTERVAL_MIN;
if (intervalMin <= 0) return;
if (!conn.isActive) return;
// #8182: skip terminal connections (credits_exhausted / banned / expired).
// These can never self-heal via a token refresh — probing them wastes
// CPU and network on every sweep cycle. Mirrors isTerminalConnectionStatus
// in src/sse/services/auth.ts and TERMINAL_CONNECTION_STATUSES in
// src/lib/quota/connectionRecovery.ts.
//
// #5326 exception: a GitHub Copilot access-token-only connection parked in
// "expired" with errorCode "no_refresh_token" is NOT actually terminal — it's
// the exact target of the self-heal below (canClearGitHubNoRefreshTokenState),
// which clears that stale status back to "active" once the Copilot sub-token
// proves usable. Treating it as terminal here made that self-heal unreachable,
// leaving healthy Copilot connections stuck at "expired" forever.
const isRecoverableGithubCopilotNoRefresh =
conn.testStatus === "expired" &&
conn.errorCode === "no_refresh_token" &&
isGitHubAccessTokenOnlyConnection(conn);
// Cursor has no refresh_token by design — an existing REQUEST-TIME path
// (resolveTerminalConnectionStatus() in src/sse/services/auth.ts) can land
// a Cursor connection at testStatus "expired" on a live 401 before the
// Cursor renewal branch below ever runs. Un-terminal it so the sweep can
// still attempt a renewal, UNLESS the account is genuinely dead
// (lastErrorType "account_deactivated" is documented as permanently dead
// and must not be retried — doing so would repeatedly nudge cursor-agent
// and re-scrape against a dead account).
const isRecoverableCursorExpired =
conn.testStatus === "expired" &&
String(conn.provider || "").toLowerCase() === "cursor" &&
conn.lastErrorType !== "account_deactivated";
const terminalStatuses = new Set(["credits_exhausted", "banned", "expired"]);
if (
typeof conn.testStatus === "string" &&
terminalStatuses.has(conn.testStatus.toLowerCase()) &&
!isRecoverableGithubCopilotNoRefresh &&
!isRecoverableCursorExpired
) {
return;
}
// Deprecated upstream (see DEPRECATED_PROVIDERS in tokenRefresh): the provider is not
// routable, so refreshing kept a credential alive that could never answer a request.
// Surface that as a terminal state naming the migration, instead of the silent
// `Skipping … (refresh unsupported)` that dropping it from supportsTokenRefresh alone
// would produce — which would leave the row at "active" forever, doing nothing.
//
// Placed AFTER the terminal-status guard above, which makes this idempotent for free:
// once marked "expired" the connection is skipped on every later sweep, so this writes
// exactly once instead of rewriting the same reason each cycle.
const deprecation = getDeprecationNotice(String(conn.provider || ""));
if (deprecation) {
const now = new Date().toISOString();
await updateProviderConnection(conn.id, {
testStatus: "expired",
lastHealthCheckAt: now,
lastError: deprecation.reason,
lastErrorAt: now,
lastErrorType: "provider_deprecated",
lastErrorSource: "oauth",
errorCode: "provider_deprecated",
});
log(
`${LOG_PREFIX} ${conn.provider}/${getConnectionLogLabel(conn)} is a deprecated provider; marking expired (migrate to ${deprecation.migrateTo})`
);
return;
}
// Cursor's refreshToken is always null (no refresh_token by design), so
// falling into the generic !conn.refreshToken block below was always a
// silent no-op for Cursor. Explicit provider dispatch here is clearer than
// relying on that fallthrough.
if (String(conn.provider || "").toLowerCase() === "cursor") {
const tokenExpiresAt = getEffectiveTokenExpiryMs(conn);
const isAboutToExpire = tokenExpiresAt > 0 && tokenExpiresAt - Date.now() < TOKEN_EXPIRY_BUFFER;
if (tokenExpiresAt > 0 && !isAboutToExpire) return;
if (isInRefreshBackoff(conn, Date.now())) return;
const now = new Date().toISOString();
await checkCursorConnectionIfNeeded({
conn,
now,
buildRefreshFailureUpdate,
log,
logWarn,
logError,
getConnectionLogLabel,
logPrefix: LOG_PREFIX,
});
return;
}
if (!conn.refreshToken || typeof conn.refreshToken !== "string") {
if (isGitHubAccessTokenOnlyConnection(conn)) {
const now = new Date().toISOString();
const providerSpecificData = conn.providerSpecificData || {};
const hasCopilotToken =
typeof providerSpecificData.copilotToken === "string" &&
providerSpecificData.copilotToken.trim().length > 0;
const copilotExpiresAtMs = getCopilotTokenExpiryMs(
providerSpecificData.copilotTokenExpiresAt
);
const copilotAboutToExpire =
!hasCopilotToken ||
!copilotExpiresAtMs ||
copilotExpiresAtMs - Date.now() < TOKEN_EXPIRY_BUFFER;
let refreshedProviderSpecificData: Record<string, unknown> | null = null;
if (copilotAboutToExpire) {
const hideLogs = await shouldHideLogs();
const proxyResolution = await resolveProxyForConnection(conn.id);
const proxyConfig = extractResolvedProxyConfig(proxyResolution);
const healthCheckLog = {
info: (tag: string, msg: string) => {
if (!hideLogs) console.log(LOG_PREFIX, `[${tag}]`, msg);
},
warn: (tag: string, msg: string) => {
if (!hideLogs) console.warn(LOG_PREFIX, `[${tag}]`, msg);
},
error: (tag: string, msg: string, extra?: Record<string, unknown>) => {
if (!hideLogs) console.error(LOG_PREFIX, `[${tag}]`, msg, extra || "");
},
};
const copilotResult = await refreshCopilotToken(
conn.accessToken,
healthCheckLog,
proxyConfig,
getCopilotTokenBaseUrl(conn)
);
if (copilotResult?.token) {
refreshedProviderSpecificData = {
...providerSpecificData,
copilotToken: copilotResult.token,
copilotTokenExpiresAt: copilotResult.expiresAt,
};
}
}
if (canClearGitHubNoRefreshTokenState(conn)) {
await updateProviderConnection(conn.id, {
lastHealthCheckAt: now,
testStatus: "active",
lastError:
copilotAboutToExpire && !refreshedProviderSpecificData
? "Health check: Copilot token refresh failed"
: null,
lastErrorAt: copilotAboutToExpire && !refreshedProviderSpecificData ? now : null,
lastErrorType:
copilotAboutToExpire && !refreshedProviderSpecificData ? "token_refresh_failed" : null,
lastErrorSource: copilotAboutToExpire && !refreshedProviderSpecificData ? "oauth" : null,
errorCode:
copilotAboutToExpire && !refreshedProviderSpecificData ? "refresh_failed" : null,
expiredRetryCount: null,
expiredRetryAt: null,
...(refreshedProviderSpecificData
? { providerSpecificData: refreshedProviderSpecificData }
: {}),
});
} else {
await updateProviderConnection(conn.id, {
lastHealthCheckAt: now,
...(refreshedProviderSpecificData
? { providerSpecificData: refreshedProviderSpecificData }
: {}),
});
}
// Steady-state ticks stay silent: this path runs once per TICK_MS (60s) for
// EVERY github/ghe-copilot connection, so an unconditional line here emits
// ~1440 entries/day per connection all saying the same nothing-changed thing.
// Only report when the sweep actually did work — a Copilot sub-token refresh
// attempt — so a genuine refresh failure still surfaces in the log.
if (copilotAboutToExpire) {
log(
`${LOG_PREFIX} ${conn.provider}/${getConnectionLogLabel(conn)} Copilot token ${
refreshedProviderSpecificData ? "refreshed" : "refresh FAILED"
} (no refresh token; connection stays active)`
);
}
return;
}
// #5326: a refresh-CAPABLE provider (e.g. antigravity/gemini) with no usable
// refresh token can never self-heal via the sweep — it genuinely needs re-auth.
// Silently skipping here left the row at testStatus="active" while the dashboard
// badge (which derives expiry from tokenExpiresAt||expiresAt) showed a confusing
// cosmetic "Token Expired". Surface reality as a terminal "expired" status instead.
// Guard tightly so we do NOT clobber:
// - providers without refresh tokens (supportsTokenRefresh=false; #8407 devin-cli)
// - connections already in a terminal/specific state (expired/banned/credits_exhausted)
// - transient cooldown state (unavailable) owned by the request path
const refreshCapableNeedsReauth =
supportsTokenRefresh(conn.provider) &&
(!conn.testStatus || conn.testStatus === "active") &&
!(conn.apiKey && conn.apiKey.length > 0); // API-key-only connections don't need refresh tokens
if (refreshCapableNeedsReauth) {
const now = new Date().toISOString();
await updateProviderConnection(conn.id, {
testStatus: "expired",
lastHealthCheckAt: now,
lastError: "No refresh token available — re-authenticate this account.",
lastErrorAt: now,
lastErrorType: "no_refresh_token",
lastErrorSource: "oauth",
errorCode: "no_refresh_token",
});
log(
`${LOG_PREFIX} ${conn.provider}/${getConnectionLogLabel(conn)} has no refresh token; marking expired (needs re-auth)`
);
}
return;
}
// Retry expired connections with exponential backoff up to EXPIRED_RETRY_MAX times.
if (conn.testStatus === "expired") {
const retryCount = conn.expiredRetryCount ?? 0;
if (retryCount >= EXPIRED_RETRY_MAX) return;
const lastRetry = conn.expiredRetryAt ? new Date(conn.expiredRetryAt).getTime() : 0;
const backoffMs = EXPIRED_RETRY_BACKOFF_MIN * 60 * 1000 * Math.pow(2, retryCount);
if (Date.now() - lastRetry < backoffMs) return;
log(
`${LOG_PREFIX} Retrying expired ${conn.provider}/${getConnectionLogLabel(conn)} (attempt ${retryCount + 1}/${EXPIRED_RETRY_MAX})`
);
}
if (!supportsTokenRefresh(conn.provider)) {
const now = new Date().toISOString();
await updateProviderConnection(conn.id, { lastHealthCheckAt: now });
log(
`${LOG_PREFIX} Skipping ${conn.provider}/${getConnectionLogLabel(conn)} (refresh unsupported)`
);
return;
}
const intervalMs = intervalMin * 60 * 1000;
const lastCheck = conn.lastHealthCheckAt ? new Date(conn.lastHealthCheckAt).getTime() : 0;
// Prefer expiry-driven refresh when the provider returns a concrete expiry timestamp.
// Rotating-token providers such as Codex should not be refreshed on a fixed hourly
// cadence while the access token is still valid for days.
const tokenExpiresAt = getEffectiveTokenExpiryMs(conn);
const hasKnownExpiry = tokenExpiresAt > 0;
const isAboutToExpire = hasKnownExpiry && tokenExpiresAt - Date.now() < TOKEN_EXPIRY_BUFFER;
// ROTATING_REFRESH_PROVIDERS — providers whose refresh_tokens are SINGLE-USE
// (each refresh consumes the old one and returns a new one). For these, refreshing
// on a fixed interval — instead of strictly on imminent expiry — burns rotations
// unnecessarily AND can trigger Auth0's token family revocation (especially OpenAI
// Codex). 9router did not have this background sweep; it was introduced in OmniRoute
// and is the root cause of "adding account B invalidates account A" reports.
// The interval path is kept ONLY for non-rotating providers where token state can
// drift silently (e.g. cookie-based, opaque sessions without expires_at).
const ROTATING_REFRESH_PROVIDERS = new Set([
"codex",
"openai",
"kimi-coding",
"cline",
"kiro",
"amazon-q",
"gitlab-duo",
"claude",
"openference",
]);
const isRotatingProvider = ROTATING_REFRESH_PROVIDERS.has(
String(conn.provider || "").toLowerCase()
);
const shouldRefreshByInterval =
!hasKnownExpiry && !isRotatingProvider && Date.now() - lastCheck >= intervalMs;
if (!isAboutToExpire && !shouldRefreshByInterval) return;
// Circuit breaker: if recent refreshes for this connection failed, wait out
// the exponential backoff window instead of retrying every 60s tick. This is
// what stops the refresh loop when getAccessToken keeps returning null
// (dead proxy / network blip / unclassified upstream error).
if (isInRefreshBackoff(conn, Date.now())) {
return;
}
const reason = isAboutToExpire ? "token expiring soon" : `interval: ${intervalMin}min`;
log(`${LOG_PREFIX} Refreshing ${conn.provider}/${getConnectionLogLabel(conn)} (${reason})`);
const attemptedRefreshToken = conn.refreshToken;
const attemptedAccessToken = conn.accessToken || null;
const credentials = {
connectionId: conn.id,
refreshToken: attemptedRefreshToken,
accessToken: attemptedAccessToken,
expiresAt: getEffectiveTokenExpiryIso(conn),
providerSpecificData: conn.providerSpecificData,
};
const hideLogs = await shouldHideLogs();
const proxyResolution = await resolveProxyForConnection(conn.id);
const proxyConfig = extractResolvedProxyConfig(proxyResolution);
const healthCheckLog = {
info: (tag: string, msg: string) => {
if (!hideLogs) console.log(LOG_PREFIX, `[${tag}]`, msg);
},
warn: (tag: string, msg: string) => {
if (!hideLogs) console.warn(LOG_PREFIX, `[${tag}]`, msg);
},
error: (tag: string, msg: string, extra?: Record<string, unknown>) => {
if (!hideLogs) console.error(LOG_PREFIX, `[${tag}]`, msg, extra || "");
},
};
// Pass onPersist so the DB write is atomic with the network call inside the mutex.
// This prevents a concurrent sweep or request from reading stale credentials
// and re-using an already-consumed rotating refresh token (Codex/OpenAI).
type RefreshResultShape = {
accessToken?: string;
refreshToken?: string;
expiresAt?: string;
expiresIn?: number;
providerSpecificData?: Record<string, unknown>;
};
type ConnectionUpdate = Parameters<typeof updateProviderConnection>[1];
let persistedResult: RefreshResultShape | null = null;
let result: RefreshResultShape | null;
try {
result = await getAccessToken(
conn.provider,
credentials,
healthCheckLog,
proxyConfig,
async (refreshResult: RefreshResultShape) => {
const now = new Date().toISOString();
const updateData: ConnectionUpdate = {
accessToken: refreshResult.accessToken,
lastHealthCheckAt: now,
testStatus: "active",
lastError: null,
lastErrorAt: null,
lastErrorType: null,
lastErrorSource: null,
errorCode: null,
expiredRetryCount: null,
expiredRetryAt: null,
};
if (refreshResult.refreshToken) {
updateData.refreshToken = refreshResult.refreshToken;
}
if (refreshResult.expiresAt) {
updateData.expiresAt = refreshResult.expiresAt;
updateData.tokenExpiresAt = refreshResult.expiresAt;
} else if (refreshResult.expiresIn) {
const expiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString();
updateData.expiresAt = expiresAt;
updateData.tokenExpiresAt = expiresAt;
}
// Merge new providerSpecificData and ALWAYS clear the refresh circuit
// breaker streak on a successful refresh.
const mergedProviderData = {
...(conn.providerSpecificData || {}),
...(refreshResult.providerSpecificData || {}),
};
const clearedProviderData = clearRefreshCircuit(mergedProviderData);
if (clearedProviderData !== undefined) {
updateData.providerSpecificData = clearedProviderData;
} else if (refreshResult.providerSpecificData) {
updateData.providerSpecificData = mergedProviderData;
}
try {
await updateProviderConnection(conn.id, updateData);
} catch (dbErr) {
// DB write failed after successful refresh - log but do not throw.
// The outer catch would misclassify this as a network error.
logWarn(
`${LOG_PREFIX} ~ ${conn.provider}/${getConnectionLogLabel(conn)} DB write failed after successful refresh` +
` (${dbErr instanceof Error ? dbErr.message : String(dbErr)}); token not persisted`
);
return;
}
// Mark as persisted AFTER the DB write succeeds.
persistedResult = refreshResult;
}
);
} catch (err) {
// If onPersist already wrote a successful result, do not overwrite it.
if (persistedResult) {
logWarn(
`${LOG_PREFIX} ~ ${conn.provider}/${getConnectionLogLabel(conn)} refresh error after successful persist` +
` (${err instanceof Error ? err.message : String(err)}); ignoring`
);
return;
}
// Classify: only network/timeout errors are transient. Programming errors
// and DB failures fall through to the exponential backoff path.
const errObj = typeof err === "object" && err !== null ? err : {};
const errName = err instanceof Error ? err.name : String(errObj.name ?? "");
const errMsg = err instanceof Error ? err.message : String(err);
const errCode = String(errObj.code ?? "");
// Also check err.cause for wrapped fetch errors.
const errCause = errObj.cause instanceof Error ? errObj.cause.message : "";
const errCauseCode = String(errObj.cause?.code ?? "");
const combinedMsg = `${errMsg} ${errCause}`;
const combinedCode = `${errCode} ${errCauseCode}`;
const isTransientNetworkError =
errName === "AbortError" ||
errName === "TimeoutError" ||
/ETIMEDOUT|ECONNREFUSED|ECONNRESET|ECONNABORTED|EPIPE|EHOSTUNREACH|ENETUNREACH|ENOTCONN|ENOTFOUND|EAI_AGAIN|ERR_NETWORK|ERR_SOCKET|ERR_CONNECTION|socket hang up|fetch failed/i.test(
combinedMsg
) ||
/ETIMEDOUT|ECONNREFUSED|ECONNRESET|ECONNABORTED|EPIPE|EHOSTUNREACH|ENETUNREACH|ENOTCONN|ENOTFOUND|EAI_AGAIN|ERR_NETWORK|ERR_SOCKET|ERR_CONNECTION/i.test(
combinedCode
);
if (isTransientNetworkError) {
const transientNow = new Date().toISOString();
const updateData = buildTransientRefreshRetryUpdate(conn, transientNow);
try {
await updateProviderConnection(conn.id, updateData);
} catch (dbErr) {
logWarn(
`${LOG_PREFIX} ~ ${conn.provider}/${getConnectionLogLabel(conn)} DB write failed after transient error` +
` (${dbErr instanceof Error ? dbErr.message : String(dbErr)}); state not persisted`
);
}
logWarn(
`${LOG_PREFIX} ~ ${conn.provider}/${getConnectionLogLabel(conn)} refresh transient error` +
` (${err instanceof Error ? err.message : String(err)}); retry in ${TRANSIENT_REFRESH_RETRY_MIN}min`
);
} else {
// Non-transient error: apply standard exponential backoff.
const failNow = new Date().toISOString();
const updateData = buildRefreshFailureUpdate(conn, failNow);
try {
await updateProviderConnection(conn.id, updateData);
} catch (dbErr) {
logWarn(
`${LOG_PREFIX} ~ ${conn.provider}/${getConnectionLogLabel(conn)} DB write failed after permanent error` +
` (${dbErr instanceof Error ? dbErr.message : String(dbErr)}); state not persisted`
);
}
logWarn(
`${LOG_PREFIX} ~ ${conn.provider}/${getConnectionLogLabel(conn)} refresh error` +
` (${err instanceof Error ? err.message : String(err)}); applying exponential backoff`
);
}
return;
}
const now = new Date().toISOString();
// ─── Handle unrecoverable errors (e.g. refresh_token_reused) ───────────
// OpenAI Codex uses rotating one-time-use refresh tokens.
// Once used, the old token is permanently invalidated.
// Retrying will never succeed → deactivate and stop the loop.
if (isUnrecoverableRefreshError(result)) {
const currentConnection = await getCachedProviderConnectionById(conn.id);
const credentialsChangedSinceSweep =
!!currentConnection &&
(currentConnection.refreshToken !== attemptedRefreshToken ||
(currentConnection.accessToken || null) !== attemptedAccessToken);
if (credentialsChangedSinceSweep) {
await updateProviderConnection(conn.id, {
lastHealthCheckAt: now,
});
logWarn(
`${LOG_PREFIX} ! ${conn.provider}/${getConnectionLogLabel(conn)} changed during refresh; skipping stale deactivation`
);
return;
}
const accessTokenStillValid =
getEffectiveTokenExpiryMs(currentConnection || conn) > Date.now() + TOKEN_EXPIRY_BUFFER;
if (accessTokenStillValid) {
await updateProviderConnection(conn.id, {
lastHealthCheckAt: now,
testStatus: "active",
lastError: `Health check refresh failed (${result.error}). Re-authenticate before the current access token expires.`,
lastErrorAt: now,
lastErrorType: result.error,
lastErrorSource: "oauth",
errorCode: result.error,
});
logWarn(
`${LOG_PREFIX} ! ${conn.provider}/${getConnectionLogLabel(conn)} refresh token is invalid (${result.error}), but the current access token is still valid; keeping connection active`
);
return;
}
await updateProviderConnection(conn.id, {
lastHealthCheckAt: now,
testStatus: "expired",
lastError: isRotatingProvider
? `Refresh token consumed (${result.error}). Please re-authenticate this account.`
: `Refresh token rejected (${result.error}). Please re-authenticate this account.`,
lastErrorAt: now,
lastErrorType: result.error,
lastErrorSource: "oauth",
errorCode: result.error,
isActive: false,
// Only rotating-token providers (Codex/OpenAI/etc.) have single-use refresh
// tokens that are genuinely consumed and worthless after a failed refresh, so
// clearing them is safe. For non-rotating providers (Google: antigravity /
// gemini) the stored refresh_token is the user's only recovery
// artifact — nulling it caused #3679 (the connection reports "No valid refresh
// token available" and can never recover even after re-activation). Preserve it.
...(isRotatingProvider ? { refreshToken: null } : {}),
});
logError(
`${LOG_PREFIX}${conn.provider}/${getConnectionLogLabel(conn)}` +
`Refresh token is permanently invalid (${result.error}). ` +
`Connection deactivated. Re-authenticate to restore.`
);
return;
}
if (result && result.accessToken) {
// onPersist already wrote the core token fields atomically inside the mutex.
// Only write the lastHealthCheckAt timestamp (and any fields onPersist may have
// missed) here, to avoid a redundant full update that would race against another
// concurrent refresh that already wrote fresh credentials.
if (persistedResult) {
await updateProviderConnection(conn.id, { lastHealthCheckAt: now });
} else {
// No onPersist (e.g. no connectionId — token-hash dedup path). Write all fields.
const updateData: any = {
accessToken: result.accessToken,
lastHealthCheckAt: now,
testStatus: "active",
lastError: null,
lastErrorAt: null,
lastErrorType: null,
lastErrorSource: null,
errorCode: null,
expiredRetryCount: null,
expiredRetryAt: null,
};
if (result.refreshToken) {
updateData.refreshToken = result.refreshToken;
}
if (result.expiresAt) {
updateData.expiresAt = result.expiresAt;
updateData.tokenExpiresAt = result.expiresAt;
} else if (result.expiresIn) {
const expiresAt = new Date(Date.now() + result.expiresIn * 1000).toISOString();
updateData.expiresAt = expiresAt;
updateData.tokenExpiresAt = expiresAt;
}
if (result.providerSpecificData) {
updateData.providerSpecificData = {
...(conn.providerSpecificData || {}),
...result.providerSpecificData,
};
}
await updateProviderConnection(conn.id, updateData);
}
log(`${LOG_PREFIX}${conn.provider}/${getConnectionLogLabel(conn)} refreshed`);
// ── GitHub Copilot sub-token refresh ──────────────────────────────────────
// Extracted to tokenHealthCheckCopilot.ts to keep this file under the
// frozen file-size budget. See that file's header comment for context.
await refreshGithubCopilotSubTokenIfNeeded({
conn,
result,
proxyConfig,
healthCheckLog,
log,
logWarn,
logError,
getConnectionLogLabel,
logPrefix: LOG_PREFIX,
});
} else {
const updateData = buildRefreshFailureUpdate(conn, now);
await updateProviderConnection(conn.id, updateData);
logWarn(
`${LOG_PREFIX}${conn.provider}/${getConnectionLogLabel(conn)} refresh failed` +
(conn.testStatus === "expired"
? ` (${updateData.expiredRetryCount}/${EXPIRED_RETRY_MAX} expired retries used)`
: "")
);
}
}