diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index e969a7beab4..c21aa9877e1 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -4,11 +4,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import type * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import type * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { describe, expect, it } from "vite-plus/test"; import type * as EffectAcpSchema from "effect-acp/schema"; import type { CursorSettings } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; import { @@ -18,6 +20,7 @@ import { discoverCursorModelsViaAcp, getCursorFallbackModels, getCursorParameterizedModelPickerUnsupportedMessage, + isCursorDesktopAgentForwarderScript, parseCursorAboutOutput, parseCursorCliConfigChannel, parseCursorVersionDate, @@ -158,6 +161,27 @@ const makeExitLogFixture = Effect.fn("makeExitLogFixture")(function* (prefix: st }; }); +const makeCursorDesktopForwarder = Effect.fn("makeCursorDesktopForwarder")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-desktop-forwarder-", + }); + const wrapperPath = path.join( + tempDir, + platform === "win32" ? "cursor-agent.cmd" : "cursor-agent", + ); + const script = + platform === "win32" + ? "@echo off\r\ncursor agent %*\r\n" + : '#!/bin/sh\nexec cursor agent "$@"\n'; + yield* fileSystem.writeFileString(wrapperPath, script); + yield* fileSystem.chmod(wrapperPath, 0o755); + return wrapperPath; +}); + const parameterizedGpt54ConfigOptions = [ { type: "select", @@ -428,6 +452,28 @@ describe("buildCursorCapabilitiesFromConfigOptions", () => { }); describe("checkCursorProviderStatus", () => { + it("rejects desktop forwarder shims without spawning them", async () => { + const wrapperPath = await runNode(makeCursorDesktopForwarder()); + const spawner = ChildProcessSpawner.make(() => + Effect.die("Cursor desktop forwarder must not be spawned"), + ); + const provider = await runNode( + checkCursorProviderStatus({ + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }).pipe(Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))), + ); + + expect(provider).toMatchObject({ + installed: false, + status: "error", + auth: { status: "unknown" }, + }); + expect(provider.message).toContain("forwards to the Cursor desktop launcher"); + }); + it("reports the install docs when the Cursor CLI command is missing", async () => { const provider = await runNode( checkCursorProviderStatus({ @@ -474,6 +520,17 @@ describe("checkCursorProviderStatus", () => { }); }); +describe("isCursorDesktopAgentForwarderScript", () => { + it("recognizes shell wrappers that invoke the desktop CLI's agent argument", () => { + expect(isCursorDesktopAgentForwarderScript("@echo off\r\ncursor agent %*\r\n")).toBe(true); + expect(isCursorDesktopAgentForwarderScript('exec "/opt/Cursor/cursor" agent "$@"')).toBe(true); + expect(isCursorDesktopAgentForwarderScript('exec /usr/local/bin/cursor agent "$@"')).toBe(true); + expect(isCursorDesktopAgentForwarderScript("C:\\tools\\cursor.exe agent %*")).toBe(true); + expect(isCursorDesktopAgentForwarderScript("cursor-agent about")).toBe(false); + expect(isCursorDesktopAgentForwarderScript('# cursor agent "$@"')).toBe(false); + }); +}); + describe("discoverCursorModelsViaAcp", () => { it("keeps the ACP probe runtime alive long enough to discover models", async () => { const wrapperPath = await runNode(makeMockAgentWrapper()); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index fee4306c4c5..0685b23d1d0 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -28,7 +28,7 @@ import { getProviderOptionBooleanSelectionValue, getProviderOptionStringSelectionValue, } from "@t3tools/shared/model"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell"; import { buildBooleanOptionDescriptor, @@ -579,6 +579,12 @@ export function getCursorFallbackModels( /** Timeout for `agent about` — it's slower than a simple `--version` probe. */ const ABOUT_TIMEOUT_MS = 8_000; +const CURSOR_FORWARDER_INSPECTION_MAX_BYTES = 64n * 1024n; +const CURSOR_DESKTOP_FORWARDER_MESSAGE = [ + "The configured Cursor Agent command forwards to the Cursor desktop launcher, which cannot provide ACP and may open editor windows.", + "Install the standalone Cursor CLI and set the binary path to `cursor-agent`.", + `See ${CURSOR_CLI_INSTALLATION_DOCS_URL}.`, +].join(" "); /** Strip ANSI escape sequences so we can parse plain key-value lines. */ function stripAnsi(text: string): string { @@ -622,6 +628,40 @@ function buildCursorCliCommandMissingMessage(binaryPath: string): string { ].join(" "); } +export function isCursorDesktopAgentForwarderScript(script: string): boolean { + return /^[^\S\r\n]*(?!(?:#|::|rem(?:[^\S\r\n]|$)))(?:(?:exec|@?call|&)[^\S\r\n]+)?@?(?:"[^"\r\n]*[\\/]cursor(?:\.exe|\.cmd|\.bat)?"|(?:[^\s"'&|<>]+[\\/])?cursor(?:\.exe|\.cmd|\.bat)?)[^\S\r\n]+agent(?:[^\S\r\n]|$)/im.test( + script, + ); +} + +const isCursorDesktopAgentForwarder = Effect.fn("isCursorDesktopAgentForwarder")(function* ( + binaryPath: string, + environment?: NodeJS.ProcessEnv, +) { + const fileSystem = yield* FileSystem.FileSystem; + const resolvedPath = yield* resolveCommandPath( + binaryPath, + environment ? { env: environment } : {}, + ).pipe(Effect.option); + if (Option.isNone(resolvedPath)) { + return false; + } + + const info = yield* fileSystem.stat(resolvedPath.value).pipe(Effect.option); + if ( + Option.isNone(info) || + info.value.type !== "File" || + info.value.size > CURSOR_FORWARDER_INSPECTION_MAX_BYTES + ) { + return false; + } + + const script = yield* fileSystem + .readFileString(resolvedPath.value) + .pipe(Effect.orElseSucceed(() => "")); + return isCursorDesktopAgentForwarderScript(script); +}); + export function buildCursorProviderSnapshot(input: { readonly checkedAt: string; readonly cursorSettings: CursorSettings; @@ -1011,6 +1051,22 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( }); } + if (yield* isCursorDesktopAgentForwarder(cursorSettings.binaryPath, environment)) { + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: cursorSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "error", + auth: { status: "unknown" }, + message: CURSOR_DESKTOP_FORWARDER_MESSAGE, + }, + }); + } + // Single `agent about` probe: returns version + auth status in one call. const aboutProbe = yield* runCursorAboutCommand(cursorSettings, environment).pipe( Effect.timeoutOption(ABOUT_TIMEOUT_MS),