Skip to content
Open
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
59 changes: 58 additions & 1 deletion apps/server/src/provider/Layers/CursorProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -18,6 +20,7 @@ import {
discoverCursorModelsViaAcp,
getCursorFallbackModels,
getCursorParameterizedModelPickerUnsupportedMessage,
isCursorDesktopAgentForwarderScript,
parseCursorAboutOutput,
parseCursorCliConfigChannel,
parseCursorVersionDate,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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());
Expand Down
58 changes: 57 additions & 1 deletion apps/server/src/provider/Layers/CursorProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
);
Comment thread
cursor[bot] marked this conversation as resolved.
}

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;
Expand Down Expand Up @@ -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),
Expand Down
Loading