mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 03:02:14 +03:00
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.
This commit is contained in:
committed by
diegosouzapw
parent
6448f02b33
commit
41121078ab
@@ -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";
|
||||
|
||||
@@ -28,6 +28,17 @@ const MANUAL_REFRESH_COOLDOWN_MS = 30_000;
|
||||
*/
|
||||
const lastManualRefreshAttemptAt = new Map<string, number>();
|
||||
|
||||
/** 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(
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof tryIdeAuth>;
|
||||
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<typeof tryIdeAuth> {
|
||||
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<CursorRenewalResult> {
|
||||
const resolveIdeAuth = deps?.tryIdeAuth ?? tryIdeAuth;
|
||||
const resolveIdeAuth = deps?.tryIdeAuth ?? dedupedTryIdeAuth;
|
||||
const resolveAgentAuth = deps?.tryAgentAuth ?? tryAgentAuth;
|
||||
const resolveAvailability = deps?.checkCursorAgentAvailability ?? checkCursorAgentAvailability;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof renewCursorConnection>[1];
|
||||
}): Promise<void> {
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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", () => {
|
||||
|
||||
@@ -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<never> => {
|
||||
throw new Error(rawMessage);
|
||||
};
|
||||
const throwingTryAgentAuth = async (): Promise<never> => {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user