Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/app/api/oauth/cursor/auto-import/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,65 @@ 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 <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;
}
}
Comment on lines +50 to +61

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

On Linux, desktop entries can be installed system-wide (e.g., via package managers or system-wide integrations) under /usr/share/applications/ or /usr/local/share/applications/ instead of the user's home directory. If which cursor is not available, checking only the user-specific path ~/.local/share/applications/cursor.desktop can lead to false negatives. We should expand the search to include standard system-wide XDG desktop directories.

  try {
    await exec("which", ["cursor"], { timeout: 5000 });
    return true;
  } catch {
    const desktopPaths = [
      join(home, ".local/share/applications/cursor.desktop"),
      "/usr/share/applications/cursor.desktop",
      "/usr/local/share/applications/cursor.desktop",
    ];
    for (const desktopPath of desktopPaths) {
      try {
        await canAccess(desktopPath, constants.R_OK);
        return true;
      } catch {
        // continue to next path
      }
    }
    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
Expand Down Expand Up @@ -184,6 +241,17 @@ async function tryIdeAuth(): Promise<{
};
}
} 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];
Comment on lines +244 to 255

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Checking verifyLinuxCursorInstalled() before verifying if the database file actually exists is inefficient and can lead to misleading error messages. If the user does not have Cursor installed at all, the database file won't exist, but we would still spawn a child process to run which cursor and check the filesystem for desktop files, only to return a misleading error message claiming that "Cursor config files found but Cursor IDE does not appear to be installed". Checking if the database file exists first avoids spawning unnecessary processes and keeps the error message accurate.

    dbPath = candidates[0];
    // 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") {
      try {
        await access(dbPath, constants.R_OK);
        if (!(await verifyLinuxCursorInstalled())) {
          return {
            found: false,
            error:
              "Cursor config files found but Cursor IDE does not appear to be " +
              "installed. Skipping auto-import.",
          };
        }
      } catch {
        return {
          found: false,
          error: "Cursor IDE database not found",
        };
      }
    }

}

Expand Down
65 changes: 65 additions & 0 deletions tests/unit/oauth-cursor-auto-import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
extractCursorTokensFromRows,
fuzzyExtractCursorTokensFromRows,
cursorDbCandidatePaths,
verifyLinuxCursorInstalled,
} from "../../src/app/api/oauth/cursor/auto-import/route";

describe("normalizeVscDbValue", () => {
Expand Down Expand Up @@ -138,3 +139,67 @@ describe("cursorDbCandidatePaths", () => {
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,
});
});
});
Loading