diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/CursorAgentNudge.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/CursorAgentNudge.test.tsx index acf3685116..650f3695b2 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/CursorAgentNudge.test.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/CursorAgentNudge.test.tsx @@ -7,6 +7,26 @@ * is the precedent this component follows), and this directory's own * __tests__/phase1d.test.tsx for the createRoot/act mounting convention. */ +// NOTE ON WHICH CONFIG DISCOVERS THIS FILE (kept as a `//` block — see why +// below): unlike tests/unit/ui/use-provider-connections-cursor-refresh.test.tsx +// and tests/unit/ui/connectionsSearchFilter.test.tsx (which had to relocate +// out of their __tests__/ directories because vitest.mcp.config.ts's +// src/app/(dashboard)/**/__tests__/**/*.test.tsx glob spells out the +// (dashboard) path segment literally, which tinyglobby parses as an (empty) +// extglob group — matching nothing), THIS file's location IS correct per the +// plan: vitest.config.ts's glob (src/app/**/dashboard/providers/**/__tests__/ +// **/*.test.tsx) never spells out (dashboard) literally — its ** wildcard +// swallows that segment regardless of its literal name — so it matches here +// without hitting the same bug. That means this file is collected only by +// vitest.config.ts (`npm run test:vitest:ui`), NOT by vitest.mcp.config.ts +// (`npm run test:vitest`) — confirmed via a direct `vitest list` probe +// against both configs. Both jobs are CI-blocking (`test:vitest:ui` was +// promoted from advisory to blocking per ci.yml's own comment, PR #7127), +// so this is a coverage-attribution quirk, not a real CI gap — this file +// still runs and gates merges, just via the sibling config. +// (This note is a `//` block, not part of the /** */ JSDoc above, because +// the glob patterns it quotes contain a literal `*/` sequence that would +// otherwise terminate a block comment early.) import React from "react"; import { act } from "react"; import { createRoot, hydrateRoot } from "react-dom/client"; diff --git a/src/app/api/providers/[id]/refresh-cursor/route.ts b/src/app/api/providers/[id]/refresh-cursor/route.ts index 9d47210f59..8f6c2895cc 100644 --- a/src/app/api/providers/[id]/refresh-cursor/route.ts +++ b/src/app/api/providers/[id]/refresh-cursor/route.ts @@ -28,6 +28,17 @@ const MANUAL_REFRESH_COOLDOWN_MS = 30_000; */ const lastManualRefreshAttemptAt = new Map(); +/** Opportunistic eviction — no separate timer needed since this route is + * already called on every manual-refresh click; keeps the map from growing + * unbounded across the lifetime of the process as connections are added/removed. */ +function evictExpiredManualRefreshAttempts(now: number): void { + for (const [connectionId, attemptedAt] of lastManualRefreshAttemptAt) { + if (now - attemptedAt >= MANUAL_REFRESH_COOLDOWN_MS) { + lastManualRefreshAttemptAt.delete(connectionId); + } + } +} + /** * POST /api/providers/[id]/refresh-cursor * Manually trigger a Cursor session renewal attempt (nudge `cursor-agent`, @@ -57,8 +68,11 @@ export async function POST(_request: Request, { params }: { params: Promise<{ id ); } + const now = Date.now(); + evictExpiredManualRefreshAttempts(now); + const lastAttempt = lastManualRefreshAttemptAt.get(connection.id) ?? 0; - const elapsedMs = Date.now() - lastAttempt; + const elapsedMs = now - lastAttempt; if (elapsedMs < MANUAL_REFRESH_COOLDOWN_MS) { const retryAfterMs = MANUAL_REFRESH_COOLDOWN_MS - elapsedMs; return NextResponse.json( @@ -74,7 +88,7 @@ export async function POST(_request: Request, { params }: { params: Promise<{ id } // Set immediately before invoking renewCursorConnection() — regardless of // outcome — so rapid repeated clicks are throttled either way. - lastManualRefreshAttemptAt.set(connection.id, Date.now()); + lastManualRefreshAttemptAt.set(connection.id, now); return await runCursorRenewalExclusive(connection.id, async () => { const result = await renewCursorConnection({ @@ -82,35 +96,44 @@ export async function POST(_request: Request, { params }: { params: Promise<{ id machineId: connection.providerSpecificData?.machineId as string | null | undefined, }); - if (result.status === "renewed") { - const now = new Date().toISOString(); - const update = buildCursorRenewedUpdate(connection, result, now); - await updateProviderConnection(connection.id, update); - return NextResponse.json({ - success: true, - connectionId: connection.id, - provider: "cursor", - expiresAt: update.expiresAt as string, - refreshedAt: now, - }); + switch (result.status) { + case "renewed": { + const nowIso = new Date().toISOString(); + const update = buildCursorRenewedUpdate(connection, result, nowIso); + await updateProviderConnection(connection.id, update); + return NextResponse.json({ + success: true, + connectionId: connection.id, + provider: "cursor", + expiresAt: update.expiresAt as string, + refreshedAt: nowIso, + }); + } + case "unchanged": { + return NextResponse.json({ + success: true, + unchanged: true, + connectionId: connection.id, + provider: "cursor", + expiresAt: connection.expiresAt ?? null, + refreshedAt: new Date().toISOString(), + message: "Cursor session is already current — no newer token found on this host.", + }); + } + case "error": { + return NextResponse.json( + { + error: "Token refresh failed — provider returned no new token", + details: result.error, + }, + { status: 502 } + ); + } + default: { + const _exhaustive: never = result; + throw new Error(`Unhandled CursorRenewalResult status: ${JSON.stringify(_exhaustive)}`); + } } - - if (result.status === "unchanged") { - return NextResponse.json({ - success: true, - unchanged: true, - connectionId: connection.id, - provider: "cursor", - expiresAt: connection.expiresAt ?? null, - refreshedAt: new Date().toISOString(), - message: "Cursor session is already current — no newer token found on this host.", - }); - } - - return NextResponse.json( - { error: "Token refresh failed — provider returned no new token", details: result.error }, - { status: 502 } - ); }); } catch (error) { return NextResponse.json( diff --git a/src/app/api/providers/cursor/agent-availability/route.ts b/src/app/api/providers/cursor/agent-availability/route.ts index bac93446f1..5c7f8ef40d 100644 --- a/src/app/api/providers/cursor/agent-availability/route.ts +++ b/src/app/api/providers/cursor/agent-availability/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { getCachedCursorAgentAvailability } from "@/lib/cursor/renewal"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; /** * GET /api/providers/cursor/agent-availability @@ -19,13 +20,29 @@ import { getCachedCursorAgentAvailability } from "@/lib/cursor/renewal"; * LOCAL_ONLY (see `LOCAL_ONLY_API_PREFIXES` in * `src/server/authz/routeGuard.ts`), so `managementPolicy` already enforces * auth + loopback before this handler runs, matching the sibling - * `/api/providers/[id]/refresh` and `/api/providers/[id]/login` routes - * (neither perform their own in-route auth check either). + * `/api/providers/[id]/refresh` route (also no in-route auth check). The + * other sibling, `/api/providers/[id]/login`, does call + * `requireManagementAuth()` itself — redundant given `managementPolicy`'s + * enforcement, but not incorrect. * * 🔒 LOCAL_ONLY — spawns `cursor-agent status --format json` via * `checkCursorAgentAvailability()` (Hard Rules #15 + #17). */ export async function GET() { - const { available } = await getCachedCursorAgentAvailability(); - return NextResponse.json({ cursorAgentAvailable: available }); + try { + const { available } = await getCachedCursorAgentAvailability(); + return NextResponse.json({ cursorAgentAvailable: available }); + } catch (error) { + // checkCursorAgentAvailability() currently swallows all realistic errors + // internally (spawn failures, unparseable output) — this catch is + // defense-in-depth consistency with the rest of this plan's routes, not + // a currently-reachable path. + return NextResponse.json( + { + cursorAgentAvailable: false, + error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + }, + { status: 500 } + ); + } } diff --git a/src/lib/cursor/renewal.ts b/src/lib/cursor/renewal.ts index 5338cf7938..8d4aa2dab9 100644 --- a/src/lib/cursor/renewal.ts +++ b/src/lib/cursor/renewal.ts @@ -1,3 +1,4 @@ +import { homedir } from "os"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { createKeyedMutex } from "@/shared/utils/keyedMutex"; import { resolveCursorAgentBinary, runCursorAgent } from "@/lib/providerModels/cursorAgent"; @@ -96,10 +97,15 @@ export async function checkCursorAgentAvailability(): Promise<{ return { available: parsed.isAuthenticated === true, binaryPath: binary }; } catch { // Unparseable/empty output (e.g. an older CLI release predating - // `--format json` support on `status`). Fail closed: absent a - // parseable, positive confirmation, treat as unavailable rather than - // risk nudging an unconfirmed session. - return { available: false, binaryPath: binary }; + // `--format json` support on `status`). Fall back to the same legacy + // auth-detection convention `cursorAgent.ts` already uses for + // `--list-models`/`--model --help`: absent an explicit "not + // authenticated" signal in stdout/stderr, treat the binary as available. + const combined = `${result.stdout}\n${result.stderr}`; + if (/Authentication required|Not logged in/i.test(combined)) { + return { available: false, binaryPath: binary }; + } + return { available: true, binaryPath: binary }; } } @@ -131,6 +137,35 @@ export async function getCachedCursorAgentAvailability(): Promise<{ return result; } +const IDE_AUTH_DEDUP_TTL_MS = 5_000; + +let cachedIdeAuthCall: { + home: string; + promise: ReturnType; + expiresAt: number; +} | null = null; + +/** + * Collapses duplicate tryIdeAuth() calls — each of which opens and queries + * the host's Cursor state.vscdb — across multiple Cursor connections that + * become due for renewal in the same sweep tick. Mirrors + * getCachedCursorAgentAvailability()'s short-TTL cache pattern above; keyed + * by homedir() since that determines which physical file(s) tryIdeAuth() + * would probe. Only wraps the REAL tryIdeAuth() — renewCursorConnection()'s + * `deps.tryIdeAuth` test override bypasses this cache entirely (a test mock + * isn't reading a real shared file, so there is nothing to dedup). + */ +function dedupedTryIdeAuth(): ReturnType { + const home = homedir(); + const now = Date.now(); + if (cachedIdeAuthCall && cachedIdeAuthCall.home === home && cachedIdeAuthCall.expiresAt > now) { + return cachedIdeAuthCall.promise; + } + const promise = tryIdeAuth(); + cachedIdeAuthCall = { home, promise, expiresAt: now + IDE_AUTH_DEDUP_TTL_MS }; + return promise; +} + export type CursorRenewalResult = | { status: "renewed"; @@ -175,7 +210,7 @@ export async function renewCursorConnection( checkCursorAgentAvailability?: typeof checkCursorAgentAvailability; } ): Promise { - const resolveIdeAuth = deps?.tryIdeAuth ?? tryIdeAuth; + const resolveIdeAuth = deps?.tryIdeAuth ?? dedupedTryIdeAuth; const resolveAgentAuth = deps?.tryAgentAuth ?? tryAgentAuth; const resolveAvailability = deps?.checkCursorAgentAvailability ?? checkCursorAgentAvailability; diff --git a/src/lib/cursor/tokenExtractor.ts b/src/lib/cursor/tokenExtractor.ts index e37e91bb5c..c21c77d533 100644 --- a/src/lib/cursor/tokenExtractor.ts +++ b/src/lib/cursor/tokenExtractor.ts @@ -171,16 +171,24 @@ export function cursorDbCandidatePaths( * login (the official curl-installer convention). * 2. `~/.cursor/agent-cli-state.json` — a second candidate this codebase's * own `src/shared/services/cliRuntime.ts` (`CLI_TOOLS.cursor.paths.state`) - * already lists but did not previously probe for auth. Its schema is - * UNVERIFIED against a real authenticated install; if it lacks a usable - * `accessToken` string field, this candidate is skipped gracefully. + * already lists but did not previously probe for auth. If it lacks a + * usable `accessToken` string field, this candidate is skipped + * gracefully. * * KNOWN LIMITATION: some `cursor-agent` releases may store the access/refresh * token in the OS keychain instead of a locally-readable file. When neither * candidate above yields a token, this function correctly reports * `{found: false}` even if `cursor-agent status` reports the CLI as * authenticated — this is a documented, accepted gap (see the renewal plan's - * "Trade-offs Accepted" section), not a silent bug. + * "Trade-offs Accepted" section), not a silent bug. Confirmed, not just + * hypothetical: empirically validated against a real, authenticated + * `cursor-agent` install (v2026.07.23, Homebrew Cask `cursor-cli`) on + * 2026-07-31 — that install's `~/.cursor/agent-cli-state.json` exists but its + * actual schema is `{version, hasShownAgentCommandTip, + * hasClearedLegacyStatsigFields}`, with no `accessToken` field at all, while + * `cursor-agent status --format json` reported `isAuthenticated: true`. This + * candidate is correctly skipped for that install; the graceful-degradation + * fallback below is confirmed correct, not a gap in this specific case. */ export async function tryAgentAuth(): Promise<{ found: boolean; diff --git a/src/lib/tokenHealthCheckCursor.ts b/src/lib/tokenHealthCheckCursor.ts index 3bfb9d234c..ba9d799f72 100644 --- a/src/lib/tokenHealthCheckCursor.ts +++ b/src/lib/tokenHealthCheckCursor.ts @@ -27,37 +27,63 @@ export async function checkCursorConnectionIfNeeded(params: { logError: (message: string, ...args: any[]) => void; getConnectionLogLabel: (conn: { name?: string; email?: string; id?: string }) => string; logPrefix: string; + /** Testability seam forwarded verbatim to renewCursorConnection() — mirrors + * the deps param Task 2 added there, so tests can force a {status:"error"} + * result without a mock.module() shim. */ + deps?: Parameters[1]; }): Promise { - const { conn, now, buildRefreshFailureUpdate, log, logWarn, getConnectionLogLabel, logPrefix } = - params; + const { + conn, + now, + buildRefreshFailureUpdate, + log, + logWarn, + logError, + getConnectionLogLabel, + logPrefix, + deps, + } = params; await runCursorRenewalExclusive(conn.id, async () => { - const result = await renewCursorConnection({ - accessToken: conn.accessToken, - machineId: conn.providerSpecificData?.machineId ?? null, - }); - - if (result.status === "renewed") { - await updateProviderConnection(conn.id, buildCursorRenewedUpdate(conn, result, now)); - log( - `${logPrefix} ✓ Cursor session renewed for ${getConnectionLogLabel(conn)} (source: ${result.source})` - ); - return; - } - - const message = - result.status === "error" - ? `Cursor session renewal failed: ${result.error}` - : "Cursor session unchanged — no newer token found on this host."; - await updateProviderConnection( - conn.id, - buildRefreshFailureUpdate(conn, now, { - errorCode: "cursor_session_stale", - lastErrorType: "cursor_session_stale", - lastError: message, - testStatus: "active", - }) + const result = await renewCursorConnection( + { + accessToken: conn.accessToken, + machineId: conn.providerSpecificData?.machineId ?? null, + }, + deps ); - logWarn(`${logPrefix} ✗ Cursor session stale for ${getConnectionLogLabel(conn)}: ${message}`); + + switch (result.status) { + case "renewed": { + await updateProviderConnection(conn.id, buildCursorRenewedUpdate(conn, result, now)); + log( + `${logPrefix} ✓ Cursor session renewed for ${getConnectionLogLabel(conn)} (source: ${result.source})` + ); + return; + } + case "unchanged": + case "error": { + const message = + result.status === "error" + ? `Cursor session renewal failed: ${result.error}` + : "Cursor session unchanged — no newer token found on this host."; + await updateProviderConnection( + conn.id, + buildRefreshFailureUpdate(conn, now, { + errorCode: "cursor_session_stale", + lastErrorType: "cursor_session_stale", + lastError: message, + testStatus: "active", + }) + ); + const logFn = result.status === "error" ? logError : logWarn; + logFn(`${logPrefix} ✗ Cursor session stale for ${getConnectionLogLabel(conn)}: ${message}`); + return; + } + default: { + const _exhaustive: never = result; + throw new Error(`Unhandled CursorRenewalResult status: ${JSON.stringify(_exhaustive)}`); + } + } }); } diff --git a/tests/unit/cursor-renewal.test.ts b/tests/unit/cursor-renewal.test.ts index 0d206826c1..fcb480bca1 100644 --- a/tests/unit/cursor-renewal.test.ts +++ b/tests/unit/cursor-renewal.test.ts @@ -76,6 +76,8 @@ if (process.env.FAKE_CURSOR_AGENT_HANG === "1") { process.stdout.write(JSON.stringify({ status: "unauthenticated", isAuthenticated: false })); } else if (mode === "garbage") { process.stdout.write("not json output at all"); + } else if (mode === "legacy-unauthenticated") { + process.stderr.write("Error: Not logged in. Run 'cursor-agent login' to authenticate."); } } `; @@ -195,9 +197,20 @@ describe("checkCursorAgentAvailability", () => { assert.equal(result.binaryPath, binaryPath); }); - it("reports available:false (does not throw) on unparseable/legacy-style stdout", async () => { + it("reports available:true (legacy-authenticated) on unparseable stdout with no auth-required signal", async () => { + // Mirrors fetchCursorAgentModels()'s legacy fallback convention in + // cursorAgent.ts: an older CLI release predating `--format json` support + // on `status` still produces some non-JSON output, but absent an + // explicit "not authenticated" signal, the binary is treated as available. process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "garbage"; const result = await checkCursorAgentAvailability(); + assert.equal(result.available, true); + assert.equal(result.binaryPath, binaryPath); + }); + + it("reports available:false (legacy-unauthenticated) when unparseable stdout/stderr matches the auth-required pattern", async () => { + process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "legacy-unauthenticated"; + const result = await checkCursorAgentAvailability(); assert.equal(result.available, false); assert.equal(result.binaryPath, binaryPath); }); @@ -380,6 +393,20 @@ describe("renewCursorConnection", () => { fs.writeFileSync(path.join(authDir, "auth.json"), JSON.stringify({ accessToken })); } + async function updateIdeToken(accessToken: string): Promise { + const { openDatabaseAsync } = await import("@/lib/db/adapters/driverFactory"); + const dbPath = path.join( + tmpHome, + "Library/Application Support/Cursor/User/globalStorage/state.vscdb" + ); + const db = await openDatabaseAsync(dbPath); + db.prepare("INSERT OR REPLACE INTO itemTable (key, value) VALUES (?, ?)").run( + "cursorAuth/accessToken", + accessToken + ); + db.close(); + } + it("(a) cursor-agent unavailable + IDE re-scrape finds a new token -> renewed via cursor-ide", async () => { process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "unauthenticated"; // cursor-agent "unavailable" await writeIdeToken("new-ide-token", "machine-1"); @@ -520,6 +547,52 @@ describe("renewCursorConnection", () => { source: "cursor-ide", }); }); + + it("(g) dedupes tryIdeAuth() across near-simultaneous calls (PERF-001): a stale cached result is served within the TTL, then a fresh one after it expires", async (t) => { + // Simulates multiple Cursor connections becoming due for renewal in the + // same sweep tick: renewCursorConnection() is called back-to-back for + // the SAME host, so the second call must reuse the first's in-flight/ + // recently-resolved tryIdeAuth() result rather than re-opening + // state.vscdb — this is a resource-usage guard (PERF-001), not a + // correctness fix. Uses fake timers on `Date` (same technique as + // getCachedCursorAgentAvailability's TTL test) since renewal.ts's dedup + // cache is keyed on Date.now(), not a mockable timer/interval. + t.mock.timers.enable({ apis: ["Date"] }); + process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "unauthenticated"; // skip the nudge; isolate the IDE-cache behavior + + await writeIdeToken("token-A", "machine-a"); + + const first = await renewCursorConnection({ accessToken: "old-token" }); + assert.deepEqual(first, { + status: "renewed", + accessToken: "token-A", + machineId: "machine-a", + source: "cursor-ide", + }); + + // Underlying file now has a NEW token, but a second call within the TTL + // must still observe the CACHED "token-A" — proven by comparing against + // current.accessToken: "token-A" (the first result) reads as unchanged + // only if the cache is actually being served instead of a fresh re-scrape. + await updateIdeToken("token-B"); + t.mock.timers.tick(2000); // well within the 5s dedup TTL + const second = await renewCursorConnection({ accessToken: "token-A" }); + assert.deepEqual( + second, + { status: "unchanged" }, + "expected the cached (stale) tryIdeAuth() result, not a fresh state.vscdb read" + ); + + // Past the TTL, the next call must re-open the file and observe "token-B". + t.mock.timers.tick(4000); // cumulative 6s, past the 5s TTL + const third = await renewCursorConnection({ accessToken: "token-A" }); + assert.deepEqual(third, { + status: "renewed", + accessToken: "token-B", + machineId: "machine-a", + source: "cursor-ide", + }); + }); }); describe("buildCursorRenewedUpdate", () => { diff --git a/tests/unit/token-health-check-cursor.test.ts b/tests/unit/token-health-check-cursor.test.ts index a5a5d304a4..75eb4066d1 100644 --- a/tests/unit/token-health-check-cursor.test.ts +++ b/tests/unit/token-health-check-cursor.test.ts @@ -33,6 +33,7 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const tokenHealthCheck = await import("../../src/lib/tokenHealthCheck.ts"); +const tokenHealthCheckCursor = await import("../../src/lib/tokenHealthCheckCursor.ts"); async function resetStorage() { core.resetDbInstance(); @@ -271,28 +272,66 @@ test('checkConnection: Cursor "unchanged" result marks cursor_session_stale, sta }); }); -test( - 'checkConnection: Cursor "error" result -> DB update shape', - { - skip: - "Same root-cause testability gap as tests/unit/cursor-renewal.test.ts's original " + - "case (d), one level up: checkCursorConnectionIfNeeded() (src/lib/tokenHealthCheckCursor.ts) " + - "calls the real renewCursorConnection() with NO deps override, so there is no way to force " + - 'it to return {status:"error"} from here (tryIdeAuth()/tryAgentAuth() never throw for real — ' + - "verified in Task 1/2's tests — and this harness has no mock.module() support). " + - "renewCursorConnection()'s OWN error-mapping (sanitizeErrorMessage wiring) is already " + - "covered directly in tests/unit/cursor-renewal.test.ts's case (d), using its deps seam. " + - "The remaining untested surface is narrowly this file's message-building line " + - "(`Cursor session renewal failed: ${result.error}`) plus the cursor_session_stale/testStatus:" + - '"active" override — both of which ARE exercised by the "unchanged" test above via the ' + - "identical buildRefreshFailureUpdate call site (only the interpolated message text differs). " + - "Flagged to the team lead/reviewer: forwarding an optional deps param from " + - "checkCursorConnectionIfNeeded() through to its internal renewCursorConnection() call " + - "(mirroring the seam already added to renewCursorConnection() itself for Task 2) would close " + - "this specific gap with a small, additive change.", - }, - async () => {} -); +test('checkCursorConnectionIfNeeded: Cursor "error" result -> DB update shape (via the deps testability seam)', async () => { + await resetStorage(); + const id = await createCursorConnection({ + accessToken: "old-token", + tokenExpiresAt: NEAR_EXPIRY_ISO, + expiresAt: NEAR_EXPIRY_ISO, + }); + + // Closes the gap the skipped test above used to document: checkCursorConnectionIfNeeded() + // now forwards an optional `deps` param straight through to renewCursorConnection() (mirroring + // the seam Task 2 added there), so this can force a {status:"error"} result directly instead + // of going through tokenHealthCheck.checkConnection(), which has no deps param of its own. + const rawMessage = + "Failed to read Cursor IDE database at " + + "/Users/secret-user/project/src/lib/cursor/tokenExtractor.ts:284:15 - permission denied"; + const throwingTryIdeAuth = async (): Promise => { + throw new Error(rawMessage); + }; + const throwingTryAgentAuth = async (): Promise => { + throw new Error(rawMessage); + }; + + const errors: string[] = []; + const warnings: string[] = []; + const now = new Date().toISOString(); + + await tokenHealthCheckCursor.checkCursorConnectionIfNeeded({ + conn: await freshConn(id), + now, + buildRefreshFailureUpdate: tokenHealthCheck.buildRefreshFailureUpdate, + log: () => {}, + logWarn: (message: string) => warnings.push(message), + logError: (message: string) => errors.push(message), + getConnectionLogLabel: (c) => String(c.email ?? c.id ?? "unknown"), + logPrefix: "[test]", + deps: { + tryIdeAuth: throwingTryIdeAuth, + tryAgentAuth: throwingTryAgentAuth, + checkCursorAgentAvailability: async () => ({ available: false, binaryPath: null }), + }, + }); + + const updated = await freshConn(id); + assert.equal(updated.errorCode, "cursor_session_stale"); + assert.equal(updated.lastErrorType, "cursor_session_stale"); + assert.match(updated.lastError as string, /Cursor session renewal failed:/); + assert.equal( + updated.testStatus, + "active", + "must NOT be terminal — future sweeps must keep retrying" + ); + assert.ok(updated.lastHealthCheckAt); + + assert.equal(errors.length, 1, "the error branch must log via logError, not logWarn"); + assert.equal(warnings.length, 0); + assert.ok( + !errors[0].includes("/Users/secret-user"), + `raw absolute path must not survive sanitization, got: ${errors[0]}` + ); +}); // ============================================================================ // Step 1: buildRefreshFailureUpdate's overrides param — DB-shape-adjacent proof