From df6f40146584843966bdb315d511f87a4bdc882f Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 31 Jul 2026 09:22:22 -0400 Subject: [PATCH] refactor(cursor): extracts token extraction into shared lib Moves tryIdeAuth/tryAgentAuth and supporting helpers out of the auto-import route into src/lib/cursor/tokenExtractor.ts, and adds an agent-cli-state.json fallback candidate path to tryAgentAuth (alongside the existing auth.json candidate) so the extraction logic can be reused by the upcoming renewal orchestrator. --- src/app/api/oauth/cursor/auto-import/route.ts | 321 +------------- src/lib/cursor/tokenExtractor.ts | 343 +++++++++++++++ tests/unit/cursor-token-extractor.test.ts | 407 ++++++++++++++++++ tests/unit/oauth-cursor-auto-import.test.ts | 205 --------- 4 files changed, 751 insertions(+), 525 deletions(-) create mode 100644 src/lib/cursor/tokenExtractor.ts create mode 100644 tests/unit/cursor-token-extractor.test.ts delete mode 100644 tests/unit/oauth-cursor-auto-import.test.ts diff --git a/src/app/api/oauth/cursor/auto-import/route.ts b/src/app/api/oauth/cursor/auto-import/route.ts index 85f9c8baa8..ee0ee963f7 100755 --- a/src/app/api/oauth/cursor/auto-import/route.ts +++ b/src/app/api/oauth/cursor/auto-import/route.ts @@ -1,325 +1,6 @@ import { NextResponse } from "next/server"; -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 { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; - -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 `; 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; - /** 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 { - 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(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 auth.json - * (written by `cursor-agent` CLI after login). - */ -async function tryAgentAuth(): Promise<{ - found: boolean; - accessToken?: string; - source?: string; - error?: string; -}> { - try { - const authPath = join(homedir(), ".config", "cursor", "auth.json"); - 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" }; - } - return { found: false, error: "cursor-agent auth.json has no accessToken" }; - } catch { - 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. - */ -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; - try { - const { tryOpenSync } = await import("@/lib/db/adapters/driverFactory"); - db = tryOpenSync(dbPath, { readonly: true, fileMustExist: true }); - 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" }; - } -} +import { tryAgentAuth, tryIdeAuth } from "@/lib/cursor/tokenExtractor"; /** * GET /api/oauth/cursor/auto-import diff --git a/src/lib/cursor/tokenExtractor.ts b/src/lib/cursor/tokenExtractor.ts new file mode 100644 index 0000000000..9b03ca6880 --- /dev/null +++ b/src/lib/cursor/tokenExtractor.ts @@ -0,0 +1,343 @@ +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 `; 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; + /** 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 { + 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(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. Its schema is + * UNVERIFIED against a real authenticated install; 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. + */ +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"); + db = tryOpenSync(dbPath, { readonly: true, fileMustExist: true }); + 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" }; + } +} diff --git a/tests/unit/cursor-token-extractor.test.ts b/tests/unit/cursor-token-extractor.test.ts new file mode 100644 index 0000000000..8619541478 --- /dev/null +++ b/tests/unit/cursor-token-extractor.test.ts @@ -0,0 +1,407 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + normalizeVscDbValue, + extractCursorTokensFromRows, + fuzzyExtractCursorTokensFromRows, + cursorDbCandidatePaths, + verifyLinuxCursorInstalled, + tryAgentAuth, + tryIdeAuth, +} from "@/lib/cursor/tokenExtractor"; + +describe("normalizeVscDbValue", () => { + it("unwraps a JSON-encoded string", () => { + assert.equal(normalizeVscDbValue('"abc"'), "abc"); + }); + + it("returns the raw string when JSON parse fails", () => { + assert.equal(normalizeVscDbValue("not-json"), "not-json"); + }); + + it("returns the raw string when JSON parses to non-string", () => { + assert.equal(normalizeVscDbValue("123"), "123"); + assert.equal(normalizeVscDbValue("{}"), "{}"); + }); + + it("passes non-strings through unchanged", () => { + assert.equal(normalizeVscDbValue(42 as unknown as string), 42); + assert.equal(normalizeVscDbValue(null as unknown as string), null); + }); +}); + +describe("extractCursorTokensFromRows", () => { + it("extracts tokens using exact primary keys", () => { + const tokens = extractCursorTokensFromRows([ + { key: "cursorAuth/accessToken", value: "tok-1" }, + { key: "storage.serviceMachineId", value: "machine-1" }, + ]); + assert.equal(tokens.accessToken, "tok-1"); + assert.equal(tokens.machineId, "machine-1"); + }); + + it("accepts the alternative `cursorAuth/token` key", () => { + const tokens = extractCursorTokensFromRows([ + { key: "cursorAuth/token", value: "tok-2" }, + { key: "storage.machineId", value: "machine-2" }, + ]); + assert.equal(tokens.accessToken, "tok-2"); + assert.equal(tokens.machineId, "machine-2"); + }); + + it("accepts the alternative `telemetry.machineId` key", () => { + const tokens = extractCursorTokensFromRows([ + { key: "cursorAuth/accessToken", value: "tok-3" }, + { key: "telemetry.machineId", value: "machine-3" }, + ]); + assert.equal(tokens.machineId, "machine-3"); + }); + + it("prefers the first match and ignores duplicates", () => { + const tokens = extractCursorTokensFromRows([ + { key: "cursorAuth/accessToken", value: "first" }, + { key: "cursorAuth/token", value: "second" }, + ]); + assert.equal(tokens.accessToken, "first"); + }); + + it("normalizes JSON-encoded values", () => { + const tokens = extractCursorTokensFromRows([ + { key: "cursorAuth/accessToken", value: '"json-token"' }, + { key: "storage.serviceMachineId", value: '"json-machine"' }, + ]); + assert.equal(tokens.accessToken, "json-token"); + assert.equal(tokens.machineId, "json-machine"); + }); + + it("returns empty on no matches", () => { + const tokens = extractCursorTokensFromRows([{ key: "irrelevant", value: "x" }]); + assert.equal(tokens.accessToken, undefined); + assert.equal(tokens.machineId, undefined); + }); +}); + +describe("fuzzyExtractCursorTokensFromRows", () => { + it("matches keys by substring containing `accesstoken` and `machineid`", () => { + const tokens = fuzzyExtractCursorTokensFromRows([ + { key: "cursorAuth/someOtherAccessTokenKey", value: "fallback-token" }, + { key: "storage.someMachineId", value: "fallback-machine" }, + ]); + assert.equal(tokens.accessToken, "fallback-token"); + assert.equal(tokens.machineId, "fallback-machine"); + }); + + it("preserves already-found tokens (passes existing through)", () => { + const tokens = fuzzyExtractCursorTokensFromRows( + [ + { key: "cursorAuth/someOtherAccessTokenKey", value: "fallback-token" }, + { key: "storage.someMachineId", value: "fallback-machine" }, + ], + { accessToken: "already-have-it" } + ); + assert.equal(tokens.accessToken, "already-have-it"); + assert.equal(tokens.machineId, "fallback-machine"); + }); + + it("is case-insensitive on the key match", () => { + const tokens = fuzzyExtractCursorTokensFromRows([ + { key: "Some.ACCESSTOKEN.suffix", value: "tok" }, + { key: "Some.MACHINEID.suffix", value: "mid" }, + ]); + assert.equal(tokens.accessToken, "tok"); + assert.equal(tokens.machineId, "mid"); + }); +}); + +describe("cursorDbCandidatePaths", () => { + it("returns standard + Insiders paths on macOS", () => { + const paths = cursorDbCandidatePaths("darwin", { home: "/Users/test" }); + assert.equal(paths.length, 2); + assert.ok(paths[0].includes("Cursor/User/globalStorage/state.vscdb")); + assert.ok(paths[1].includes("Cursor - Insiders/User/globalStorage/state.vscdb")); + }); + + it("returns a single path on Linux", () => { + const paths = cursorDbCandidatePaths("linux", { home: "/home/test" }); + assert.deepEqual(paths, ["/home/test/.config/Cursor/User/globalStorage/state.vscdb"]); + }); + + it("returns a single path on Windows using APPDATA", () => { + const paths = cursorDbCandidatePaths("win32", { + home: "C:/Users/test", + appdata: "C:/Users/test/AppData/Roaming", + }); + assert.equal(paths.length, 1); + assert.ok(paths[0].includes("Cursor/User/globalStorage/state.vscdb")); + }); + + it("returns empty array for unsupported platforms", () => { + assert.deepEqual(cursorDbCandidatePaths("freebsd" as NodeJS.Platform, { home: "/x" }), []); + }); +}); + +describe("verifyLinuxCursorInstalled (port: 9router#313)", () => { + const okExec = async () => ({ stdout: "/usr/bin/cursor\n", stderr: "" }); + const failExec = async () => { + throw new Error("which: no cursor in PATH"); + }; + const okAccess = async () => {}; + const failAccess = async () => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }; + + it("returns true when `which cursor` succeeds (does not probe the .desktop file)", async () => { + let accessCalled = false; + const installed = await verifyLinuxCursorInstalled({ + execFile: okExec, + access: async () => { + accessCalled = true; + }, + home: "/home/test", + }); + assert.equal(installed, true); + assert.equal(accessCalled, false); + }); + + it("falls back to the cursor.desktop launcher when `which` fails", async () => { + let probedPath = ""; + const installed = await verifyLinuxCursorInstalled({ + execFile: failExec, + access: async (p) => { + probedPath = p; + }, + home: "/home/test", + }); + assert.equal(installed, true); + assert.equal(probedPath, "/home/test/.local/share/applications/cursor.desktop"); + }); + + it("returns false when neither `which` nor the .desktop file resolve (phantom config)", async () => { + const installed = await verifyLinuxCursorInstalled({ + execFile: failExec, + access: failAccess, + home: "/home/test", + }); + assert.equal(installed, false); + }); + + it("probes `which cursor` with a fixed binary name and a bounded timeout", async () => { + let calledWith: { file: string; args: string[]; timeout: number } | null = null; + const installed = await verifyLinuxCursorInstalled({ + execFile: async (file, args, options) => { + calledWith = { file, args, timeout: options.timeout }; + return { stdout: "/usr/bin/cursor", stderr: "" }; + }, + access: okAccess, + home: "/home/test", + }); + assert.equal(installed, true); + assert.deepEqual(calledWith, { + file: "which", + args: ["cursor"], + timeout: 5000, + }); + }); +}); + +describe("tryAgentAuth", () => { + const ORIGINAL_HOME = process.env.HOME; + const ORIGINAL_USERPROFILE = process.env.USERPROFILE; + let tmpHome: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cursor-agent-auth-")); + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + }); + + afterEach(() => { + process.env.HOME = ORIGINAL_HOME; + if (ORIGINAL_USERPROFILE !== undefined) { + process.env.USERPROFILE = ORIGINAL_USERPROFILE; + } else { + delete process.env.USERPROFILE; + } + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it("finds a token in the primary auth.json candidate", async () => { + const authDir = path.join(tmpHome, ".config", "cursor"); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + path.join(authDir, "auth.json"), + JSON.stringify({ accessToken: "primary-token" }) + ); + + const result = await tryAgentAuth(); + assert.equal(result.found, true); + assert.equal(result.accessToken, "primary-token"); + assert.equal(result.source, "cursor-agent"); + }); + + it("falls back to agent-cli-state.json when auth.json is missing", async () => { + const stateDir = path.join(tmpHome, ".cursor"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "agent-cli-state.json"), + JSON.stringify({ accessToken: "fallback-token" }) + ); + + const result = await tryAgentAuth(); + assert.equal(result.found, true); + assert.equal(result.accessToken, "fallback-token"); + assert.equal(result.source, "cursor-agent"); + }); + + it("reports not found when neither candidate has a usable accessToken", async () => { + const stateDir = path.join(tmpHome, ".cursor"); + fs.mkdirSync(stateDir, { recursive: true }); + // Schema differs from what's expected — no accessToken field. + fs.writeFileSync( + path.join(stateDir, "agent-cli-state.json"), + JSON.stringify({ authId: "some-id", displayName: "someone" }) + ); + + const result = await tryAgentAuth(); + assert.equal(result.found, false); + assert.equal(result.error, "cursor-agent auth.json not found"); + }); + + it("reports not found when neither file exists", async () => { + const result = await tryAgentAuth(); + assert.equal(result.found, false); + assert.equal(result.error, "cursor-agent auth.json not found"); + }); + + it("reports not found (does not throw) when auth.json contains malformed JSON", async () => { + const authDir = path.join(tmpHome, ".config", "cursor"); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync(path.join(authDir, "auth.json"), "{ this is not valid json "); + + const result = await tryAgentAuth(); + assert.equal(result.found, false); + assert.equal(result.error, "cursor-agent auth.json not found"); + }); + + it("falls through to the second candidate when the primary file is malformed JSON", async () => { + const authDir = path.join(tmpHome, ".config", "cursor"); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync(path.join(authDir, "auth.json"), "not json at all"); + + const stateDir = path.join(tmpHome, ".cursor"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "agent-cli-state.json"), + JSON.stringify({ accessToken: "fallback-after-malformed" }) + ); + + const result = await tryAgentAuth(); + assert.equal(result.found, true); + assert.equal(result.accessToken, "fallback-after-malformed"); + }); +}); + +describe("tryIdeAuth", () => { + let originalPlatformDescriptor: PropertyDescriptor | undefined; + const ORIGINAL_HOME = process.env.HOME; + const ORIGINAL_USERPROFILE = process.env.USERPROFILE; + let tmpHome: string | undefined; + + beforeEach(() => { + originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + }); + + afterEach(() => { + if (originalPlatformDescriptor) { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + process.env.HOME = ORIGINAL_HOME; + if (ORIGINAL_USERPROFILE !== undefined) { + process.env.USERPROFILE = ORIGINAL_USERPROFILE; + } else { + delete process.env.USERPROFILE; + } + if (tmpHome) { + fs.rmSync(tmpHome, { recursive: true, force: true }); + tmpHome = undefined; + } + }); + + it("dispatches to the unsupported-platform branch for a platform with no candidate paths", async () => { + Object.defineProperty(process, "platform", { value: "freebsd", configurable: true }); + + const result = await tryIdeAuth(); + assert.equal(result.found, false); + assert.equal(result.error, "Unsupported platform"); + }); + + // The following exercise the SUPPORTED-platform dispatch branch through to a + // real tryOpenSync() call. mock.module() is unavailable in this tsx/ESM + + // Node native test-runner setup (see tests/unit/token-health-check-sweep.test.ts), + // and tryIdeAuth() takes no injectable options — so instead of mocking the + // driver, these seed a REAL sqlite file at the exact candidate path via the + // same resilient driver factory (openDatabaseAsync), matching the technique + // tests/unit/db-import-resilient-driver-3025.test.ts already uses. + describe("on a supported platform (darwin), against a real state.vscdb", () => { + beforeEach(() => { + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cursor-ide-auth-")); + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + }); + + it("finds tokens when the real database contains the expected keys", async () => { + const dbPath = cursorDbCandidatePaths("darwin", { home: tmpHome as string })[0]; + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + + const { openDatabaseAsync } = await import("@/lib/db/adapters/driverFactory"); + const seed = await openDatabaseAsync(dbPath); + seed.exec("CREATE TABLE itemTable (key TEXT PRIMARY KEY, value TEXT)"); + seed + .prepare("INSERT INTO itemTable (key, value) VALUES (?, ?)") + .run("cursorAuth/accessToken", "found-token"); + seed + .prepare("INSERT INTO itemTable (key, value) VALUES (?, ?)") + .run("storage.serviceMachineId", "found-machine"); + seed.close(); + + const result = await tryIdeAuth(); + assert.equal(result.found, true); + assert.equal(result.accessToken, "found-token"); + assert.equal(result.machineId, "found-machine"); + assert.equal(result.source, "cursor-ide"); + }); + + it("reports tokens not found when the real database has no matching keys", async () => { + const dbPath = cursorDbCandidatePaths("darwin", { home: tmpHome as string })[0]; + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + + const { openDatabaseAsync } = await import("@/lib/db/adapters/driverFactory"); + const seed = await openDatabaseAsync(dbPath); + seed.exec("CREATE TABLE itemTable (key TEXT PRIMARY KEY, value TEXT)"); + seed.prepare("INSERT INTO itemTable (key, value) VALUES (?, ?)").run("irrelevant.key", "x"); + seed.close(); + + const result = await tryIdeAuth(); + assert.equal(result.found, false); + assert.equal(result.error, "Tokens not found in database"); + }); + + it("reports a db-open failure (not a thrown exception) when the file is not a valid sqlite database", async () => { + // better-sqlite3's Database constructor opens lazily — it does not + // validate the file format until the first prepare()/query, so this + // exercises the query-time catch block (SQLITE_NOTADB), not the + // upfront `!db` "(driver unavailable)" branch. + const dbPath = cursorDbCandidatePaths("darwin", { home: tmpHome as string })[0]; + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + fs.writeFileSync(dbPath, "not a real sqlite database file"); + + const result = await tryIdeAuth(); + assert.equal(result.found, false); + assert.equal(result.error, "Failed to read database"); + }); + }); +}); diff --git a/tests/unit/oauth-cursor-auto-import.test.ts b/tests/unit/oauth-cursor-auto-import.test.ts deleted file mode 100644 index 7bb3c09720..0000000000 --- a/tests/unit/oauth-cursor-auto-import.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { - normalizeVscDbValue, - extractCursorTokensFromRows, - fuzzyExtractCursorTokensFromRows, - cursorDbCandidatePaths, - verifyLinuxCursorInstalled, -} from "../../src/app/api/oauth/cursor/auto-import/route"; - -describe("normalizeVscDbValue", () => { - it("unwraps a JSON-encoded string", () => { - assert.equal(normalizeVscDbValue('"abc"'), "abc"); - }); - - it("returns the raw string when JSON parse fails", () => { - assert.equal(normalizeVscDbValue("not-json"), "not-json"); - }); - - it("returns the raw string when JSON parses to non-string", () => { - assert.equal(normalizeVscDbValue("123"), "123"); - assert.equal(normalizeVscDbValue("{}"), "{}"); - }); - - it("passes non-strings through unchanged", () => { - assert.equal(normalizeVscDbValue(42 as unknown as string), 42); - assert.equal(normalizeVscDbValue(null as unknown as string), null); - }); -}); - -describe("extractCursorTokensFromRows", () => { - it("extracts tokens using exact primary keys", () => { - const tokens = extractCursorTokensFromRows([ - { key: "cursorAuth/accessToken", value: "tok-1" }, - { key: "storage.serviceMachineId", value: "machine-1" }, - ]); - assert.equal(tokens.accessToken, "tok-1"); - assert.equal(tokens.machineId, "machine-1"); - }); - - it("accepts the alternative `cursorAuth/token` key", () => { - const tokens = extractCursorTokensFromRows([ - { key: "cursorAuth/token", value: "tok-2" }, - { key: "storage.machineId", value: "machine-2" }, - ]); - assert.equal(tokens.accessToken, "tok-2"); - assert.equal(tokens.machineId, "machine-2"); - }); - - it("accepts the alternative `telemetry.machineId` key", () => { - const tokens = extractCursorTokensFromRows([ - { key: "cursorAuth/accessToken", value: "tok-3" }, - { key: "telemetry.machineId", value: "machine-3" }, - ]); - assert.equal(tokens.machineId, "machine-3"); - }); - - it("prefers the first match and ignores duplicates", () => { - const tokens = extractCursorTokensFromRows([ - { key: "cursorAuth/accessToken", value: "first" }, - { key: "cursorAuth/token", value: "second" }, - ]); - assert.equal(tokens.accessToken, "first"); - }); - - it("normalizes JSON-encoded values", () => { - const tokens = extractCursorTokensFromRows([ - { key: "cursorAuth/accessToken", value: '"json-token"' }, - { key: "storage.serviceMachineId", value: '"json-machine"' }, - ]); - assert.equal(tokens.accessToken, "json-token"); - assert.equal(tokens.machineId, "json-machine"); - }); - - it("returns empty on no matches", () => { - const tokens = extractCursorTokensFromRows([{ key: "irrelevant", value: "x" }]); - assert.equal(tokens.accessToken, undefined); - assert.equal(tokens.machineId, undefined); - }); -}); - -describe("fuzzyExtractCursorTokensFromRows", () => { - it("matches keys by substring containing `accesstoken` and `machineid`", () => { - const tokens = fuzzyExtractCursorTokensFromRows([ - { key: "cursorAuth/someOtherAccessTokenKey", value: "fallback-token" }, - { key: "storage.someMachineId", value: "fallback-machine" }, - ]); - assert.equal(tokens.accessToken, "fallback-token"); - assert.equal(tokens.machineId, "fallback-machine"); - }); - - it("preserves already-found tokens (passes existing through)", () => { - const tokens = fuzzyExtractCursorTokensFromRows( - [ - { key: "cursorAuth/someOtherAccessTokenKey", value: "fallback-token" }, - { key: "storage.someMachineId", value: "fallback-machine" }, - ], - { accessToken: "already-have-it" } - ); - assert.equal(tokens.accessToken, "already-have-it"); - assert.equal(tokens.machineId, "fallback-machine"); - }); - - it("is case-insensitive on the key match", () => { - const tokens = fuzzyExtractCursorTokensFromRows([ - { key: "Some.ACCESSTOKEN.suffix", value: "tok" }, - { key: "Some.MACHINEID.suffix", value: "mid" }, - ]); - assert.equal(tokens.accessToken, "tok"); - assert.equal(tokens.machineId, "mid"); - }); -}); - -describe("cursorDbCandidatePaths", () => { - it("returns standard + Insiders paths on macOS", () => { - const paths = cursorDbCandidatePaths("darwin", { home: "/Users/test" }); - assert.equal(paths.length, 2); - assert.ok(paths[0].includes("Cursor/User/globalStorage/state.vscdb")); - assert.ok(paths[1].includes("Cursor - Insiders/User/globalStorage/state.vscdb")); - }); - - it("returns a single path on Linux", () => { - const paths = cursorDbCandidatePaths("linux", { home: "/home/test" }); - assert.deepEqual(paths, [ - "/home/test/.config/Cursor/User/globalStorage/state.vscdb", - ]); - }); - - it("returns a single path on Windows using APPDATA", () => { - const paths = cursorDbCandidatePaths("win32", { - home: "C:/Users/test", - appdata: "C:/Users/test/AppData/Roaming", - }); - assert.equal(paths.length, 1); - assert.ok(paths[0].includes("Cursor/User/globalStorage/state.vscdb")); - }); - - it("returns empty array for unsupported platforms", () => { - assert.deepEqual(cursorDbCandidatePaths("freebsd" as NodeJS.Platform, { home: "/x" }), []); - }); -}); - -describe("verifyLinuxCursorInstalled (port: 9router#313)", () => { - const okExec = async () => ({ stdout: "/usr/bin/cursor\n", stderr: "" }); - const failExec = async () => { - throw new Error("which: no cursor in PATH"); - }; - const okAccess = async () => {}; - const failAccess = async () => { - throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); - }; - - it("returns true when `which cursor` succeeds (does not probe the .desktop file)", async () => { - let accessCalled = false; - const installed = await verifyLinuxCursorInstalled({ - execFile: okExec, - access: async () => { - accessCalled = true; - }, - home: "/home/test", - }); - assert.equal(installed, true); - assert.equal(accessCalled, false); - }); - - it("falls back to the cursor.desktop launcher when `which` fails", async () => { - let probedPath = ""; - const installed = await verifyLinuxCursorInstalled({ - execFile: failExec, - access: async (p) => { - probedPath = p; - }, - home: "/home/test", - }); - assert.equal(installed, true); - assert.equal(probedPath, "/home/test/.local/share/applications/cursor.desktop"); - }); - - it("returns false when neither `which` nor the .desktop file resolve (phantom config)", async () => { - const installed = await verifyLinuxCursorInstalled({ - execFile: failExec, - access: failAccess, - home: "/home/test", - }); - assert.equal(installed, false); - }); - - it("probes `which cursor` with a fixed binary name and a bounded timeout", async () => { - let calledWith: { file: string; args: string[]; timeout: number } | null = null; - const installed = await verifyLinuxCursorInstalled({ - execFile: async (file, args, options) => { - calledWith = { file, args, timeout: options.timeout }; - return { stdout: "/usr/bin/cursor", stderr: "" }; - }, - access: okAccess, - home: "/home/test", - }); - assert.equal(installed, true); - assert.deepEqual(calledWith, { - file: "which", - args: ["cursor"], - timeout: 5000, - }); - }); -});