FN-7699: apply cursorCliBinaryPath override to model-picker discovery

Thread the machine-local cursorCliBinaryPath operator override into /api/models Cursor CLI discovery so the model picker spawns the same cursor-agent binary already validated by sign-in/status/probe.

- register-model-routes.ts reads globalSettings.cursorCliBinaryPath, trims and normalizes blank to undefined (preserving PATH auto-detection)
- passes the normalized binaryPath through to getCursorPickerModels({ binaryPath }) for model discovery
- adds regression tests covering override-set and override-blank/unset behavior in register-model-routes-cursor-cli.test.ts
- adds changeset fn-7699-cursor-cli-binary-path-model-picker.md (patch, fix) documenting the follow-up to FN-7696

Files changed:
$(cat /tmp/diffstat.txt)

Fusion-Task-Id: FN-7699

Fusion-Task-Lineage: 9895af8e-447d-425b-af58-1af4748c013c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-08 18:21:42 -07:00
parent c9a2b201be
commit 639a706f12
3 changed files with 96 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: The Cursor CLI binary path override now also applies to the model picker, not just sign-in/status.
category: fix
dev: /api/models reads globalSettings.cursorCliBinaryPath (trim/blank→undefined) and threads it as getCursorPickerModels({ binaryPath }) so model-picker discovery spawns the same machine-local cursor-agent used by auth/probe/status. Blank/undefined preserves PATH auto-detection. Follow-up to FN-7696.

View File

