mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 16:42:16 +03:00
⭐5 — Cursor PKCE login com Bearer quota, auto router e empty-turn errors. Feature completa e testada (11 arquivos de teste, 133 testes focados, todos verdes).
**Validação (worktree combinado `.claude/worktrees/fix-9909`, board sobre `origin/release/v3.8.50`):**
- 3 conflitos reais resolvidos: `config/quality/eslint-suppressions.json` (aditivo), `open-sse/config/providers/registry/cursor/index.ts` (dedup de 208 entradas de catálogo, 0 IDs duplicados verificado), `open-sse/executors/cursor.ts` (imports aditivos).
- `npm run typecheck:core`: limpo.
- `check-changelog-integrity`, `check-file-size`, `check-complexity` (2615/2774), `check-cognitive-complexity` (1175/1223), `check-dead-code` (410/416): todos OK.
- `check-public-creds`: 1 entrada obsoleta pré-existente na allowlist (`copilot-m365-web.ts:330`), já presente no tip da release — não é desta PR.
- `npm run lint`: 0 errors (5 warnings pré-existentes).
- Testes focados (`cursor-agent-cli-version`, `cursor-available-models`, `cursor-catalog-combo-compat`, `cursor-errors-classify`, `cursor-login-pkce`, `cursor-model-effort-suffix-7289`, `cursor-streaming`, `cursor-token-extractor`, `cursor-token-refresh-wiring`, `cursor-usage-fetcher`, `empty-stream-no-content-8649`): 133/133 verdes.
- Corrigido durante a validação: 1 teste novo da própria PR (`cursor-model-effort-suffix-7289.test.ts`, "splits effort off legacy grok- ids") colidia com `CURSOR_MODEL_ALIASES` já mesclado na release (mapeia `grok-4.5-high` → `cursor-grok-4.5-high` antes do fallback legado rodar); ajustado para usar um id não-aliasado (`grok-3-high`) que de fato exercita o fallback — commit `68b58ed`.
Obrigado pela contribuição, @yansigit — feature robusta com boa cobertura de testes.
116 lines
3.8 KiB
TypeScript
116 lines
3.8 KiB
TypeScript
/**
|
|
* Cursor OAuth token refresh via api2.cursor.sh/auth/exchange_user_api_key.
|
|
* OpenCodex-compatible (Bearer refresh token, JSON body `{}`).
|
|
* Self-contained in open-sse (no import from src/).
|
|
*/
|
|
|
|
const CURSOR_REFRESH_URL = "https://api2.cursor.sh/auth/exchange_user_api_key";
|
|
const REFRESH_TIMEOUT_MS = 15_000;
|
|
const REFRESH_ATTEMPTS = 3;
|
|
const REFRESH_RETRY_BASE_MS = 300;
|
|
const EXPIRY_SKEW_MS = 5 * 60 * 1000;
|
|
const FALLBACK_TTL_MS = 60 * 60 * 1000;
|
|
|
|
function isRetryableRefreshStatus(status: number): boolean {
|
|
return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
}
|
|
|
|
function refreshRetryDelayMs(attempt: number, baseMs: number): number {
|
|
const exp = baseMs * 2 ** attempt;
|
|
return Math.floor(exp * (0.8 + Math.random() * 0.4));
|
|
}
|
|
|
|
function decodeExpMs(token: string): number {
|
|
try {
|
|
const parts = token.split(".");
|
|
if (parts.length !== 3) return Date.now() + FALLBACK_TTL_MS;
|
|
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8")) as {
|
|
exp?: unknown;
|
|
};
|
|
if (typeof payload.exp === "number") return payload.exp * 1000 - EXPIRY_SKEW_MS;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return Date.now() + FALLBACK_TTL_MS;
|
|
}
|
|
|
|
export type RefreshCursorTokenOptions = {
|
|
retryBaseMs?: number;
|
|
attempts?: number;
|
|
};
|
|
|
|
/**
|
|
* @returns {{ accessToken, refreshToken, expiresAt } | { error, code } | null}
|
|
*/
|
|
export async function refreshCursorToken(
|
|
refreshToken: string,
|
|
log?: { error?: (...args: unknown[]) => void; info?: (...args: unknown[]) => void },
|
|
_proxyConfig: unknown = null,
|
|
options: RefreshCursorTokenOptions = {}
|
|
) {
|
|
if (!refreshToken) {
|
|
return { error: "unrecoverable_refresh_error", code: "no_refresh_token" };
|
|
}
|
|
|
|
const attempts = options.attempts ?? REFRESH_ATTEMPTS;
|
|
const retryBaseMs = options.retryBaseMs ?? REFRESH_RETRY_BASE_MS;
|
|
let lastError: unknown;
|
|
|
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(CURSOR_REFRESH_URL, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${refreshToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: "{}",
|
|
signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
|
|
});
|
|
} catch (err) {
|
|
lastError = err;
|
|
if (attempt === attempts - 1) break;
|
|
await new Promise((r) => setTimeout(r, refreshRetryDelayMs(attempt, retryBaseMs)));
|
|
continue;
|
|
}
|
|
|
|
if (response.ok) {
|
|
const data = (await response.json()) as { accessToken?: string; refreshToken?: string };
|
|
if (!data.accessToken) {
|
|
log?.error?.("TOKEN_REFRESH", "Cursor refresh response missing access token");
|
|
return null;
|
|
}
|
|
const nextRefresh = data.refreshToken || refreshToken;
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Cursor token");
|
|
return {
|
|
accessToken: data.accessToken,
|
|
refreshToken: nextRefresh,
|
|
expiresAt: new Date(decodeExpMs(data.accessToken)).toISOString(),
|
|
};
|
|
}
|
|
|
|
if (response.status === 401 || response.status === 403) {
|
|
log?.error?.("TOKEN_REFRESH", "Cursor refresh rejected — re-authentication required", {
|
|
status: response.status,
|
|
});
|
|
return { error: "unrecoverable_refresh_error", code: "unauthorized" };
|
|
}
|
|
|
|
if (!isRetryableRefreshStatus(response.status) || attempt === attempts - 1) {
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Cursor token", { status: response.status });
|
|
return null;
|
|
}
|
|
|
|
lastError = new Error(`Cursor token refresh failed: ${response.status}`);
|
|
await response.body?.cancel().catch(() => {});
|
|
await new Promise((r) => setTimeout(r, refreshRetryDelayMs(attempt, retryBaseMs)));
|
|
}
|
|
|
|
log?.error?.(
|
|
"TOKEN_REFRESH",
|
|
lastError instanceof Error ? lastError.message : "Cursor token refresh failed"
|
|
);
|
|
return null;
|
|
}
|