authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 10:11:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 10:11:34-07:00
log33806e73555107b8ac8fb18fd15b9dfb87ec6557
tree54901ad6c38609e39e20bcc9448e6e7b4186fae4
parentf853d514cab475e73468f7cb7bab1aae48dbe549
signature Signed by PGP key B5690EEEBB952194

refactor(provider-cursor): follow the Effect service conventions (#17545)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

18 files changed, 378 insertions(+), 360 deletions(-)

apps/server/scripts/record-cursor-agent-sdk-replay-fixture.ts+3-1
...@@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Config from "effect/Config";3import * as Config from "effect/Config";
4import * as Console from "effect/Console";4import * as Console from "effect/Console";
5import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
6import * as FileSystem from "effect/FileSystem";7import * as FileSystem from "effect/FileSystem";
7import * as Option from "effect/Option";8import * as Option from "effect/Option";
8import * as Path from "effect/Path";9import * as Path from "effect/Path";
...@@ -10,6 +11,7 @@ import * as Redacted from "effect/Redacted";...@@ -10,6 +11,7 @@ import * as Redacted from "effect/Redacted";
10import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
11import * as Struct from "effect/Struct";12import * as Struct from "effect/Struct";
12import { Command, Flag } from "effect/cli";13import { Command, Flag } from "effect/cli";
14import * as CursorSdk from "@t3tools/provider-cursor/server/CursorSdk";
1315
14import {16import {
15 recordCursorAgentSdkReplayTranscript,17 recordCursorAgentSdkReplayTranscript,
...@@ -219,6 +221,6 @@ const recordCursorReplayCommand = Command.make(...@@ -219,6 +221,6 @@ const recordCursorReplayCommand = Command.make(
219);221);
220222
221Command.run(recordCursorReplayCommand, { version: "0.0.0" }).pipe(223Command.run(recordCursorReplayCommand, { version: "0.0.0" }).pipe(
222 Effect.provide(NodeServices.layer),224 Effect.provide(Layer.mergeAll(CursorSdk.layer, NodeServices.layer)),
223 NodeRuntime.runMain,225 NodeRuntime.runMain,
224);226);
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts+4-4
...@@ -213,7 +213,7 @@ function replayRunnerError(...@@ -213,7 +213,7 @@ function replayRunnerError(
213213
214export function makeCursorAgentSdkReplayRunner(214export function makeCursorAgentSdkReplayRunner(
215 transcript: CursorAgentSdkReplayTranscript,215 transcript: CursorAgentSdkReplayTranscript,
216): CursorAgentSdk.CursorAgentSdkRunnerShape {216): CursorAgentSdk.CursorAgentSdkRunner["Service"] {
217 let cursor = 0;217 let cursor = 0;
218 let failure: CursorAgentSdkReplayError | null = null;218 let failure: CursorAgentSdkReplayError | null = null;
219 let cursorAdvanced = makeSignal();219 let cursorAdvanced = makeSignal();
...@@ -491,7 +491,7 @@ export function makeCursorAgentSdkReplayRunner(...@@ -491,7 +491,7 @@ export function makeCursorAgentSdkReplayRunner(
491function layerCursorAgentSdkReplay(491function layerCursorAgentSdkReplay(
492 transcript: CursorAgentSdkReplayTranscript,492 transcript: CursorAgentSdkReplayTranscript,
493 options?: {493 options?: {
494 readonly runner?: CursorAgentSdk.CursorAgentSdkRunnerShape;494 readonly runner?: CursorAgentSdk.CursorAgentSdkRunner["Service"];
495 readonly assertCompleteOnFinalize?: boolean;495 readonly assertCompleteOnFinalize?: boolean;
496 },496 },
497): Layer.Layer<CursorAgentSdk.CursorAgentSdkRunner> {497): Layer.Layer<CursorAgentSdk.CursorAgentSdkRunner> {
...@@ -512,7 +512,7 @@ function layerCursorAgentSdkReplay(...@@ -512,7 +512,7 @@ function layerCursorAgentSdkReplay(
512export function layer(512export function layer(
513 transcript: CursorAgentSdkReplayTranscript,513 transcript: CursorAgentSdkReplayTranscript,
514 options?: {514 options?: {
515 readonly runner?: CursorAgentSdk.CursorAgentSdkRunnerShape;515 readonly runner?: CursorAgentSdk.CursorAgentSdkRunner["Service"];
516 readonly assertCompleteOnFinalize?: boolean;516 readonly assertCompleteOnFinalize?: boolean;
517 },517 },
518) {518) {
...@@ -807,7 +807,7 @@ export const recordCursorAgentSdkReplayTranscript = Effect.fn(...@@ -807,7 +807,7 @@ export const recordCursorAgentSdkReplayTranscript = Effect.fn(
807 heldUntilCancel = [];807 heldUntilCancel = [];
808 }808 }
809 });809 });
810 const runner = CursorAgentSdk.makeCursorAgentSdkRunner(() => recordFrame);810 const runner = yield* CursorAgentSdk.makeCursorAgentSdkRunner(() => recordFrame);
811811
812 const awaitSignal = (signal: Deferred.Deferred<void>, description: string) =>812 const awaitSignal = (signal: Deferred.Deferred<void>, description: string) =>
813 Deferred.await(signal).pipe(813 Deferred.await(signal).pipe(
apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts+3-1
...@@ -3,6 +3,7 @@ import * as Layer from "effect/Layer";...@@ -3,6 +3,7 @@ import * as Layer from "effect/Layer";
3import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts";3import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts";
4import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts";4import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts";
5import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk";5import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk";
6import * as CursorSdk from "@t3tools/provider-cursor/server/CursorSdk";
6import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";7import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
7import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";8import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
89
...@@ -10,6 +11,7 @@ export type ProviderOrchestrationAdapterInfrastructure =...@@ -10,6 +11,7 @@ export type ProviderOrchestrationAdapterInfrastructure =
10 | ClaudeAdapterV2.ClaudeAgentSdkQueryRunner11 | ClaudeAdapterV2.ClaudeAgentSdkQueryRunner
11 | CodexAdapterV2.CodexAppServerClientFactory12 | CodexAdapterV2.CodexAppServerClientFactory
12 | CursorAgentSdk.CursorAgentSdkRunner13 | CursorAgentSdk.CursorAgentSdkRunner
14 | CursorSdk.CursorSdk
13 | IdAllocator.IdAllocatorV2;15 | IdAllocator.IdAllocatorV2;
1416
15/**17/**
...@@ -21,7 +23,7 @@ export type ProviderOrchestrationAdapterInfrastructure =...@@ -21,7 +23,7 @@ export type ProviderOrchestrationAdapterInfrastructure =
21export const layer = Layer.mergeAll(23export const layer = Layer.mergeAll(
22 ClaudeAdapterV2.layerQueryRunner,24 ClaudeAdapterV2.layerQueryRunner,
23 CodexAdapterV2.layerAppServerClientFactory,25 CodexAdapterV2.layerAppServerClientFactory,
24 CursorAgentSdk.layer,26 CursorAgentSdk.layer.pipe(Layer.provideMerge(CursorSdk.layer)),
25 IdAllocator.layer,27 IdAllocator.layer,
26 ProviderContinuationRequests.layer,28 ProviderContinuationRequests.layer,
27);29);
packages/provider-cursor/package.json+4
...@@ -22,6 +22,10 @@...@@ -22,6 +22,10 @@
22 "./server/CursorAgentSdk": {22 "./server/CursorAgentSdk": {
23 "types": "./src/server/CursorAgentSdk.ts",23 "types": "./src/server/CursorAgentSdk.ts",
24 "import": "./src/server/CursorAgentSdk.ts"24 "import": "./src/server/CursorAgentSdk.ts"
25 },
26 "./server/CursorSdk": {
27 "types": "./src/server/CursorSdk.ts",
28 "import": "./src/server/CursorSdk.ts"
25 }29 }
26 },30 },
27 "scripts": {31 "scripts": {
packages/provider-cursor/src/server/CursorAgentSdk.test.ts+3
...@@ -7,6 +7,7 @@ import { vi } from "vite-plus/test";...@@ -7,6 +7,7 @@ import { vi } from "vite-plus/test";
77
8import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";8import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
9import * as CursorAgentSdk from "./CursorAgentSdk.ts";9import * as CursorAgentSdk from "./CursorAgentSdk.ts";
10import * as CursorSdk from "./CursorSdk.ts";
1011
11const cursorSdkMock = vi.hoisted(() => {12const cursorSdkMock = vi.hoisted(() => {
12 const closeExecutionOrder: Array<string> = [];13 const closeExecutionOrder: Array<string> = [];
...@@ -102,6 +103,7 @@ vi.mock("./sdk.ts", () => ({...@@ -102,6 +103,7 @@ vi.mock("./sdk.ts", () => ({
102 list: vi.fn(async () => []),103 list: vi.fn(async () => []),
103 },104 },
104 },105 },
106 createAgentPlatform: vi.fn(),
105}));107}));
106108
107const layerTest = CursorAgentSdk.layer.pipe(109const layerTest = CursorAgentSdk.layer.pipe(
...@@ -124,6 +126,7 @@ const layerTest = CursorAgentSdk.layer.pipe(...@@ -124,6 +126,7 @@ const layerTest = CursorAgentSdk.layer.pipe(
124 canonical: undefined,126 canonical: undefined,
125 }),127 }),
126 ),128 ),
129 Layer.provide(CursorSdk.layer),
127 Layer.provide(NodeServices.layer),130 Layer.provide(NodeServices.layer),
128);131);
129132
packages/provider-cursor/src/server/CursorAgentSdk.ts+51-49
...@@ -12,11 +12,13 @@ import {...@@ -12,11 +12,13 @@ import {
12 type ThreadId,12 type ThreadId,
13} from "@t3tools/contracts";13} from "@t3tools/contracts";
14import * as Context from "effect/Context";14import * as Context from "effect/Context";
15import * as Deferred from "effect/Deferred";
15import * as Effect from "effect/Effect";16import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";17import * as Layer from "effect/Layer";
18import * as Ref from "effect/Ref";
17import * as Schema from "effect/Schema";19import * as Schema from "effect/Schema";
1820
19import { Agent, createAgentPlatform } from "./sdk.ts";21import * as CursorSdk from "./CursorSdk.ts";
20import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";22import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
2123
22export const CURSOR_AGENT_SDK_PROTOCOL = "cursor-agent-sdk.local" as const;24export const CURSOR_AGENT_SDK_PROTOCOL = "cursor-agent-sdk.local" as const;
...@@ -66,16 +68,14 @@ export interface CursorAgentSdkSession {...@@ -66,16 +68,14 @@ export interface CursorAgentSdkSession {
66 readonly close: Effect.Effect<void, CursorAgentSdkRunnerError>;68 readonly close: Effect.Effect<void, CursorAgentSdkRunnerError>;
67}69}
6870
69export interface CursorAgentSdkRunnerShape {
70 readonly open: (
71 input: CursorAgentSdkOpenInput,
72 ) => Effect.Effect<CursorAgentSdkSession, CursorAgentSdkRunnerError>;
73 readonly assertComplete: Effect.Effect<void, CursorAgentSdkRunnerError>;
74}
75
76export class CursorAgentSdkRunner extends Context.Service<71export class CursorAgentSdkRunner extends Context.Service<
77 CursorAgentSdkRunner,72 CursorAgentSdkRunner,
78 CursorAgentSdkRunnerShape73 {
74 readonly open: (
75 input: CursorAgentSdkOpenInput,
76 ) => Effect.Effect<CursorAgentSdkSession, CursorAgentSdkRunnerError>;
77 readonly assertComplete: Effect.Effect<void, CursorAgentSdkRunnerError>;
78 }
79>()("@t3tools/provider-cursor/server/CursorAgentSdk/CursorAgentSdkRunner") {}79>()("@t3tools/provider-cursor/server/CursorAgentSdk/CursorAgentSdkRunner") {}
8080
81export interface CursorAgentSdkLoggedAgentOptions {81export interface CursorAgentSdkLoggedAgentOptions {
...@@ -298,49 +298,55 @@ function makeCursorAgentSdkProtocolLogger(input: {...@@ -298,49 +298,55 @@ function makeCursorAgentSdkProtocolLogger(input: {
298 .pipe(Effect.ignore);298 .pipe(Effect.ignore);
299}299}
300300
301/**
302 * The Cursor SDK decides once per process whether local sandboxing works, and
303 * caches the answer the first time any run starts. Only sandboxed runs point
304 * it at its `cursorsandbox` helper first, so after an unsandboxed (Full access)
305 * run it caches "unsupported" and rejects every later sandboxed run until the
306 * server restarts. Warming a bare sandboxed executor before the first
307 * unsandboxed agent opens lets the SDK find the helper and cache the real
308 * answer. Warming is best effort: on a machine without sandbox support it
309 * fails, the SDK caches "unsupported", and sandboxed runs report that as
310 * before.
311 */
312let cursorSandboxSupportPrime: Promise<void> | undefined;
313
314function primeCursorSandboxSupport(options: AgentOptions): Promise<void> {
315 cursorSandboxSupportPrime ??= (async () => {
316 const cwd = typeof options.local?.cwd === "string" ? options.local.cwd : undefined;
317 const platform = await createAgentPlatform(cwd === undefined ? {} : { workspaceRef: cwd });
318 const release = await platform.prewarmLocalWorkspace({
319 ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
320 local: {
321 ...(cwd === undefined ? {} : { cwd }),
322 settingSources: [],
323 sandboxOptions: { enabled: true },
324 },
325 });
326 await release();
327 })().catch(() => undefined);
328 return cursorSandboxSupportPrime;
329}
330
331/**301/**
332 * Runs agents through the Cursor SDK, logging every frame to the protocol302 * Runs agents through the Cursor SDK, logging every frame to the protocol
333 * logger chosen for each opened agent. The live layer writes the native303 * logger chosen for each opened agent. The live layer writes the native
334 * provider event log; the replay recorder turns the same frames into a304 * provider event log; the replay recorder turns the same frames into a
335 * transcript.305 * transcript.
336 */306 */
337export function makeCursorAgentSdkRunner(307export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(function* (
338 protocolLoggerFor: (input: CursorAgentSdkOpenInput) => CursorAgentSdkProtocolLogger | undefined,308 protocolLoggerFor: (input: CursorAgentSdkOpenInput) => CursorAgentSdkProtocolLogger | undefined,
339): CursorAgentSdkRunnerShape {309) {
310 const { Agent, createAgentPlatform } = yield* CursorSdk.CursorSdk;
311
312 /**
313 * The Cursor SDK decides once per process whether local sandboxing works, and
314 * caches the answer the first time any run starts. Only sandboxed runs point
315 * it at its `cursorsandbox` helper first, so after an unsandboxed (Full access)
316 * run it caches "unsupported" and rejects every later sandboxed run until the
317 * server restarts. Warming a bare sandboxed executor before the first
318 * unsandboxed agent opens lets the SDK find the helper and cache the real
319 * answer. Warming is best effort: on a machine without sandbox support it
320 * fails, the SDK caches "unsupported", and sandboxed runs report that as
321 * before.
322 */
323 const sandboxSupportPrimeStarted = yield* Ref.make(false);
324 const sandboxSupportPrimed = yield* Deferred.make<void>();
325 const primeCursorSandboxSupport = Effect.fn("CursorAgentSdkRunner.primeSandboxSupport")(
326 function* (options: AgentOptions) {
327 if (yield* Ref.getAndSet(sandboxSupportPrimeStarted, true)) {
328 return yield* Deferred.await(sandboxSupportPrimed);
329 }
330 yield* Effect.tryPromise(async () => {
331 const cwd = typeof options.local?.cwd === "string" ? options.local.cwd : undefined;
332 const platform = await createAgentPlatform(cwd === undefined ? {} : { workspaceRef: cwd });
333 const release = await platform.prewarmLocalWorkspace({
334 ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
335 local: {
336 ...(cwd === undefined ? {} : { cwd }),
337 settingSources: [],
338 sandboxOptions: { enabled: true },
339 },
340 });
341 await release();
342 }).pipe(Effect.ignore, Effect.ensuring(Deferred.succeed(sandboxSupportPrimed, undefined)));
343 },
344 );
345
340 return CursorAgentSdkRunner.of({346 return CursorAgentSdkRunner.of({
341 open: Effect.fn("CursorAgentSdkRunner.open")(function* (input) {347 open: Effect.fn("CursorAgentSdkRunner.open")(function* (input) {
342 if (input.options.local?.sandboxOptions?.enabled === false) {348 if (input.options.local?.sandboxOptions?.enabled === false) {
343 yield* Effect.promise(() => primeCursorSandboxSupport(input.options));349 yield* primeCursorSandboxSupport(input.options);
344 }350 }
345 const protocolLogger = protocolLoggerFor(input);351 const protocolLogger = protocolLoggerFor(input);
346 const log = (event: CursorAgentSdkProtocolLogEvent) =>352 const log = (event: CursorAgentSdkProtocolLogEvent) =>
...@@ -590,17 +596,13 @@ export function makeCursorAgentSdkRunner(...@@ -590,17 +596,13 @@ export function makeCursorAgentSdkRunner(
590 }),596 }),
591 assertComplete: Effect.void,597 assertComplete: Effect.void,
592 });598 });
593}599});
594600
595export const layer: Layer.Layer<601export const layer = Layer.effect(
596 CursorAgentSdkRunner,
597 never,
598 ProviderEventLoggers.ProviderEventLoggers
599> = Layer.effect(
600 CursorAgentSdkRunner,602 CursorAgentSdkRunner,
601 Effect.gen(function* () {603 Effect.gen(function* () {
602 const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers;604 const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers;
603 return makeCursorAgentSdkRunner((input) =>605 return yield* makeCursorAgentSdkRunner((input) =>
604 makeCursorAgentSdkProtocolLogger({606 makeCursorAgentSdkProtocolLogger({
605 nativeEventLogger,607 nativeEventLogger,
606 threadId: input.threadId,608 threadId: input.threadId,
packages/provider-cursor/src/server/CursorSdk.test.ts created+183
...@@ -0,0 +1,183 @@
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") {
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});
packages/provider-cursor/src/server/CursorSdk.ts created+83
...@@ -0,0 +1,83 @@
1// @effect-diagnostics nodeBuiltinImport:off globalConsole:off -- The guard runs in Node's unhandled-rejection callback, outside an Effect runtime. stderr must match Node's default unhandled-rejection print.
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import { Agent, createAgentPlatform } from "./sdk.ts";
7
8/**
9 * The Cursor Agent SDK runs in this process and spawns a shell for tool
10 * calls. The command is Cursor's sandbox wrapper (`dump_zsh_state`,
11 * `dump_bash_state`, `__CURSOR_SANDBOX_ENV_RESTORE`). A bad working directory
12 * makes Node report `spawn /bin/zsh ENOENT`. The SDK listens for that error,
13 * then rejects an internal promise nothing awaits. Node exits on that
14 * unhandled rejection and takes the server with it.
15 *
16 * The guard ignores only that rejection. Every other unhandled rejection
17 * still prints and exits when this is the process's only listener, matching
18 * Node's default. `child_process.spawn` itself is left alone, so git,
19 * terminals, and other providers keep their own error handling.
20 */
21const CURSOR_SHELL_SPAWN_MARKERS = [
22 "dump_zsh_state",
23 "dump_bash_state",
24 "__CURSOR_SANDBOX_ENV_RESTORE",
25] as const;
26
27export function isCursorShellSpawnFailure(reason: unknown): boolean {
28 if (typeof reason !== "object" || reason === null) {
29 return false;
30 }
31 const syscall = Reflect.get(reason, "syscall");
32 const code = Reflect.get(reason, "code");
33 const spawnargs = Reflect.get(reason, "spawnargs");
34 if (typeof syscall !== "string" || !syscall.startsWith("spawn") || typeof code !== "string") {
35 return false;
36 }
37 if (!Array.isArray(spawnargs)) {
38 return false;
39 }
40 return spawnargs.some(
41 (arg) =>
42 typeof arg === "string" && CURSOR_SHELL_SPAWN_MARKERS.some((marker) => arg.includes(marker)),
43 );
44}
45
46function onUnhandledRejection(reason: unknown): void {
47 if (isCursorShellSpawnFailure(reason)) {
48 console.error("Cursor shell spawn failed. The server will keep running.", reason);
49 return;
50 }
51 // Another handler registered besides this one owns the decision.
52 if (process.listenerCount("unhandledRejection") > 1) {
53 return;
54 }
55 console.error(reason);
56 process.exit(1);
57}
58
59/**
60 * The SDK entry points that run agents in this process. Reaching them through
61 * this service keeps the shell spawn guard armed while they are in use.
62 */
63export class CursorSdk extends Context.Service<
64 CursorSdk,
65 {
66 readonly Agent: typeof Agent;
67 readonly createAgentPlatform: typeof createAgentPlatform;
68 }
69>()("@t3tools/provider-cursor/server/CursorSdk") {}
70
71/** Provide once per process: a second guard would defer to the first and swallow real rejections. */
72export const layer = Layer.effect(
73 CursorSdk,
74 Effect.acquireRelease(
75 Effect.sync(() => {
76 process.on("unhandledRejection", onUnhandledRejection);
77 }),
78 () =>
79 Effect.sync(() => {
80 process.off("unhandledRejection", onUnhandledRejection);
81 }),
82 ).pipe(Effect.as(CursorSdk.of({ Agent, createAgentPlatform }))),
83);
packages/provider-cursor/src/server/CursorSdkCatalog.ts+11-8
...@@ -28,13 +28,14 @@ export class CursorSdkCatalogError extends Schema.TaggedError<CursorSdkCatalogEr...@@ -28,13 +28,14 @@ export class CursorSdkCatalogError extends Schema.TaggedError<CursorSdkCatalogEr
28 }28 }
29}29}
3030
31export interface CursorSdkCatalogShape {31export class CursorSdkCatalog extends Context.Service<
32 readonly read: (apiKey: string) => Effect.Effect<CursorSdkCatalogSnapshot, CursorSdkCatalogError>;32 CursorSdkCatalog,
33}33 {
3434 readonly read: (
35export class CursorSdkCatalog extends Context.Service<CursorSdkCatalog, CursorSdkCatalogShape>()(35 apiKey: string,
36 "@t3tools/provider-cursor/server/CursorSdkCatalog",36 ) => Effect.Effect<CursorSdkCatalogSnapshot, CursorSdkCatalogError>;
37) {}37 }
38>()("@t3tools/provider-cursor/server/CursorSdkCatalog") {}
3839
39function isAuthenticationFailure(cause: unknown): boolean {40function isAuthenticationFailure(cause: unknown): boolean {
40 return (41 return (
...@@ -87,6 +88,8 @@ export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function*...@@ -87,6 +88,8 @@ export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function*
8788
88export const layer = Layer.effect(CursorSdkCatalog, makeCursorSdkCatalog());89export const layer = Layer.effect(CursorSdkCatalog, makeCursorSdkCatalog());
8990
90export function layerTest(read: CursorSdkCatalogShape["read"]): Layer.Layer<CursorSdkCatalog> {91export function layerTest(
92 read: CursorSdkCatalog["Service"]["read"],
93): Layer.Layer<CursorSdkCatalog> {
91 return Layer.succeed(CursorSdkCatalog, CursorSdkCatalog.of({ read }));94 return Layer.succeed(CursorSdkCatalog, CursorSdkCatalog.of({ read }));
92}95}
packages/provider-cursor/src/server/adapter.ts-22
...@@ -34,7 +34,6 @@ import * as DateTime from "effect/DateTime";...@@ -34,7 +34,6 @@ import * as DateTime from "effect/DateTime";
34import * as Deferred from "effect/Deferred";34import * as Deferred from "effect/Deferred";
35import * as Effect from "effect/Effect";35import * as Effect from "effect/Effect";
36import * as FileSystem from "effect/FileSystem";36import * as FileSystem from "effect/FileSystem";
37import * as Layer from "effect/Layer";
38import * as Option from "effect/Option";37import * as Option from "effect/Option";
39import * as Path from "effect/Path";38import * as Path from "effect/Path";
40import * as Queue from "effect/Queue";39import * as Queue from "effect/Queue";
...@@ -2645,24 +2644,3 @@ export const CursorAdapterV2Driver: ProviderAdapterDriver<...@@ -2645,24 +2644,3 @@ export const CursorAdapterV2Driver: ProviderAdapterDriver<
2645 ),2644 ),
2646 ),2645 ),
2647};2646};
2648
2649const layer: Layer.Layer<
2650 ProviderAdapter.ProviderAdapterV2,
2651 never,
2652 | CursorAgentSdk.CursorAgentSdkRunner
2653 | FileSystem.FileSystem
2654 | Path.Path
2655 | IdAllocator.IdAllocatorV2
2656 | McpProviderSessions.McpProviderSessions
2657 | ProviderHost.ProviderHost
2658> = Layer.effect(
2659 ProviderAdapter.ProviderAdapterV2,
2660 Effect.gen(function* () {
2661 const hostEnvironment = yield* HostProcessEnvironment;
2662 return yield* makeCursorAdapterV2({
2663 instanceId: CURSOR_DEFAULT_INSTANCE_ID,
2664 settings: DEFAULT_CURSOR_SETTINGS,
2665 environment: hostEnvironment,
2666 });
2667 }),
2668);
packages/provider-cursor/src/server/driver.test.ts+3-1
...@@ -7,13 +7,14 @@ import * as Layer from "effect/Layer";...@@ -7,13 +7,14 @@ import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";7import * as Option from "effect/Option";
8import * as Stream from "effect/Stream";8import * as Stream from "effect/Stream";
9import { vi } from "vite-plus/test";9import { vi } from "vite-plus/test";
10import { HttpClient } from "effect/http";10import * as HttpClient from "effect/http/HttpClient";
11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
1212
13import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";13import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
14import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";14import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
15import { CursorDriver } from "./driver.ts";15import { CursorDriver } from "./driver.ts";
16import * as CursorAgentSdk from "./CursorAgentSdk.ts";16import * as CursorAgentSdk from "./CursorAgentSdk.ts";
17import * as CursorSdk from "./CursorSdk.ts";
17import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";18import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
18import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";19import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
19import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";20import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
...@@ -23,6 +24,7 @@ const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe(...@@ -23,6 +24,7 @@ const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe(
23 Layer.provideMerge(NodeServices.layer),24 Layer.provideMerge(NodeServices.layer),
24 Layer.provideMerge(IdAllocator.layer),25 Layer.provideMerge(IdAllocator.layer),
25 Layer.provideMerge(McpProviderSessions.layer),26 Layer.provideMerge(McpProviderSessions.layer),
27 Layer.provideMerge(CursorSdk.layer),
26 Layer.provideMerge(28 Layer.provideMerge(
27 Layer.mock(CursorAgentSdk.CursorAgentSdkRunner)({29 Layer.mock(CursorAgentSdk.CursorAgentSdkRunner)({
28 open: () => Effect.die("Maintenance resolution must not open a Cursor session"),30 open: () => Effect.die("Maintenance resolution must not open a Cursor session"),
packages/provider-cursor/src/server/driver.ts+4-2
...@@ -13,7 +13,7 @@ import * as Crypto from "effect/Crypto";...@@ -13,7 +13,7 @@ import * as Crypto from "effect/Crypto";
13import * as FileSystem from "effect/FileSystem";13import * as FileSystem from "effect/FileSystem";
14import * as Path from "effect/Path";14import * as Path from "effect/Path";
15import * as Schema from "effect/Schema";15import * as Schema from "effect/Schema";
16import { HttpClient } from "effect/http";16import * as HttpClient from "effect/http/HttpClient";
17import { readCursorUsageLimits } from "./usageLimits.ts";17import { readCursorUsageLimits } from "./usageLimits.ts";
1818
19import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";19import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
...@@ -40,6 +40,7 @@ import { probeCursorSkills } from "./skills.ts";...@@ -40,6 +40,7 @@ import { probeCursorSkills } from "./skills.ts";
40import { makeCursorAuth } from "./auth.ts";40import { makeCursorAuth } from "./auth.ts";
41import * as CursorCredentialStore from "./credentialStore.ts";41import * as CursorCredentialStore from "./credentialStore.ts";
42import * as CursorAgentSdk from "./CursorAgentSdk.ts";42import * as CursorAgentSdk from "./CursorAgentSdk.ts";
43import * as CursorSdk from "./CursorSdk.ts";
43const decodeCursorSettings = Schema.decodeSync(CursorSettings);44const decodeCursorSettings = Schema.decodeSync(CursorSettings);
44const isSdkRunnerError = Schema.is(CursorAgentSdk.CursorAgentSdkRunnerError);45const isSdkRunnerError = Schema.is(CursorAgentSdk.CursorAgentSdkRunnerError);
4546
...@@ -55,6 +56,7 @@ export type CursorDriverEnv =...@@ -55,6 +56,7 @@ export type CursorDriverEnv =
55 | FileSystem.FileSystem56 | FileSystem.FileSystem
56 | Path.Path57 | Path.Path
57 | HttpClient.HttpClient58 | HttpClient.HttpClient
59 | CursorSdk.CursorSdk
58 | ProviderHost.ProviderHost;60 | ProviderHost.ProviderHost;
5961
60export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {62export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
...@@ -238,7 +240,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {...@@ -238,7 +240,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
238 new ProviderDriverError({240 new ProviderDriverError({
239 driver: DRIVER_KIND,241 driver: DRIVER_KIND,
240 instanceId,242 instanceId,
241 detail: `Failed to build Cursor snapshot: ${cause.message ?? String(cause)}`,243 detail: "Failed to build Cursor snapshot.",
242 cause,244 cause,
243 }),245 }),
244 ),246 ),
packages/provider-cursor/src/server/sdk.test.ts deleted-193
...@@ -1,193 +0,0 @@
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
5const cursorSdkUrl = new URL("./sdk.ts", import.meta.url).href;
6
7// Loads sdk.ts in a fresh process. @cursor/sdk is stubbed so the guard
8// can be tested without the real package. The vitest worker already has its
9// own unhandledRejection listener, which would hide the process-exit behavior.
10const probeProgram = `
11import { mkdtempSync, writeFileSync } from "node:fs";
12import { tmpdir } from "node:os";
13import { join } from "node:path";
14import { registerHooks } from "node:module";
15import { pathToFileURL } from "node:url";
16import { spawn } from "node:child_process";
17
18const dir = mkdtempSync(join(tmpdir(), "cursor-sdk-stub-"));
19const stub = join(dir, "stub.cjs");
20writeFileSync(
21 stub,
22 "module.exports = { Agent: {}, AuthenticationError: class {}, createAgentPlatform: () => ({}), Cursor: {}, CursorSdkError: class {}, InMemoryCredentialStore: class {} };",
23);
24registerHooks({
25 resolve(specifier, context, next) {
26 if (specifier === "@cursor/sdk") {
27 return { url: pathToFileURL(stub).href, shortCircuit: true };
28 }
29 return next(specifier, context);
30 },
31});
32
33const { isCursorShellSpawnFailure } = await import(${JSON.stringify(cursorSdkUrl)});
34const mode = process.argv[1];
35const missingCwd = join(tmpdir(), "t3-missing-cwd-" + process.pid);
36
37if (mode === "predicate") {
38 const cursorShell = Object.assign(new Error("spawn /bin/zsh ENOENT"), {
39 code: "ENOENT",
40 syscall: "spawn /bin/zsh",
41 path: "/bin/zsh",
42 spawnargs: ["-c", "dump_zsh_state >&4", "--", "true"],
43 });
44 const bashShell = Object.assign(new Error("spawn bash ENOENT"), {
45 code: "ENOENT",
46 syscall: "spawn bash",
47 spawnargs: ["-c", "dump_bash_state >&4"],
48 });
49 const sandboxRestore = Object.assign(new Error("spawn /bin/zsh ENOENT"), {
50 code: "ENOENT",
51 syscall: "spawn /bin/zsh",
52 spawnargs: ["-c", "builtin eval \\"\${__CURSOR_SANDBOX_ENV_RESTORE:-}\\""],
53 });
54 const gitSpawn = Object.assign(new Error("spawn git ENOENT"), {
55 code: "ENOENT",
56 syscall: "spawn git",
57 path: "git",
58 spawnargs: ["status"],
59 });
60 const plainZsh = Object.assign(new Error("spawn /bin/zsh ENOENT"), {
61 code: "ENOENT",
62 syscall: "spawn /bin/zsh",
63 path: "/bin/zsh",
64 spawnargs: ["-lc", "true"],
65 });
66 console.log(
67 JSON.stringify({
68 cursorShell: isCursorShellSpawnFailure(cursorShell),
69 bashShell: isCursorShellSpawnFailure(bashShell),
70 sandboxRestore: isCursorShellSpawnFailure(sandboxRestore),
71 gitSpawn: isCursorShellSpawnFailure(gitSpawn),
72 plainZsh: isCursorShellSpawnFailure(plainZsh),
73 open: isCursorShellSpawnFailure(
74 Object.assign(new Error("open failed"), { code: "ENOENT", syscall: "open" }),
75 ),
76 plain: isCursorShellSpawnFailure(new Error("boom")),
77 string: isCursorShellSpawnFailure("spawn ENOENT"),
78 }),
79 );
80 process.exit(0);
81}
82
83if (mode === "cursor-shell") {
84 const child = spawn("/bin/zsh", ["-c", "dump_zsh_state >&4", "--", "true"], {
85 cwd: missingCwd,
86 });
87 child.on("error", (error) => {
88 Promise.reject(error);
89 });
90 setTimeout(() => process.exit(0), 500);
91} else if (mode === "other-spawn") {
92 Promise.reject(
93 Object.assign(new Error("spawn git ENOENT"), {
94 code: "ENOENT",
95 syscall: "spawn git",
96 path: "git",
97 spawnargs: ["status"],
98 }),
99 );
100 setTimeout(() => process.exit(0), 500);
101} else if (mode === "other-rejection") {
102 Promise.reject(new Error("boom"));
103 setTimeout(() => process.exit(0), 500);
104} else if (mode === "spawn-without-listener") {
105 spawn(process.execPath, ["-e", "process.exit(0)"], { cwd: missingCwd });
106 setTimeout(() => process.exit(0), 500);
107} else {
108 console.error("Unknown cursor shell spawn guard probe: " + mode);
109 process.exit(2);
110}
111`;
112
113function runProbe(mode: string): Promise<{ code: number | null; stdout: string; stderr: string }> {
114 return new Promise((resolve, reject) => {
115 const child = NodeChildProcess.spawn(
116 process.execPath,
117 [
118 "--experimental-strip-types",
119 "--disable-warning=ExperimentalWarning",
120 "--input-type=module",
121 "-e",
122 probeProgram,
123 mode,
124 ],
125 { stdio: ["ignore", "pipe", "pipe"] },
126 );
127 let stdout = "";
128 let stderr = "";
129 child.stdout.setEncoding("utf8");
130 child.stderr.setEncoding("utf8");
131 child.stdout.on("data", (chunk: string) => {
132 stdout += chunk;
133 });
134 child.stderr.on("data", (chunk: string) => {
135 stderr += chunk;
136 });
137 const timer = setTimeout(() => {
138 child.kill();
139 reject(new Error(`probe ${mode} timed out`));
140 }, 5_000);
141 child.on("error", reject);
142 child.on("exit", (code) => {
143 clearTimeout(timer);
144 resolve({ code, stdout, stderr });
145 });
146 });
147}
148
149describe("isCursorShellSpawnFailure", () => {
150 it("matches only Cursor's shell wrapper", async () => {
151 const result = await runProbe("predicate");
152 expect(result.code).toBe(0);
153 expect(JSON.parse(result.stdout)).toEqual({
154 cursorShell: true,
155 bashShell: true,
156 sandboxRestore: true,
157 gitSpawn: false,
158 plainZsh: false,
159 open: false,
160 plain: false,
161 string: false,
162 });
163 });
164});
165
166describe("Cursor shell spawn guard", () => {
167 it("keeps the process alive when Cursor's shell spawn rejects", async () => {
168 const result = await runProbe("cursor-shell");
169 expect(result.code).toBe(0);
170 expect(result.stderr).toContain("The server will keep running.");
171 expect(result.stderr).toContain("ENOENT");
172 });
173
174 it("still exits when a different spawn failure is unhandled", async () => {
175 const result = await runProbe("other-spawn");
176 expect(result.code).toBe(1);
177 expect(result.stderr).toContain("spawn git ENOENT");
178 expect(result.stderr).not.toContain("The server will keep running.");
179 });
180
181 it("still exits on unrelated unhandled rejections", async () => {
182 const result = await runProbe("other-rejection");
183 expect(result.code).toBe(1);
184 expect(result.stderr).toContain("boom");
185 });
186
187 it("leaves a spawn with no error listener fatal", async () => {
188 const result = await runProbe("spawn-without-listener");
189 expect(result.code).toBe(1);
190 expect(result.stderr).toContain("Unhandled");
191 expect(result.stderr).not.toContain("The server will keep running.");
192 });
193});
packages/provider-cursor/src/server/sdk.ts+1-64
...@@ -1,69 +1,6 @@...@@ -1,69 +1,6 @@
1// @effect-diagnostics nodeBuiltinImport:off globalConsole:off -- Installed before the SDK loads, outside an Effect runtime. stderr must match Node's default unhandled-rejection print.1// @effect-diagnostics nodeBuiltinImport:off -- The SDK must load from disk beside its Webpack chunks.
2import * as NodeModule from "node:module";2import * as NodeModule from "node:module";
33
4/**
5 * The Cursor Agent SDK runs in this process and spawns a shell for tool
6 * calls. The command is Cursor's sandbox wrapper (`dump_zsh_state`,
7 * `dump_bash_state`, `__CURSOR_SANDBOX_ENV_RESTORE`). A bad working directory
8 * makes Node report `spawn /bin/zsh ENOENT`. The SDK listens for that error,
9 * then rejects an internal promise nothing awaits. Node exits on that
10 * unhandled rejection and takes the server with it.
11 *
12 * This listener ignores only that rejection. Every other unhandled rejection
13 * still prints and exits when this is the process's only listener, matching
14 * Node's default. `child_process.spawn` itself is left alone, so git,
15 * terminals, and other providers keep their own error handling.
16 */
17const CURSOR_SHELL_SPAWN_MARKERS = [
18 "dump_zsh_state",
19 "dump_bash_state",
20 "__CURSOR_SANDBOX_ENV_RESTORE",
21] as const;
22
23export function isCursorShellSpawnFailure(reason: unknown): boolean {
24 if (typeof reason !== "object" || reason === null) {
25 return false;
26 }
27 const syscall = Reflect.get(reason, "syscall");
28 const code = Reflect.get(reason, "code");
29 const spawnargs = Reflect.get(reason, "spawnargs");
30 if (typeof syscall !== "string" || !syscall.startsWith("spawn") || typeof code !== "string") {
31 return false;
32 }
33 if (!Array.isArray(spawnargs)) {
34 return false;
35 }
36 return spawnargs.some(
37 (arg) =>
38 typeof arg === "string" && CURSOR_SHELL_SPAWN_MARKERS.some((marker) => arg.includes(marker)),
39 );
40}
41
42let cursorShellSpawnGuardInstalled = false;
43
44function installCursorShellSpawnGuard(): void {
45 if (cursorShellSpawnGuardInstalled) {
46 return;
47 }
48 cursorShellSpawnGuardInstalled = true;
49
50 process.on("unhandledRejection", (reason) => {
51 if (isCursorShellSpawnFailure(reason)) {
52 console.error("Cursor shell spawn failed. The server will keep running.", reason);
53 return;
54 }
55 // Another handler registered besides this one owns the decision.
56 if (process.listenerCount("unhandledRejection") > 1) {
57 return;
58 }
59 console.error(reason);
60 process.exit(1);
61 });
62}
63
64// Arm before the SDK loads. Its shell tools spawn from this process.
65installCursorShellSpawnGuard();
66
67// Cursor's Webpack chunks and local helpers must stay beside the SDK entry.4// Cursor's Webpack chunks and local helpers must stay beside the SDK entry.
68// createRequire also loads that disk-backed package from a Node SEA executable.5// createRequire also loads that disk-backed package from a Node SEA executable.
69const requireCursorSdk = NodeModule.createRequire(import.meta.url);6const requireCursorSdk = NodeModule.createRequire(import.meta.url);
packages/provider-cursor/src/server/textGeneration.test.ts+18-12
...@@ -5,12 +5,14 @@ import { describe, expect, it } from "@effect/vitest";...@@ -5,12 +5,14 @@ import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";6import * as FileSystem from "effect/FileSystem";
7import * as Fiber from "effect/Fiber";7import * as Fiber from "effect/Fiber";
8import * as Layer from "effect/Layer";
8import * as TestClock from "effect/testing/TestClock";9import * as TestClock from "effect/testing/TestClock";
9import * as Schema from "effect/Schema";10import * as Schema from "effect/Schema";
10import { createModelSelection } from "@t3tools/shared/model";11import { createModelSelection } from "@t3tools/shared/model";
11import { beforeEach, vi } from "vite-plus/test";12import { beforeEach, vi } from "vite-plus/test";
1213
13import { makeCursorTextGeneration } from "./textGeneration.ts";14import { makeCursorTextGeneration } from "./textGeneration.ts";
15import * as CursorSdk from "./CursorSdk.ts";
1416
15const cursorSdkMock = vi.hoisted(() => ({17const cursorSdkMock = vi.hoisted(() => ({
16 create: vi.fn<(options: unknown) => Promise<unknown>>(),18 create: vi.fn<(options: unknown) => Promise<unknown>>(),
...@@ -25,13 +27,17 @@ const cursorSdkMock = vi.hoisted(() => ({...@@ -25,13 +27,17 @@ const cursorSdkMock = vi.hoisted(() => ({
25 })),27 })),
26}));28}));
2729
28vi.mock("./sdk.ts", () => ({ Agent: { create: cursorSdkMock.create } }));30vi.mock("./sdk.ts", () => ({
31 Agent: { create: cursorSdkMock.create },
32 createAgentPlatform: vi.fn(),
33}));
2934
30let hasCustomPolicy = false;35let hasCustomPolicy = false;
31const layerFs = FileSystem.layerNoop({36const layerFs = FileSystem.layerNoop({
32 exists: () => Effect.succeed(hasCustomPolicy),37 exists: () => Effect.succeed(hasCustomPolicy),
33 makeTempDirectoryScoped: () => Effect.succeed("/isolated-text-generation"),38 makeTempDirectoryScoped: () => Effect.succeed("/isolated-text-generation"),
34});39});
40const layerTest = Layer.merge(layerFs, CursorSdk.layer);
3541
36const decodeCursorSettings = Schema.decodeSync(CursorSettings);42const decodeCursorSettings = Schema.decodeSync(CursorSettings);
37const cursorSettings = decodeCursorSettings({ enabled: true });43const cursorSettings = decodeCursorSettings({ enabled: true });
...@@ -90,7 +96,7 @@ describe("CursorTextGeneration", () => {...@@ -90,7 +96,7 @@ describe("CursorTextGeneration", () => {
90 expect(cursorSdkMock.create).toHaveBeenLastCalledWith(96 expect(cursorSdkMock.create).toHaveBeenLastCalledWith(
91 expect.objectContaining({ apiKey: "second-browser-key" }),97 expect.objectContaining({ apiKey: "second-browser-key" }),
92 );98 );
93 }).pipe(Effect.provide(layerFs)),99 }).pipe(Effect.provide(layerTest)),
94 );100 );
95101
96 it.effect("uses the Cursor SDK prompt API with model parameters and API key", () =>102 it.effect("uses the Cursor SDK prompt API with model parameters and API key", () =>
...@@ -139,7 +145,7 @@ describe("CursorTextGeneration", () => {...@@ -139,7 +145,7 @@ describe("CursorTextGeneration", () => {
139 enableAgentRetries: true,145 enableAgentRetries: true,
140 },146 },
141 });147 });
142 }).pipe(Effect.provide(layerFs)),148 }).pipe(Effect.provide(layerTest)),
143 );149 );
144150
145 it.effect("continues in the temp directory when the SDK cannot sandbox", () =>151 it.effect("continues in the temp directory when the SDK cannot sandbox", () =>
...@@ -175,7 +181,7 @@ describe("CursorTextGeneration", () => {...@@ -175,7 +181,7 @@ describe("CursorTextGeneration", () => {
175 enableAgentRetries: true,181 enableAgentRetries: true,
176 },182 },
177 });183 });
178 }).pipe(Effect.provide(layerFs)),184 }).pipe(Effect.provide(layerTest)),
179 );185 );
180186
181 it.effect("does not retry Agent.create for errors other than an unsupported sandbox", () =>187 it.effect("does not retry Agent.create for errors other than an unsupported sandbox", () =>
...@@ -199,7 +205,7 @@ describe("CursorTextGeneration", () => {...@@ -199,7 +205,7 @@ describe("CursorTextGeneration", () => {
199205
200 expect(error.detail).toBe("Cursor SDK text generation failed.");206 expect(error.detail).toBe("Cursor SDK text generation failed.");
201 expect(cursorSdkMock.create).toHaveBeenCalledTimes(1);207 expect(cursorSdkMock.create).toHaveBeenCalledTimes(1);
202 }).pipe(Effect.provide(layerFs)),208 }).pipe(Effect.provide(layerTest)),
203 );209 );
204210
205 it.effect("accepts json objects with extra assistant text around them", () =>211 it.effect("accepts json objects with extra assistant text around them", () =>
...@@ -227,7 +233,7 @@ describe("CursorTextGeneration", () => {...@@ -227,7 +233,7 @@ describe("CursorTextGeneration", () => {
227233
228 expect(generated.subject).toBe("Update README dummy comment with attribution and date");234 expect(generated.subject).toBe("Update README dummy comment with attribution and date");
229 expect(generated.body).toBe("");235 expect(generated.body).toBe("");
230 }).pipe(Effect.provide(layerFs)),236 }).pipe(Effect.provide(layerTest)),
231 );237 );
232238
233 it.effect("generates thread titles through Cursor SDK text generation", () =>239 it.effect("generates thread titles through Cursor SDK text generation", () =>
...@@ -251,7 +257,7 @@ describe("CursorTextGeneration", () => {...@@ -251,7 +257,7 @@ describe("CursorTextGeneration", () => {
251 });257 });
252258
253 expect(generated.title).toBe("Trim reconnect spinner status after resume.");259 expect(generated.title).toBe("Trim reconnect spinner status after resume.");
254 }).pipe(Effect.provide(layerFs)),260 }).pipe(Effect.provide(layerTest)),
255 );261 );
256262
257 it.effect.each(["error", "cancelled"] as const)(263 it.effect.each(["error", "cancelled"] as const)(
...@@ -285,7 +291,7 @@ describe("CursorTextGeneration", () => {...@@ -285,7 +291,7 @@ describe("CursorTextGeneration", () => {
285 : "Cursor SDK request finished with an error.",291 : "Cursor SDK request finished with an error.",
286 );292 );
287 expect(cursorSdkMock.close).toHaveBeenCalledOnce();293 expect(cursorSdkMock.close).toHaveBeenCalledOnce();
288 }).pipe(Effect.provide(layerFs)),294 }).pipe(Effect.provide(layerTest)),
289 );295 );
290296
291 it.effect("fails closed when ambient sandbox policy can expand write access", () =>297 it.effect("fails closed when ambient sandbox policy can expand write access", () =>
...@@ -301,7 +307,7 @@ describe("CursorTextGeneration", () => {...@@ -301,7 +307,7 @@ describe("CursorTextGeneration", () => {
301 );307 );
302 expect(failure.detail).toContain("custom ~/.cursor/sandbox.json");308 expect(failure.detail).toContain("custom ~/.cursor/sandbox.json");
303 expect(cursorSdkMock.prompt).not.toHaveBeenCalled();309 expect(cursorSdkMock.prompt).not.toHaveBeenCalled();
304 }).pipe(Effect.provide(layerFs)),310 }).pipe(Effect.provide(layerTest)),
305 );311 );
306312
307 it.effect("cancels the native run when text generation times out", () =>313 it.effect("cancels the native run when text generation times out", () =>
...@@ -327,7 +333,7 @@ describe("CursorTextGeneration", () => {...@@ -327,7 +333,7 @@ describe("CursorTextGeneration", () => {
327 expect((yield* Fiber.join(result)).detail).toContain("timed out");333 expect((yield* Fiber.join(result)).detail).toContain("timed out");
328 expect(cursorSdkMock.cancel).toHaveBeenCalledOnce();334 expect(cursorSdkMock.cancel).toHaveBeenCalledOnce();
329 expect(cursorSdkMock.close).toHaveBeenCalledOnce();335 expect(cursorSdkMock.close).toHaveBeenCalledOnce();
330 }).pipe(Effect.provide(layerFs), Effect.scoped),336 }).pipe(Effect.provide(layerTest), Effect.scoped),
331 );337 );
332338
333 it.effect.each(["create", "send"] as const)(339 it.effect.each(["create", "send"] as const)(
...@@ -389,7 +395,7 @@ describe("CursorTextGeneration", () => {...@@ -389,7 +395,7 @@ describe("CursorTextGeneration", () => {
389 expect(cursorSdkMock.close).toHaveBeenCalledOnce();395 expect(cursorSdkMock.close).toHaveBeenCalledOnce();
390 expect(wait).not.toHaveBeenCalled();396 expect(wait).not.toHaveBeenCalled();
391 if (phase === "send") expect(cursorSdkMock.cancel).toHaveBeenCalledOnce();397 if (phase === "send") expect(cursorSdkMock.cancel).toHaveBeenCalledOnce();
392 }).pipe(Effect.provide(layerFs), Effect.scoped),398 }).pipe(Effect.provide(layerTest), Effect.scoped),
393 );399 );
394400
395 it.effect("requires CURSOR_API_KEY before calling the SDK", () =>401 it.effect("requires CURSOR_API_KEY before calling the SDK", () =>
...@@ -411,6 +417,6 @@ describe("CursorTextGeneration", () => {...@@ -411,6 +417,6 @@ describe("CursorTextGeneration", () => {
411417
412 expect(error.detail).toBe("Sign in with Cursor or add CURSOR_API_KEY in provider settings.");418 expect(error.detail).toBe("Sign in with Cursor or add CURSOR_API_KEY in provider settings.");
413 expect(cursorSdkMock.prompt).not.toHaveBeenCalled();419 expect(cursorSdkMock.prompt).not.toHaveBeenCalled();
414 }).pipe(Effect.provide(layerFs)),420 }).pipe(Effect.provide(layerTest)),
415 );421 );
416});422});
packages/provider-cursor/src/server/textGeneration.ts+2-1
...@@ -2,7 +2,6 @@ import * as NodeOS from "node:os";...@@ -2,7 +2,6 @@ import * as NodeOS from "node:os";
2import * as FileSystem from "effect/FileSystem";2import * as FileSystem from "effect/FileSystem";
33
4import type { AgentOptions, RunResult } from "@cursor/sdk";4import type { AgentOptions, RunResult } from "@cursor/sdk";
5import { Agent } from "./sdk.ts";
6import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";6import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
...@@ -13,6 +12,7 @@ import type { CursorSettings } from "../settings.ts";...@@ -13,6 +12,7 @@ import type { CursorSettings } from "../settings.ts";
13import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";12import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
14import { cursorSdkModelSelection } from "./sdkModel.ts";13import { cursorSdkModelSelection } from "./sdkModel.ts";
15import type { CursorAuth } from "./auth.ts";14import type { CursorAuth } from "./auth.ts";
15import * as CursorSdk from "./CursorSdk.ts";
1616
17const CURSOR_TIMEOUT_MS = 180_000;17const CURSOR_TIMEOUT_MS = 180_000;
1818
...@@ -58,6 +58,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu...@@ -58,6 +58,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu
58 withAccess?: CursorAuth["withAccess"],58 withAccess?: CursorAuth["withAccess"],
59) {59) {
60 const fs = yield* FileSystem.FileSystem;60 const fs = yield* FileSystem.FileSystem;
61 const { Agent } = yield* CursorSdk.CursorSdk;
61 const resolvedEnvironment = environment ?? process.env;62 const resolvedEnvironment = environment ?? process.env;
6263
63 const resolveCursorApiKey = (operation: TextGenerationOperations.Operation) =>64 const resolveCursorApiKey = (operation: TextGenerationOperations.Operation) =>
packages/provider-cursor/src/server/usageLimits.test.ts+2-1
...@@ -4,7 +4,8 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess";...@@ -4,7 +4,8 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";6import * as Path from "effect/Path";
7import { HttpClient, HttpClientResponse } from "effect/http";7import * as HttpClient from "effect/http/HttpClient";
8import * as HttpClientResponse from "effect/http/HttpClientResponse";
8import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./usageLimits.ts";9import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./usageLimits.ts";
910
10const withNodeServices = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) =>11const withNodeServices = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) =>
packages/provider-cursor/src/server/usageLimits.ts+3-1
...@@ -9,7 +9,9 @@ import * as FileSystem from "effect/FileSystem";...@@ -9,7 +9,9 @@ import * as FileSystem from "effect/FileSystem";
9import * as Option from "effect/Option";9import * as Option from "effect/Option";
10import * as Path from "effect/Path";10import * as Path from "effect/Path";
11import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
12import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";12import * as HttpClient from "effect/http/HttpClient";
13import * as HttpClientRequest from "effect/http/HttpClientRequest";
14import * as HttpClientResponse from "effect/http/HttpClientResponse";
13import {15import {
14 clampPercent,16 clampPercent,
15 makeUnavailableUsageLimits,17 makeUnavailableUsageLimits,