@@ -25,7 +25,11 @@ import { registerModelRoutes } from "../routes/register-model-routes.js";
const mockedGetCursorPickerModels = vi.mocked(getCursorPickerModels);
function setup(useCursorCli?: boolean, registryModels?: Array<{ provider: string; id: string; name: string; reasoning: boolean; contextWindow: number }>) {
function setup(
useCursorCli?: boolean,
registryModels?: Array<{ provider: string; id: string; name: string; reasoning: boolean; contextWindow: number }>,
cursorCliBinaryPath?: unknown,
) {
const getHandlers = new Map<string, (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>>();
const router = {
get: vi.fn((path: string, handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>) => {
@@ -35,7 +39,7 @@ function setup(useCursorCli?: boolean, registryModels?: Array<{ provider: string
const store = {
getGlobalSettingsStore: () => ({
getSettings: vi.fn().mockResolvedValue({ useCursorCli }),
getSettings: vi.fn().mockResolvedValue({ useCursorCli, cursorCliBinaryPath }),
}),
getSettingsFast: vi.fn().mockResolvedValue({}),
};
@@ -162,3 +166,61 @@ describe("registerModelRoutes cursor-cli merge and filter", () => {
expect(new Set(keys).size).toBe(keys.length);
});
});
/*
FNXC:CursorCli 2026-07-08-00:20:
FN-7699: the machine-local cursorCliBinaryPath operator override (already
honored by the auth/probe/status paths in register-auth-routes.ts) must also
apply to model-picker discovery, so an operator whose cursor-agent is not on
PATH still sees Cursor models in the picker. These tests assert the
normalized override is threaded into getCursorPickerModels({ binaryPath })
verbatim, that blank/undefined preserves binaryPath: undefined (PATH
auto-detection), and that the toggle-off gate (FN-7696) is not regressed.
*/
describe("registerModelRoutes cursorCliBinaryPath threading", () => {
it("threads a set cursorCliBinaryPath override into getCursorPickerModels verbatim", async () => {
mockedGetCursorPickerModels.mockResolvedValue([
{ provider: "cursor-cli", id: "cursor/gpt-5", name: "GPT-5", reasoning: false, contextWindow: 0 },
]);
const handler = setup(true, undefined, "/opt/Cursor/cursor-agent");
const response = await invoke(handler);
expect(mockedGetCursorPickerModels).toHaveBeenCalledWith({ binaryPath: "/opt/Cursor/cursor-agent" });
expect(response.models.some((m) => m.provider === "cursor-cli" && m.id === "cursor/gpt-5")).toBe(true);
});
it("threads a Windows-shim-style override path verbatim, with no mangling", async () => {
mockedGetCursorPickerModels.mockResolvedValue([]);
const winPath = "C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd";
const handler = setup(true, undefined, winPath);
await invoke(handler);
expect(mockedGetCursorPickerModels).toHaveBeenCalledWith({ binaryPath: winPath });
});
it("passes binaryPath: undefined when cursorCliBinaryPath is absent (PATH auto-detection preserved)", async () => {
mockedGetCursorPickerModels.mockResolvedValue([]);
const handler = setup(true, undefined, undefined);
await invoke(handler);
expect(mockedGetCursorPickerModels).toHaveBeenCalledWith({ binaryPath: undefined });
});
it("passes binaryPath: undefined when cursorCliBinaryPath is blank/whitespace-only", async () => {
mockedGetCursorPickerModels.mockResolvedValue([]);
const handler = setup(true, undefined, " ");
await invoke(handler);
expect(mockedGetCursorPickerModels).toHaveBeenCalledWith({ binaryPath: undefined });
});
it("passes binaryPath: undefined when cursorCliBinaryPath is an empty string", async () => {
mockedGetCursorPickerModels.mockResolvedValue([]);
const handler = setup(true, undefined, "");
await invoke(handler);
expect(mockedGetCursorPickerModels).toHaveBeenCalledWith({ binaryPath: undefined });
});
it("does not surface cursor-cli rows or call getCursorPickerModels when useCursorCli is false, regardless of cursorCliBinaryPath", async () => {
const handler = setup(false, undefined, "/opt/Cursor/cursor-agent");
const response = await invoke(handler);
expect(mockedGetCursorPickerModels).not.toHaveBeenCalled();
expect(response.models.some((m) => m.provider === "cursor-cli")).toBe(false);
});
});

View File

@@ -162,6 +162,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
let useDroidCli = false;
let useLlamaCpp = false;
let useCursorCli = false;
let cursorCliBinaryPath: string | undefined;
let resolvedPlanningProvider: string | undefined;
let resolvedPlanningModelId: string | undefined;
let customProviders: CustomProvider[] = [];
@@ -177,6 +178,20 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
useDroidCli = globalSettings.useDroidCli === true;
useLlamaCpp = globalSettings.useLlamaCpp === true;
useCursorCli = (globalSettings as Record<string, unknown>).useCursorCli === true;
/*
FNXC:CursorCli 2026-07-08-00:20:
FN-7699 (follow-up to FN-7696): the machine-local `cursorCliBinaryPath`
operator override must apply to model-picker discovery too, not just
the auth/probe/status paths (register-auth-routes.ts's
normalizeCursorCliBinaryPath). Mirror the same trim/blank->undefined
normalization here so a blank/unset override preserves PATH
auto-detection byte-for-byte, and a set override threads through to
getCursorPickerModels below so discovery spawns the exact same
cursor-agent executable the settings card already validated.
*/
const rawCursorCliBinaryPath = (globalSettings as Record<string, unknown>).cursorCliBinaryPath;
cursorCliBinaryPath =
typeof rawCursorCliBinaryPath === "string" ? rawCursorCliBinaryPath.trim() || undefined : undefined;
customProviders = globalSettings.customProviders ?? [];
const mergedSettings = await store.getSettingsFast();
@@ -299,6 +314,15 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
the existing seenModelKeys provider/id dedup so an existing row always
wins over a colliding Cursor row — purely additive, must never
displace, overwrite, or filter out an existing row.
FNXC:CursorCli 2026-07-08-00:20:
FN-7699: thread the normalized cursorCliBinaryPath operator override
(see the globalSettings read block above) into getCursorPickerModels so
discovery spawns the exact same machine-local cursor-agent executable
already validated by the auth/probe/status paths. Blank/undefined
preserves PATH auto-detection unchanged; the cache is keyed per
resolved binary path so the override participates correctly in
TTL/single-flight caching.
*/
if (useCursorCli) {
// getCursorPickerModels never throws by contract (see
@@ -306,7 +330,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
// a Cursor discovery failure can never reject the /models handler or
// drop existing rows — degrade to zero Cursor rows instead.
try {
const cursorModels = await getCursorPickerModels();
const cursorModels = await getCursorPickerModels({ binaryPath: cursorCliBinaryPath });
for (const cursorModel of cursorModels) {
const key = `${cursorModel.provider}/${cursorModel.id}`;
if (seenModelKeys.has(key)) continue;