mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 03:02:14 +03:00
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.
357 lines
13 KiB
TypeScript
357 lines
13 KiB
TypeScript
import { access, constants, readFile } from "fs/promises";
|
|
import { homedir } from "os";
|
|
import { join } from "path";
|
|
import { execFile } from "child_process";
|
|
import { promisify } from "util";
|
|
import type { SqliteAdapter } from "@/lib/db/adapters/types";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
/**
|
|
* Probe dependencies for {@link verifyLinuxCursorInstalled}. Injectable so the
|
|
* guard can be unit-tested without spawning a real `which` process or touching
|
|
* the filesystem — mirrors the `__setExecFileImpl` pattern in
|
|
* `src/lib/cli-helper/tool-detector.ts`.
|
|
*/
|
|
export interface CursorInstallProbe {
|
|
/** Runs `which <binary>`; rejects when the binary is not on PATH. */
|
|
execFile?: (
|
|
file: string,
|
|
args: string[],
|
|
options: { timeout: number }
|
|
) => Promise<{ stdout: string; stderr: string }>;
|
|
/** Resolves when the path is readable; rejects otherwise (e.g. `fs.access`). */
|
|
access?: (path: string, mode: number) => Promise<void>;
|
|
/** Override the home directory used to locate the `.desktop` fallback. */
|
|
home?: string;
|
|
}
|
|
|
|
/**
|
|
* On Linux, verify that the Cursor IDE is actually installed before trusting
|
|
* leftover config files (state.vscdb). A removed Cursor install can leave its
|
|
* `~/.config/Cursor/...` directory behind, which would otherwise trigger a
|
|
* false-positive auto-import and create a phantom Cursor provider connection.
|
|
*
|
|
* The check prefers `which cursor` and falls back to a readable
|
|
* `~/.local/share/applications/cursor.desktop` entry (the desktop launcher a
|
|
* package install drops even when the CLI shim is not on PATH).
|
|
*
|
|
* Port of decolua/9router#313 — only the linux probe is added; macOS/Windows
|
|
* keep their existing behavior (no install probe).
|
|
*/
|
|
export async function verifyLinuxCursorInstalled(probe: CursorInstallProbe = {}): Promise<boolean> {
|
|
const exec = probe.execFile ?? execFileAsync;
|
|
const canAccess = probe.access ?? access;
|
|
const home = probe.home ?? homedir();
|
|
|
|
try {
|
|
await exec("which", ["cursor"], { timeout: 5000 });
|
|
return true;
|
|
} catch {
|
|
try {
|
|
const desktopFile = join(home, ".local/share/applications/cursor.desktop");
|
|
await canAccess(desktopFile, constants.R_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Known key names Cursor IDE has used over time to persist the auth token
|
|
* and machine id in the local `state.vscdb`. Order matters — the first
|
|
* exact match wins.
|
|
*/
|
|
const ACCESS_TOKEN_KEYS = ["cursorAuth/accessToken", "cursorAuth/token"] as const;
|
|
const MACHINE_ID_KEYS = [
|
|
"storage.serviceMachineId",
|
|
"storage.machineId",
|
|
"telemetry.machineId",
|
|
] as const;
|
|
|
|
/**
|
|
* Normalize a value read from Cursor's `state.vscdb`. Some entries are
|
|
* stored as JSON-encoded strings (e.g. `'"abc"'`) — unwrap one level when
|
|
* the decoded payload is itself a string. Anything else is returned as-is.
|
|
*/
|
|
export function normalizeVscDbValue<T>(value: T): T | string {
|
|
if (typeof value !== "string") return value;
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return typeof parsed === "string" ? parsed : value;
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
interface VscDbRow {
|
|
key: string;
|
|
value: string;
|
|
}
|
|
|
|
interface ExtractedCursorTokens {
|
|
accessToken?: string;
|
|
machineId?: string;
|
|
}
|
|
|
|
/**
|
|
* Pick the first matching access-token / machine-id from a set of rows.
|
|
* Pure function — easy to unit-test without a SQLite handle.
|
|
*/
|
|
export function extractCursorTokensFromRows(rows: VscDbRow[]): ExtractedCursorTokens {
|
|
const tokens: ExtractedCursorTokens = {};
|
|
for (const row of rows) {
|
|
if (!tokens.accessToken && (ACCESS_TOKEN_KEYS as readonly string[]).includes(row.key)) {
|
|
const v = normalizeVscDbValue(row.value);
|
|
if (typeof v === "string") tokens.accessToken = v;
|
|
} else if (!tokens.machineId && (MACHINE_ID_KEYS as readonly string[]).includes(row.key)) {
|
|
const v = normalizeVscDbValue(row.value);
|
|
if (typeof v === "string") tokens.machineId = v;
|
|
}
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
/**
|
|
* Fuzzy-match access-token / machine-id from any rows whose key vaguely
|
|
* resembles the expected pattern (e.g. `cursorAuth/someOtherAccessTokenKey`,
|
|
* `storage.someMachineId`). Used only when the exact-key lookup yielded
|
|
* nothing — guards against silent breakage when Cursor renames a key.
|
|
*/
|
|
export function fuzzyExtractCursorTokensFromRows(
|
|
rows: VscDbRow[],
|
|
existing: ExtractedCursorTokens = {}
|
|
): ExtractedCursorTokens {
|
|
const tokens: ExtractedCursorTokens = { ...existing };
|
|
for (const row of rows) {
|
|
const key = row.key || "";
|
|
const lower = key.toLowerCase();
|
|
const value = normalizeVscDbValue(row.value);
|
|
if (typeof value !== "string") continue;
|
|
if (!tokens.accessToken && lower.includes("accesstoken")) tokens.accessToken = value;
|
|
if (!tokens.machineId && lower.includes("machineid")) tokens.machineId = value;
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
/**
|
|
* Resolve the candidate state.vscdb paths to probe for a given platform.
|
|
* macOS now probes both the standard install and the Insiders channel
|
|
* (port: 9router#161 — fixes false "Cursor database not found" on Macs
|
|
* that only have Cursor Insiders installed).
|
|
*/
|
|
export function cursorDbCandidatePaths(
|
|
platform: NodeJS.Platform,
|
|
env: { home: string; appdata?: string }
|
|
): string[] {
|
|
if (platform === "darwin") {
|
|
return [
|
|
join(env.home, "Library/Application Support/Cursor/User/globalStorage/state.vscdb"),
|
|
join(
|
|
env.home,
|
|
"Library/Application Support/Cursor - Insiders/User/globalStorage/state.vscdb"
|
|
),
|
|
];
|
|
}
|
|
if (platform === "linux") {
|
|
return [join(env.home, ".config/Cursor/User/globalStorage/state.vscdb")];
|
|
}
|
|
if (platform === "win32") {
|
|
return [join(env.appdata || "", "Cursor/User/globalStorage/state.vscdb")];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Try to read credentials from cursor-agent's local auth state.
|
|
*
|
|
* Probes two known candidate locations, in order:
|
|
* 1. `~/.config/cursor/auth.json` — written by `cursor-agent` CLI after
|
|
* 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. 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. 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;
|
|
accessToken?: string;
|
|
source?: string;
|
|
error?: string;
|
|
}> {
|
|
const candidates = [
|
|
join(homedir(), ".config", "cursor", "auth.json"),
|
|
join(homedir(), ".cursor", "agent-cli-state.json"),
|
|
];
|
|
|
|
for (const authPath of candidates) {
|
|
try {
|
|
const raw = await readFile(authPath, "utf-8");
|
|
const auth = JSON.parse(raw);
|
|
if (auth.accessToken && typeof auth.accessToken === "string") {
|
|
return { found: true, accessToken: auth.accessToken, source: "cursor-agent" };
|
|
}
|
|
// Schema differs from what this candidate is expected to hold — fall
|
|
// through to the next candidate rather than treating it as found.
|
|
} catch {
|
|
// Not found or unreadable — continue probing the next candidate.
|
|
}
|
|
}
|
|
|
|
return { found: false, error: "cursor-agent auth.json not found" };
|
|
}
|
|
|
|
/**
|
|
* Try to read credentials from Cursor IDE's state.vscdb.
|
|
*
|
|
* On macOS this probes both `Cursor/` and `Cursor - Insiders/`, returns a
|
|
* descriptive error if the DB exists but cannot be opened (e.g. WAL lock
|
|
* because Cursor is currently running), tries multiple known key names,
|
|
* normalizes JSON-encoded string values, and falls back to a fuzzy LIKE
|
|
* lookup if exact keys are missing — guards against silent breakage when
|
|
* Cursor renames a key in a future release.
|
|
*
|
|
* Linux and Windows code paths are unchanged.
|
|
*/
|
|
export async function tryIdeAuth(): Promise<{
|
|
found: boolean;
|
|
accessToken?: string;
|
|
machineId?: string;
|
|
source?: string;
|
|
error?: string;
|
|
}> {
|
|
const platform = process.platform;
|
|
const candidates = cursorDbCandidatePaths(platform, {
|
|
home: homedir(),
|
|
appdata: process.env.APPDATA,
|
|
});
|
|
|
|
if (candidates.length === 0) {
|
|
return { found: false, error: "Unsupported platform" };
|
|
}
|
|
|
|
// Probe candidates (matters on macOS where there can be >1; on linux/win32
|
|
// there is exactly one and we skip the probe to preserve the original
|
|
// error message).
|
|
let dbPath: string | undefined;
|
|
if (platform === "darwin") {
|
|
for (const path of candidates) {
|
|
try {
|
|
await access(path, constants.R_OK);
|
|
dbPath = path;
|
|
break;
|
|
} catch {
|
|
// continue probing
|
|
}
|
|
}
|
|
if (!dbPath) {
|
|
return {
|
|
found: false,
|
|
error:
|
|
"Cursor database not found in known macOS locations. " +
|
|
"Make sure Cursor IDE is installed and opened at least once.",
|
|
};
|
|
}
|
|
} else {
|
|
// On Linux, verify Cursor is actually installed before trusting leftover
|
|
// config files — a removed install can leave ~/.config/Cursor behind and
|
|
// would otherwise create a phantom Cursor connection (port: 9router#313).
|
|
if (platform === "linux" && !(await verifyLinuxCursorInstalled())) {
|
|
return {
|
|
found: false,
|
|
error:
|
|
"Cursor config files found but Cursor IDE does not appear to be " +
|
|
"installed. Skipping auto-import.",
|
|
};
|
|
}
|
|
dbPath = candidates[0];
|
|
}
|
|
|
|
let db: SqliteAdapter | null;
|
|
try {
|
|
const { tryOpenSync } = await import("@/lib/db/adapters/driverFactory");
|
|
// Bounded busy-timeout: tryIdeAuth() is now also called from an unattended
|
|
// sweep tick (src/lib/cursor/renewal.ts::renewCursorConnection()) on every
|
|
// near-expiry cycle, not just the explicit auto-import modal action, so a
|
|
// WAL-lock collision with a running Cursor IDE needs a retry window on
|
|
// every driver path (see driverFactory.ts::toNodeSqliteOptions()).
|
|
db = tryOpenSync(dbPath, { readonly: true, fileMustExist: true, timeout: 2000 });
|
|
if (!db) {
|
|
if (platform === "darwin") {
|
|
return {
|
|
found: false,
|
|
error: `Found Cursor database at ${dbPath} but could not open it (driver unavailable)`,
|
|
};
|
|
}
|
|
return { found: false, error: "Cursor IDE database driver unavailable" };
|
|
}
|
|
} catch (error) {
|
|
if (platform === "darwin") {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
found: false,
|
|
error: `Found Cursor database at ${dbPath} but could not open it: ${message}`,
|
|
};
|
|
}
|
|
return { found: false, error: "Cursor IDE database not found" };
|
|
}
|
|
|
|
try {
|
|
const desiredKeys = [...ACCESS_TOKEN_KEYS, ...MACHINE_ID_KEYS];
|
|
const placeholders = desiredKeys.map(() => "?").join(",");
|
|
const rows = db
|
|
.prepare(`SELECT key, value FROM itemTable WHERE key IN (${placeholders})`)
|
|
.all(...desiredKeys) as VscDbRow[];
|
|
|
|
let tokens = extractCursorTokensFromRows(rows);
|
|
|
|
// Fuzzy fallback: only on macOS — original report (and observed schema
|
|
// drift) is on darwin; other platforms keep exact-key behavior.
|
|
if (platform === "darwin" && (!tokens.accessToken || !tokens.machineId)) {
|
|
const fallbackRows = db
|
|
.prepare(
|
|
"SELECT key, value FROM itemTable " +
|
|
"WHERE key LIKE '%cursorAuth/%' " +
|
|
"OR key LIKE '%machineId%' " +
|
|
"OR key LIKE '%serviceMachineId%'"
|
|
)
|
|
.all() as VscDbRow[];
|
|
tokens = fuzzyExtractCursorTokensFromRows(fallbackRows, tokens);
|
|
}
|
|
|
|
db.close();
|
|
|
|
if (!tokens.accessToken) {
|
|
return { found: false, error: "Tokens not found in database" };
|
|
}
|
|
|
|
return {
|
|
found: true,
|
|
accessToken: tokens.accessToken,
|
|
machineId: tokens.machineId,
|
|
source: "cursor-ide",
|
|
};
|
|
} catch (error) {
|
|
db?.close();
|
|
console.error("Failed to read Cursor IDE database:", error);
|
|
return { found: false, error: "Failed to read database" };
|
|
}
|
|
}
|