Compare commits

...
Author SHA1 Message Date
bincxz 5083a81681 fix(ai): spawn Cursor CLI via node.exe + index.js on Windows
Codex review: putting the turn prompt on a cmd.exe line hits the
8191-char limit, mishandles quotes/%VAR%, and leaves the Node child
alive after Stop. Resolve cursor-agent.cmd to versions/*/node.exe plus
index.js and spawn that argv with shell:false.

Closes review on #3039
2026-08-18 10:06:49 +08:00
bincxz 2f701c1605 fix(ai): detect Cursor CLI login on Windows cmd shims
Settings → AI → Cursor Check ran `cursor-agent status` via execFileSync
on the path from `where`, which is a .cmd installer shim. Node cannot
spawn .cmd files directly (EINVAL), so the probe reported logged-out
even when the terminal showed authenticated.

Run the status probe, turns, and model listing through the existing
Windows shim spawn helper, without unwrapping cursor-agent.cmd to
node.exe (that would drop index.js). Also accept status JSON wrapped
in cmd noise.

Closes #3003
2026-08-18 09:56:40 +08:00
8 changed files with 443 additions and 19 deletions

No files matched your search

+8 -4
View File
@@ -272,15 +272,19 @@ function resolveWindowsShimToNativeExe(command, platform = process.platform) {
return null;
}
function prepareCommandForSpawn(command, args) {
function prepareCommandForSpawn(command, args, options = {}) {
const spawnArgs = Array.isArray(args) ? args : [];
if (!shouldUseShellForCommand(command)) {
return { command, args: spawnArgs, shell: false };
}
const nativeExePath = resolveWindowsShimToNativeExe(command);
if (nativeExePath) {
return { command: nativeExePath, args: spawnArgs, shell: false };
// Cursor's Windows installer .cmd launches node.exe + index.js. Unwrapping
// to the first quoted .exe would drop the script and run node with CLI args.
if (options.unwrapNativeExe !== false) {
const nativeExePath = resolveWindowsShimToNativeExe(command);
if (nativeExePath) {
return { command: nativeExePath, args: spawnArgs, shell: false };
}
}
return {
+39
View File
@@ -242,6 +242,45 @@ test("resolveWindowsShimToNativeExe resolves npm .cmd shim to native exe", () =>
}
});
test("prepareCommandForSpawn can skip native exe unwrap for node+script shims", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-spawn-no-unwrap-"));
try {
const shimPath = path.join(tmp, "cursor-agent.cmd");
const nodeExe = path.join(tmp, "versions", "2026.06.01-abc", "node.exe");
fs.mkdirSync(path.dirname(nodeExe), { recursive: true });
fs.writeFileSync(nodeExe, "", "utf8");
fs.writeFileSync(
shimPath,
'@ECHO off\r\n"%~dp0\\versions\\2026.06.01-abc\\node.exe" "%~dp0\\versions\\2026.06.01-abc\\index.js" %*\r\n',
"utf8",
);
assert.equal(resolveWindowsShimToNativeExe(shimPath, "win32"), nodeExe);
const unwrapped = prepareCommandForSpawn(shimPath, ["status", "--format", "json"]);
const wrapped = prepareCommandForSpawn(shimPath, ["status", "--format", "json"], {
unwrapNativeExe: false,
});
if (process.platform === "win32") {
assert.deepEqual(unwrapped, {
command: nodeExe,
args: ["status", "--format", "json"],
shell: false,
});
assert.deepEqual(wrapped, {
command: buildWindowsShellCommandLine(shimPath, ["status", "--format", "json"]),
args: [],
shell: true,
});
} else {
assert.equal(unwrapped.shell, false);
assert.equal(wrapped.shell, false);
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("prepareCommandForSpawn resolves Windows cmd shim to native exe with shell:false", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-spawn-native-"));
try {
+32 -11
View File
@@ -11,6 +11,7 @@ const { existsSync, readFileSync } = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { resolveCursorCliSpawnSpec } = require("./cursorCliSpawn.cjs");
function defaultFileExists(p) {
try { return existsSync(p); } catch { return false; }
@@ -156,12 +157,15 @@ function defaultResolveCursorCliBinary(name, env) {
}
function defaultRunCursorStatus(binPath, env) {
const spec = resolveCursorCliSpawnSpec(binPath, ["status", "--format", "json"]);
try {
const stdout = execFileSync(binPath, ["status", "--format", "json"], {
const stdout = execFileSync(spec.command, spec.args, {
encoding: "utf8",
timeout: 8000,
env: env || process.env,
stdio: ["pipe", "pipe", "pipe"],
shell: spec.shell,
windowsHide: true,
});
return { exitCode: 0, stdout: String(stdout || ""), stderr: "" };
} catch (err) {
@@ -173,19 +177,34 @@ function defaultRunCursorStatus(binPath, env) {
}
}
function extractFirstJsonObject(text) {
const raw = String(text || "").replace(/^\uFEFF/, "").trim();
if (!raw) return null;
const candidates = [raw];
const start = raw.indexOf("{");
const end = raw.lastIndexOf("}");
if (start >= 0 && end > start) {
const sliced = raw.slice(start, end + 1);
if (sliced !== raw) candidates.push(sliced);
}
for (const candidate of candidates) {
try {
const parsed = JSON.parse(candidate);
if (parsed && typeof parsed === "object") return parsed;
} catch { /* try next */ }
}
return null;
}
function parseCursorStatusJson(stdout) {
try {
const parsed = JSON.parse(String(stdout || "").trim());
if (!parsed || typeof parsed !== "object") return null;
// Real Cursor status always exposes isAuthenticated and/or status.
// Reject unrelated CLIs that accept unknown flags or emit other JSON.
if (typeof parsed.isAuthenticated !== "boolean" && typeof parsed.status !== "string") {
return null;
}
return parsed;
} catch {
const parsed = extractFirstJsonObject(stdout);
if (!parsed) return null;
// Real Cursor status always exposes isAuthenticated and/or status.
// Reject unrelated CLIs that accept unknown flags or emit other JSON.
if (typeof parsed.isAuthenticated !== "boolean" && typeof parsed.status !== "string") {
return null;
}
return parsed;
}
/**
@@ -260,4 +279,6 @@ module.exports = {
CURSOR_CLI_BINARY_CANDIDATES,
defaultRunSecurity,
defaultRunGhAuthStatus,
parseCursorStatusJson,
resolveCursorCliSpawnSpec,
};
@@ -2,7 +2,10 @@ const test = require("node:test");
const assert = require("node:assert/strict");
const {
probeClaudeAuth, probeCopilotAuth, probeCodexAuth, probeCodebuddyAuth, probeCursorCliAuth, probeGrokAuth,
parseCursorStatusJson, resolveCursorCliSpawnSpec,
} = require("./agentAuthProbes.cjs");
const { prepareCommandForSpawn } = require("../ai/shellUtils.cjs");
const { resolveCursorCliSpawnSpec: resolveSharedCursorCliSpawnSpec } = require("./cursorCliSpawn.cjs");
test("probeClaudeAuth: env ANTHROPIC_API_KEY -> authenticated env", () => {
const r = probeClaudeAuth({
@@ -271,3 +274,40 @@ test("probeCursorCliAuth: status command failure -> not authenticated", () => {
});
assert.equal(r.authenticated, false);
});
test("probeCursorCliAuth: extracts authenticated JSON wrapped in cmd noise", () => {
const r = probeCursorCliAuth({
resolveBinary: () => "C:\\Users\\me\\AppData\\Local\\cursor-agent\\cursor-agent.cmd",
runStatus: () => ({
exitCode: 0,
stdout: "Starting...\r\n{\"status\":\"authenticated\",\"isAuthenticated\":true,\"userInfo\":{\"email\":\"user@example.com\"}}\r\n",
}),
});
assert.equal(r.authenticated, true);
assert.equal(r.authSource, "cli-login");
assert.equal(r.email, "user@example.com");
});
test("parseCursorStatusJson accepts BOM and surrounding text", () => {
const parsed = parseCursorStatusJson(
"\uFEFFnoise\n{\"isAuthenticated\":true,\"status\":\"authenticated\"}\n",
);
assert.equal(parsed.isAuthenticated, true);
assert.equal(parsed.status, "authenticated");
});
test("resolveCursorCliSpawnSpec re-exports the shared native launch helper", () => {
const shim = "C:\\Users\\me\\AppData\\Local\\cursor-agent\\cursor-agent.cmd";
const args = ["status", "--format", "json"];
assert.deepEqual(
resolveCursorCliSpawnSpec(shim, args),
resolveSharedCursorCliSpawnSpec(shim, args),
);
assert.deepEqual(
resolveSharedCursorCliSpawnSpec(shim, args, {
exists: () => false,
readFile: () => { throw new Error("missing"); },
}),
prepareCommandForSpawn(shim, args, { unwrapNativeExe: false }),
);
});
@@ -0,0 +1,124 @@
"use strict";
/**
* Windows launch helper for the Cursor Agent installer shim.
*
* `%LOCALAPPDATA%\cursor-agent\cursor-agent.cmd` is a batch file that runs
* `versions\<id>\node.exe` + `versions\<id>\index.js`. Node cannot spawn .cmd
* directly (EINVAL). Routing the full turn prompt through cmd.exe is also
* unsafe: 8191-char limit, %VAR% expansion, and broken quote escaping.
*
* Prefer the native node+script argv so prompts stay out of a shell.
*/
const fs = require("node:fs");
const path = require("node:path");
const { prepareCommandForSpawn } = require("../ai/shellUtils.cjs");
function defaultExists(filePath) {
try { return fs.existsSync(filePath); } catch { return false; }
}
function defaultReadFile(filePath) {
return fs.readFileSync(filePath, "utf8");
}
function defaultReaddir(dirPath) {
return fs.readdirSync(dirPath);
}
function defaultStat(filePath) {
return fs.statSync(filePath);
}
function expandWindowsShimPath(raw, shimDir) {
const dp0 = /[\\/]$/.test(shimDir) ? shimDir : `${shimDir}${path.sep}`;
let resolved = String(raw || "").replace(/%~dp0/gi, dp0);
// Installer shims use Windows separators; normalize so existsSync works
// when this helper is unit-tested on POSIX.
resolved = resolved.replace(/\\/g, path.sep);
if (!path.isAbsolute(resolved) && !path.win32.isAbsolute(resolved)) {
resolved = path.resolve(shimDir, resolved);
}
return path.normalize(resolved);
}
function parseCursorAgentCmdLaunch(shimPath, { exists, readFile } = {}) {
const existsFn = exists || defaultExists;
const readFn = readFile || defaultReadFile;
let contents;
try {
contents = readFn(shimPath);
} catch {
return null;
}
const match = String(contents || "").match(/"([^"\r\n]*node\.exe)"\s+"([^"\r\n]*index\.js)"/i);
if (!match) return null;
const shimDir = path.dirname(shimPath);
const nodeExe = expandWindowsShimPath(match[1], shimDir);
const script = expandWindowsShimPath(match[2], shimDir);
if (!existsFn(nodeExe) || !existsFn(script)) return null;
return { nodeExe, script };
}
function resolveCursorAgentVersionsLaunch(installDir, { exists, readdir, stat } = {}) {
const existsFn = exists || defaultExists;
const readdirFn = readdir || defaultReaddir;
const statFn = stat || defaultStat;
const versionsDir = path.join(installDir, "versions");
if (!existsFn(versionsDir)) return null;
let names;
try {
names = readdirFn(versionsDir);
} catch {
return null;
}
const candidates = [];
for (const name of names) {
const dir = path.join(versionsDir, name);
const nodeExe = path.join(dir, "node.exe");
const script = path.join(dir, "index.js");
if (!existsFn(nodeExe) || !existsFn(script)) continue;
let mtime = 0;
try { mtime = Number(statFn(dir)?.mtimeMs) || 0; } catch { /* ignore */ }
candidates.push({ name: String(name), nodeExe, script, mtime });
}
if (candidates.length === 0) return null;
candidates.sort((a, b) => b.mtime - a.mtime || b.name.localeCompare(a.name));
return { nodeExe: candidates[0].nodeExe, script: candidates[0].script };
}
function resolveCursorAgentNativeLaunch(binPath, io = {}) {
const normalized = String(binPath || "").trim();
if (!normalized) return null;
const ext = path.extname(normalized).toLowerCase();
if (ext !== ".cmd" && ext !== ".bat") return null;
const fromShim = parseCursorAgentCmdLaunch(normalized, io);
if (fromShim) return fromShim;
return resolveCursorAgentVersionsLaunch(path.dirname(normalized), io);
}
function resolveCursorCliSpawnSpec(binPath, args, io = {}) {
const command = String(binPath || "").trim();
const spawnArgs = Array.isArray(args) ? args : [];
const native = resolveCursorAgentNativeLaunch(command, io);
if (native) {
return {
command: native.nodeExe,
args: [native.script, ...spawnArgs],
shell: false,
};
}
// Last resort for unknown shims / short probes (status, models). Turns with
// a long prompt should have resolved the official versions/ layout above.
return prepareCommandForSpawn(command, spawnArgs, { unwrapNativeExe: false });
}
module.exports = {
expandWindowsShimPath,
parseCursorAgentCmdLaunch,
resolveCursorAgentNativeLaunch,
resolveCursorAgentVersionsLaunch,
resolveCursorCliSpawnSpec,
};
@@ -0,0 +1,107 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { prepareCommandForSpawn } = require("../ai/shellUtils.cjs");
const {
expandWindowsShimPath,
parseCursorAgentCmdLaunch,
resolveCursorAgentNativeLaunch,
resolveCursorAgentVersionsLaunch,
resolveCursorCliSpawnSpec,
} = require("./cursorCliSpawn.cjs");
function writeCursorAgentInstall(root, version = "2026.06.01-abc") {
const versionDir = path.join(root, "versions", version);
fs.mkdirSync(versionDir, { recursive: true });
const nodeExe = path.join(versionDir, "node.exe");
const script = path.join(versionDir, "index.js");
fs.writeFileSync(nodeExe, "", "utf8");
fs.writeFileSync(script, "", "utf8");
const shimPath = path.join(root, "cursor-agent.cmd");
fs.writeFileSync(
shimPath,
`@ECHO off\r\n"%~dp0\\versions\\${version}\\node.exe" "%~dp0\\versions\\${version}\\index.js" %*\r\n`,
"utf8",
);
return { shimPath, nodeExe, script, versionDir };
}
test("expandWindowsShimPath expands %~dp0 relative to the shim directory", () => {
const shimDir = path.join("C:", "Users", "me", "AppData", "Local", "cursor-agent");
const resolved = expandWindowsShimPath("%~dp0\\versions\\2026.06.01-abc\\node.exe", shimDir);
assert.equal(
path.normalize(resolved),
path.normalize(path.join(shimDir, "versions", "2026.06.01-abc", "node.exe")),
);
});
test("parseCursorAgentCmdLaunch reads node.exe + index.js from the installer shim", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-shim-"));
try {
const { shimPath, nodeExe, script } = writeCursorAgentInstall(tmp);
const launch = parseCursorAgentCmdLaunch(shimPath);
assert.deepEqual(launch, { nodeExe, script });
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCursorAgentVersionsLaunch picks the newest version directory", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-versions-"));
try {
const older = writeCursorAgentInstall(tmp, "2026.01.01-old");
const newer = writeCursorAgentInstall(tmp, "2026.08.01-new");
const olderTime = new Date("2026-01-01T00:00:00Z");
const newerTime = new Date("2026-08-01T00:00:00Z");
fs.utimesSync(older.versionDir, olderTime, olderTime);
fs.utimesSync(newer.versionDir, newerTime, newerTime);
const launch = resolveCursorAgentVersionsLaunch(tmp);
assert.deepEqual(launch, { nodeExe: newer.nodeExe, script: newer.script });
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCursorAgentNativeLaunch prefers the shim's node+script over a lone exe unwrap", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-native-"));
try {
const { shimPath, nodeExe, script } = writeCursorAgentInstall(tmp);
const launch = resolveCursorAgentNativeLaunch(shimPath);
assert.deepEqual(launch, { nodeExe, script });
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCursorCliSpawnSpec puts the prompt on argv, not a cmd.exe line", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-spawn-"));
try {
const { shimPath, nodeExe, script } = writeCursorAgentInstall(tmp);
const prompt = 'do "%USERPROFILE%" and `whoami` then say hello';
const spec = resolveCursorCliSpawnSpec(shimPath, ["--print", "--trust", prompt]);
assert.deepEqual(spec, {
command: nodeExe,
args: [script, "--print", "--trust", prompt],
shell: false,
});
assert.equal(spec.command.includes("cmd.exe"), false);
assert.equal(spec.args.includes(prompt), true);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCursorCliSpawnSpec falls back to the cmd shim when versions/ is missing", () => {
const shim = "C:\\Users\\me\\AppData\\Local\\cursor-agent\\cursor-agent.cmd";
const args = ["status", "--format", "json"];
const spec = resolveCursorCliSpawnSpec(shim, args, {
exists: () => false,
readFile: () => { throw new Error("missing"); },
});
assert.deepEqual(spec, prepareCommandForSpawn(shim, args, { unwrapNativeExe: false }));
});
@@ -10,6 +10,7 @@ const { spawn } = require("node:child_process");
const { StringDecoder } = require("node:string_decoder");
const fs = require("node:fs");
const path = require("node:path");
const { resolveCursorCliSpawnSpec } = require("../cursorCliSpawn.cjs");
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
const DEFAULT_CURSOR_CLI_MODEL = "auto";
@@ -56,6 +57,15 @@ function stripCursorApiKeyFromEnv(env) {
return out;
}
function spawnCursorCliProcess(spawnImpl, cliPath, args, options = {}) {
const spawnFn = spawnImpl || spawn;
const spawnSpec = resolveCursorCliSpawnSpec(cliPath, args);
return spawnFn(spawnSpec.command, spawnSpec.args, {
...options,
shell: spawnSpec.shell,
});
}
function resolveCursorCliModel(model) {
const raw = String(model || "").trim();
return raw || DEFAULT_CURSOR_CLI_MODEL;
@@ -520,7 +530,6 @@ async function runCursorCliTurn({
failed: false,
};
const spawnFn = spawnImpl || spawn;
let child = null;
let settled = false;
@@ -529,7 +538,7 @@ async function runCursorCliTurn({
};
try {
child = spawnFn(cliPath, args, {
child = spawnCursorCliProcess(spawnImpl, cliPath, args, {
cwd: effectiveCwd,
env: childEnv,
stdio: ["ignore", "pipe", "pipe"],
@@ -667,7 +676,6 @@ async function listCursorCliModels({
if (abortSignal?.aborted) return { currentModelId: null, models: [] };
const childEnv = stripCursorApiKeyFromEnv(env || process.env);
const spawnFn = spawnImpl || spawn;
return await new Promise((resolve) => {
let stdout = "";
@@ -690,7 +698,7 @@ async function listCursorCliModels({
let child;
try {
child = spawnFn(cliPath, ["models"], {
child = spawnCursorCliProcess(spawnImpl, cliPath, ["models"], {
env: childEnv,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
@@ -771,8 +779,10 @@ module.exports = {
resetMcpMergeRefcountsForTests,
resolveCursorCliExecMode,
resolveCursorCliModel,
resolveCursorCliSpawnSpec,
resolveCursorCliWorkspaceCwd,
runCursorCliTurn,
spawnCursorCliProcess,
stripCursorApiKeyFromEnv,
translateCursorCliEvent,
};
@@ -13,8 +13,10 @@ const {
resetMcpMergeRefcountsForTests,
resolveCursorCliExecMode,
resolveCursorCliModel,
resolveCursorCliSpawnSpec,
resolveCursorCliWorkspaceCwd,
runCursorCliTurn,
spawnCursorCliProcess,
stripCursorApiKeyFromEnv,
translateCursorCliEvent,
} = require("./cursorCliDriver.cjs");
@@ -587,6 +589,83 @@ test("runCursorCliTurn closes open reasoning before done", async () => {
]);
});
test("resolveCursorCliSpawnSpec keeps a native exe on argv without a shell", () => {
const exePath = process.platform === "win32"
? "C:\\Users\\me\\AppData\\Local\\cursor-agent\\cursor-agent.exe"
: "/usr/local/bin/cursor-agent";
const args = ["--print", "--trust"];
const exe = resolveCursorCliSpawnSpec(exePath, args);
assert.equal(exe.shell, false);
assert.equal(exe.command, exePath);
assert.deepEqual(exe.args, args);
});
test("spawnCursorCliProcess launches the installer node+script with the prompt on argv", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-turn-spawn-"));
try {
const versionDir = path.join(tmp, "versions", "2026.06.01-abc");
fs.mkdirSync(versionDir, { recursive: true });
const nodeExe = path.join(versionDir, "node.exe");
const script = path.join(versionDir, "index.js");
fs.writeFileSync(nodeExe, "", "utf8");
fs.writeFileSync(script, "", "utf8");
const shimPath = path.join(tmp, "cursor-agent.cmd");
fs.writeFileSync(
shimPath,
`@ECHO off\r\n"%~dp0\\versions\\2026.06.01-abc\\node.exe" "%~dp0\\versions\\2026.06.01-abc\\index.js" %*\r\n`,
"utf8",
);
const prompt = 'review "%TEMP%" then run whoami';
const calls = [];
spawnCursorCliProcess(
(command, args, options) => {
calls.push({ command, args, options });
return { stdout: { on() {} }, stderr: { on() {} }, on() {}, kill() {} };
},
shimPath,
["--print", "--trust", prompt],
{ windowsHide: true },
);
assert.equal(calls.length, 1);
assert.equal(calls[0].command, nodeExe);
assert.deepEqual(calls[0].args, [script, "--print", "--trust", prompt]);
assert.equal(calls[0].options.shell, false);
assert.equal(String(calls[0].command).includes("cmd.exe"), false);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("spawnCursorCliProcess forwards shell from resolveCursorCliSpawnSpec", () => {
const calls = [];
const fakeChild = {
stdout: { on() {} },
stderr: { on() {} },
stdin: null,
on() {},
kill() {},
};
const cliPath = "/usr/local/bin/cursor-agent";
const child = spawnCursorCliProcess(
(command, args, options) => {
calls.push({ command, args, options });
return fakeChild;
},
cliPath,
["models"],
{ cwd: "/repo", windowsHide: true },
);
assert.equal(child, fakeChild);
assert.equal(calls.length, 1);
assert.equal(calls[0].command, cliPath);
assert.deepEqual(calls[0].args, ["models"]);
assert.equal(calls[0].options.cwd, "/repo");
assert.equal(calls[0].options.windowsHide, true);
assert.equal(calls[0].options.shell, false);
});
test("listCursorCliModels parses agent models output and prefers auto", async () => {
const fakeChild = new EventEmitter();
fakeChild.stdout = new EventEmitter();