Files
OmniRoute/src/lib/cursor/tokenExtractor.ts
Will Gordon 41121078ab 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.
2026-08-04 11:43:55 -03:00

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" };
}
}