1// @effect-diagnostics nodeBuiltinImport:off globalTimers:off -- The probe is a plain Node child; this deadline kills it if it hangs.
2import * as NodeChildProcess from "node:child_process";
3import { describe, expect, it } from "vite-plus/test";
4
5import { isCursorShellSpawnFailure } from "./CursorSdk.ts";
6
7const cursorSdkUrl = new URL("./CursorSdk.ts", import.meta.url).href;
8
9// Builds the CursorSdk layer in a fresh process. @cursor/sdk is stubbed so the guard
10// can be tested without the real package. The vitest worker already has its
11// own unhandledRejection listener, which would hide the process-exit behavior.
12const probeProgram = `
13import { mkdtempSync, writeFileSync } from "node:fs";
14import { tmpdir } from "node:os";
15import { join } from "node:path";
16import { registerHooks } from "node:module";
17import { pathToFileURL } from "node:url";
18import { spawn } from "node:child_process";
19
20const dir = mkdtempSync(join(tmpdir(), "cursor-sdk-stub-"));
21const stub = join(dir, "stub.cjs");
22writeFileSync(
23 stub,
24 "module.exports = { Agent: {}, AuthenticationError: class {}, createAgentPlatform: () => ({}), Cursor: {}, CursorSdkError: class {}, InMemoryCredentialStore: class {} };",
25);
26registerHooks({
27 resolve(specifier, context, next) {
28 if (specifier === "@cursor/sdk" || specifier === "@cursor/sdk/sqlite") {
29 return { url: pathToFileURL(stub).href, shortCircuit: true };
30 }
31 return next(specifier, context);
32 },
33});
34
35const { layer } = await import(${JSON.stringify(cursorSdkUrl)});
36const Effect = await import("effect/Effect");
37const Layer = await import("effect/Layer");
38const Exit = await import("effect/Exit");
39const Scope = await import("effect/Scope");
40const scope = Scope.makeUnsafe();
41await Effect.runPromise(Layer.buildWithScope(layer, scope));
42const mode = process.argv[1];
43const missingCwd = join(tmpdir(), "t3-missing-cwd-" + process.pid);
44
45if (mode === "cursor-shell") {
46 const child = spawn("/bin/zsh", ["-c", "dump_zsh_state >&4", "--", "true"], {
47 cwd: missingCwd,
48 });
49 child.on("error", (error) => {
50 Promise.reject(error);
51 });
52 setTimeout(() => process.exit(0), 500);
53} else if (mode === "released") {
54 await Effect.runPromise(Scope.close(scope, Exit.void));
55 Promise.reject(
56 Object.assign(new Error("spawn /bin/zsh ENOENT"), {
57 code: "ENOENT",
58 syscall: "spawn /bin/zsh",
59 spawnargs: ["-c", "dump_zsh_state >&4"],
60 }),
61 );
62 setTimeout(() => process.exit(0), 500);
63} else if (mode === "other-spawn") {
64 Promise.reject(
65 Object.assign(new Error("spawn git ENOENT"), {
66 code: "ENOENT",
67 syscall: "spawn git",
68 path: "git",
69 spawnargs: ["status"],
70 }),
71 );
72 setTimeout(() => process.exit(0), 500);
73} else if (mode === "other-rejection") {
74 Promise.reject(new Error("boom"));
75 setTimeout(() => process.exit(0), 500);
76} else if (mode === "spawn-without-listener") {
77 spawn(process.execPath, ["-e", "process.exit(0)"], { cwd: missingCwd });
78 setTimeout(() => process.exit(0), 500);
79} else {
80 console.error("Unknown cursor shell spawn guard probe: " + mode);
81 process.exit(2);
82}
83`;
84
85function runProbe(mode: string): Promise<{ code: number | null; stdout: string; stderr: string }> {
86 return new Promise((resolve, reject) => {
87 const child = NodeChildProcess.spawn(
88 process.execPath,
89 [
90 "--experimental-strip-types",
91 "--disable-warning=ExperimentalWarning",
92 "--input-type=module",
93 "-e",
94 probeProgram,
95 mode,
96 ],
97 { stdio: ["ignore", "pipe", "pipe"] },
98 );
99 let stdout = "";
100 let stderr = "";
101 child.stdout.setEncoding("utf8");
102 child.stderr.setEncoding("utf8");
103 child.stdout.on("data", (chunk: string) => {
104 stdout += chunk;
105 });
106 child.stderr.on("data", (chunk: string) => {
107 stderr += chunk;
108 });
109 const timer = setTimeout(() => {
110 child.kill();
111 reject(new Error(`probe ${mode} timed out`));
112 }, 5_000);
113 child.on("error", reject);
114 child.on("exit", (code) => {
115 clearTimeout(timer);
116 resolve({ code, stdout, stderr });
117 });
118 });
119}
120
121describe("isCursorShellSpawnFailure", () => {
122 it("matches only Cursor's shell wrapper", () => {
123 const spawnFailure = (syscall: string, spawnargs: ReadonlyArray<string>) =>
124 Object.assign(new Error(`${syscall} ENOENT`), { code: "ENOENT", syscall, spawnargs });
125 expect(
126 isCursorShellSpawnFailure(
127 spawnFailure("spawn /bin/zsh", ["-c", "dump_zsh_state >&4", "--", "true"]),
128 ),
129 ).toBe(true);
130 expect(
131 isCursorShellSpawnFailure(spawnFailure("spawn bash", ["-c", "dump_bash_state >&4"])),
132 ).toBe(true);
133 expect(
134 isCursorShellSpawnFailure(
135 spawnFailure("spawn /bin/zsh", ["-c", 'builtin eval "${__CURSOR_SANDBOX_ENV_RESTORE:-}"']),
136 ),
137 ).toBe(true);
138 expect(isCursorShellSpawnFailure(spawnFailure("spawn git", ["status"]))).toBe(false);
139 expect(isCursorShellSpawnFailure(spawnFailure("spawn /bin/zsh", ["-lc", "true"]))).toBe(false);
140 expect(
141 isCursorShellSpawnFailure(
142 Object.assign(new Error("open failed"), { code: "ENOENT", syscall: "open" }),
143 ),
144 ).toBe(false);
145 expect(isCursorShellSpawnFailure(new Error("boom"))).toBe(false);
146 expect(isCursorShellSpawnFailure("spawn ENOENT")).toBe(false);
147 });
148});
149
150describe("Cursor shell spawn guard", () => {
151 it("keeps the process alive when Cursor's shell spawn rejects", async () => {
152 const result = await runProbe("cursor-shell");
153 expect(result.code).toBe(0);
154 expect(result.stderr).toContain("The server will keep running.");
155 expect(result.stderr).toContain("ENOENT");
156 });
157
158 it("still exits when a different spawn failure is unhandled", async () => {
159 const result = await runProbe("other-spawn");
160 expect(result.code).toBe(1);
161 expect(result.stderr).toContain("spawn git ENOENT");
162 expect(result.stderr).not.toContain("The server will keep running.");
163 });
164
165 it("still exits on unrelated unhandled rejections", async () => {
166 const result = await runProbe("other-rejection");
167 expect(result.code).toBe(1);
168 expect(result.stderr).toContain("boom");
169 });
170
171 it("removes the guard when the layer is released", async () => {
172 const result = await runProbe("released");
173 expect(result.code).toBe(1);
174 expect(result.stderr).not.toContain("The server will keep running.");
175 });
176
177 it("leaves a spawn with no error listener fatal", async () => {
178 const result = await runProbe("spawn-without-listener");
179 expect(result.code).toBe(1);
180 expect(result.stderr).toContain("Unhandled");
181 expect(result.stderr).not.toContain("The server will keep running.");
182 });
183});