| author | |
| committer | |
| log | 33806e73555107b8ac8fb18fd15b9dfb87ec6557 |
| tree | 54901ad6c38609e39e20bcc9448e6e7b4186fae4 |
| parent | f853d514cab475e73468f7cb7bab1aae48dbe549 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 3 | import * as Config from "effect/Config"; |
| 4 | 4 | import * as Console from "effect/Console"; |
| 5 | 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; | |
| 6 | 7 | import * as FileSystem from "effect/FileSystem"; |
| 7 | 8 | import * as Option from "effect/Option"; |
| 8 | 9 | import * as Path from "effect/Path"; |
| ... | ... | @@ -10,6 +11,7 @@ import * as Redacted from "effect/Redacted"; |
| 10 | 11 | import * as Schema from "effect/Schema"; |
| 11 | 12 | import * as Struct from "effect/Struct"; |
| 12 | 13 | import { Command, Flag } from "effect/cli"; |
| 14 | import * as CursorSdk from "@t3tools/provider-cursor/server/CursorSdk"; | |
| 13 | 15 | |
| 14 | 16 | import { |
| 15 | 17 | recordCursorAgentSdkReplayTranscript, |
| ... | ... | @@ -219,6 +221,6 @@ const recordCursorReplayCommand = Command.make( |
| 219 | 221 | ); |
| 220 | 222 | |
| 221 | 223 | Command.run(recordCursorReplayCommand, { version: "0.0.0" }).pipe( |
| 222 | Effect.provide(NodeServices.layer), | |
| 224 | Effect.provide(Layer.mergeAll(CursorSdk.layer, NodeServices.layer)), | |
| 223 | 225 | NodeRuntime.runMain, |
| 224 | 226 | ); |
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts+4-4| ... | ... | @@ -213,7 +213,7 @@ function replayRunnerError( |
| 213 | 213 | |
| 214 | 214 | export function makeCursorAgentSdkReplayRunner( |
| 215 | 215 | transcript: CursorAgentSdkReplayTranscript, |
| 216 | ): CursorAgentSdk.CursorAgentSdkRunnerShape { | |
| 216 | ): CursorAgentSdk.CursorAgentSdkRunner["Service"] { | |
| 217 | 217 | let cursor = 0; |
| 218 | 218 | let failure: CursorAgentSdkReplayError | null = null; |
| 219 | 219 | let cursorAdvanced = makeSignal(); |
| ... | ... | @@ -491,7 +491,7 @@ export function makeCursorAgentSdkReplayRunner( |
| 491 | 491 | function layerCursorAgentSdkReplay( |
| 492 | 492 | transcript: CursorAgentSdkReplayTranscript, |
| 493 | 493 | options?: { |
| 494 | readonly runner?: CursorAgentSdk.CursorAgentSdkRunnerShape; | |
| 494 | readonly runner?: CursorAgentSdk.CursorAgentSdkRunner["Service"]; | |
| 495 | 495 | readonly assertCompleteOnFinalize?: boolean; |
| 496 | 496 | }, |
| 497 | 497 | ): Layer.Layer<CursorAgentSdk.CursorAgentSdkRunner> { |
| ... | ... | @@ -512,7 +512,7 @@ function layerCursorAgentSdkReplay( |
| 512 | 512 | export function layer( |
| 513 | 513 | transcript: CursorAgentSdkReplayTranscript, |
| 514 | 514 | options?: { |
| 515 | readonly runner?: CursorAgentSdk.CursorAgentSdkRunnerShape; | |
| 515 | readonly runner?: CursorAgentSdk.CursorAgentSdkRunner["Service"]; | |
| 516 | 516 | readonly assertCompleteOnFinalize?: boolean; |
| 517 | 517 | }, |
| 518 | 518 | ) { |
| ... | ... | @@ -807,7 +807,7 @@ export const recordCursorAgentSdkReplayTranscript = Effect.fn( |
| 807 | 807 | heldUntilCancel = []; |
| 808 | 808 | } |
| 809 | 809 | }); |
| 810 | const runner = CursorAgentSdk.makeCursorAgentSdkRunner(() => recordFrame); | |
| 810 | const runner = yield* CursorAgentSdk.makeCursorAgentSdkRunner(() => recordFrame); | |
| 811 | 811 | |
| 812 | 812 | const awaitSignal = (signal: Deferred.Deferred<void>, description: string) => |
| 813 | 813 | Deferred.await(signal).pipe( |
apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts+3-1| ... | ... | @@ -3,6 +3,7 @@ import * as Layer from "effect/Layer"; |
| 3 | 3 | import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; |
| 4 | 4 | import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; |
| 5 | 5 | import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk"; |
| 6 | import * as CursorSdk from "@t3tools/provider-cursor/server/CursorSdk"; | |
| 6 | 7 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 7 | 8 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; |
| 8 | 9 | |
| ... | ... | @@ -10,6 +11,7 @@ export type ProviderOrchestrationAdapterInfrastructure = |
| 10 | 11 | | ClaudeAdapterV2.ClaudeAgentSdkQueryRunner |
| 11 | 12 | | CodexAdapterV2.CodexAppServerClientFactory |
| 12 | 13 | | CursorAgentSdk.CursorAgentSdkRunner |
| 14 | | CursorSdk.CursorSdk | |
| 13 | 15 | | IdAllocator.IdAllocatorV2; |
| 14 | 16 | |
| 15 | 17 | /** |
| ... | ... | @@ -21,7 +23,7 @@ export type ProviderOrchestrationAdapterInfrastructure = |
| 21 | 23 | export const layer = Layer.mergeAll( |
| 22 | 24 | ClaudeAdapterV2.layerQueryRunner, |
| 23 | 25 | CodexAdapterV2.layerAppServerClientFactory, |
| 24 | CursorAgentSdk.layer, | |
| 26 | CursorAgentSdk.layer.pipe(Layer.provideMerge(CursorSdk.layer)), | |
| 25 | 27 | IdAllocator.layer, |
| 26 | 28 | ProviderContinuationRequests.layer, |
| 27 | 29 | ); |
packages/provider-cursor/package.json+4| ... | ... | @@ -22,6 +22,10 @@ |
| 22 | 22 | "./server/CursorAgentSdk": { |
| 23 | 23 | "types": "./src/server/CursorAgentSdk.ts", |
| 24 | 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 | 31 | "scripts": { |
packages/provider-cursor/src/server/CursorAgentSdk.test.ts+3| ... | ... | @@ -7,6 +7,7 @@ import { vi } from "vite-plus/test"; |
| 7 | 7 | |
| 8 | 8 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 9 | 9 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; |
| 10 | import * as CursorSdk from "./CursorSdk.ts"; | |
| 10 | 11 | |
| 11 | 12 | const cursorSdkMock = vi.hoisted(() => { |
| 12 | 13 | const closeExecutionOrder: Array<string> = []; |
| ... | ... | @@ -102,6 +103,7 @@ vi.mock("./sdk.ts", () => ({ |
| 102 | 103 | list: vi.fn(async () => []), |
| 103 | 104 | }, |
| 104 | 105 | }, |
| 106 | createAgentPlatform: vi.fn(), | |
| 105 | 107 | })); |
| 106 | 108 | |
| 107 | 109 | const layerTest = CursorAgentSdk.layer.pipe( |
| ... | ... | @@ -124,6 +126,7 @@ const layerTest = CursorAgentSdk.layer.pipe( |
| 124 | 126 | canonical: undefined, |
| 125 | 127 | }), |
| 126 | 128 | ), |
| 129 | Layer.provide(CursorSdk.layer), | |
| 127 | 130 | Layer.provide(NodeServices.layer), |
| 128 | 131 | ); |
| 129 | 132 |
packages/provider-cursor/src/server/CursorAgentSdk.ts+51-49| ... | ... | @@ -12,11 +12,13 @@ import { |
| 12 | 12 | type ThreadId, |
| 13 | 13 | } from "@t3tools/contracts"; |
| 14 | 14 | import * as Context from "effect/Context"; |
| 15 | import * as Deferred from "effect/Deferred"; | |
| 15 | 16 | import * as Effect from "effect/Effect"; |
| 16 | 17 | import * as Layer from "effect/Layer"; |
| 18 | import * as Ref from "effect/Ref"; | |
| 17 | 19 | import * as Schema from "effect/Schema"; |
| 18 | 20 | |
| 19 | import { Agent, createAgentPlatform } from "./sdk.ts"; | |
| 21 | import * as CursorSdk from "./CursorSdk.ts"; | |
| 20 | 22 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 21 | 23 | |
| 22 | 24 | export const CURSOR_AGENT_SDK_PROTOCOL = "cursor-agent-sdk.local" as const; |
| ... | ... | @@ -66,16 +68,14 @@ export interface CursorAgentSdkSession { |
| 66 | 68 | readonly close: Effect.Effect<void, CursorAgentSdkRunnerError>; |
| 67 | 69 | } |
| 68 | 70 | |
| 69 | export interface CursorAgentSdkRunnerShape { | |
| 70 | readonly open: ( | |
| 71 | input: CursorAgentSdkOpenInput, | |
| 72 | ) => Effect.Effect<CursorAgentSdkSession, CursorAgentSdkRunnerError>; | |
| 73 | readonly assertComplete: Effect.Effect<void, CursorAgentSdkRunnerError>; | |
| 74 | } | |
| 75 | ||
| 76 | 71 | export class CursorAgentSdkRunner extends Context.Service< |
| 77 | 72 | CursorAgentSdkRunner, |
| 78 | CursorAgentSdkRunnerShape | |
| 73 | { | |
| 74 | readonly open: ( | |
| 75 | input: CursorAgentSdkOpenInput, | |
| 76 | ) => Effect.Effect<CursorAgentSdkSession, CursorAgentSdkRunnerError>; | |
| 77 | readonly assertComplete: Effect.Effect<void, CursorAgentSdkRunnerError>; | |
| 78 | } | |
| 79 | 79 | >()("@t3tools/provider-cursor/server/CursorAgentSdk/CursorAgentSdkRunner") {} |
| 80 | 80 | |
| 81 | 81 | export interface CursorAgentSdkLoggedAgentOptions { |
| ... | ... | @@ -298,49 +298,55 @@ function makeCursorAgentSdkProtocolLogger(input: { |
| 298 | 298 | .pipe(Effect.ignore); |
| 299 | 299 | } |
| 300 | 300 | |
| 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 | */ | |
| 312 | let cursorSandboxSupportPrime: Promise<void> | undefined; | |
| 313 | ||
| 314 | function 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 | 302 | * Runs agents through the Cursor SDK, logging every frame to the protocol |
| 333 | 303 | * logger chosen for each opened agent. The live layer writes the native |
| 334 | 304 | * provider event log; the replay recorder turns the same frames into a |
| 335 | 305 | * transcript. |
| 336 | 306 | */ |
| 337 | export function makeCursorAgentSdkRunner( | |
| 307 | export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(function* ( | |
| 338 | 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 | 346 | return CursorAgentSdkRunner.of({ |
| 341 | 347 | open: Effect.fn("CursorAgentSdkRunner.open")(function* (input) { |
| 342 | 348 | if (input.options.local?.sandboxOptions?.enabled === false) { |
| 343 | yield* Effect.promise(() => primeCursorSandboxSupport(input.options)); | |
| 349 | yield* primeCursorSandboxSupport(input.options); | |
| 344 | 350 | } |
| 345 | 351 | const protocolLogger = protocolLoggerFor(input); |
| 346 | 352 | const log = (event: CursorAgentSdkProtocolLogEvent) => |
| ... | ... | @@ -590,17 +596,13 @@ export function makeCursorAgentSdkRunner( |
| 590 | 596 | }), |
| 591 | 597 | assertComplete: Effect.void, |
| 592 | 598 | }); |
| 593 | } | |
| 599 | }); | |
| 594 | 600 | |
| 595 | export const layer: Layer.Layer< | |
| 596 | CursorAgentSdkRunner, | |
| 597 | never, | |
| 598 | ProviderEventLoggers.ProviderEventLoggers | |
| 599 | > = Layer.effect( | |
| 601 | export const layer = Layer.effect( | |
| 600 | 602 | CursorAgentSdkRunner, |
| 601 | 603 | Effect.gen(function* () { |
| 602 | 604 | const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; |
| 603 | return makeCursorAgentSdkRunner((input) => | |
| 605 | return yield* makeCursorAgentSdkRunner((input) => | |
| 604 | 606 | makeCursorAgentSdkProtocolLogger({ |
| 605 | 607 | nativeEventLogger, |
| 606 | 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. | |
| 2 | import * as NodeChildProcess from "node:child_process"; | |
| 3 | import { describe, expect, it } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { isCursorShellSpawnFailure } from "./CursorSdk.ts"; | |
| 6 | ||
| 7 | const 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. | |
| 12 | const probeProgram = ` | |
| 13 | import { mkdtempSync, writeFileSync } from "node:fs"; | |
| 14 | import { tmpdir } from "node:os"; | |
| 15 | import { join } from "node:path"; | |
| 16 | import { registerHooks } from "node:module"; | |
| 17 | import { pathToFileURL } from "node:url"; | |
| 18 | import { spawn } from "node:child_process"; | |
| 19 | ||
| 20 | const dir = mkdtempSync(join(tmpdir(), "cursor-sdk-stub-")); | |
| 21 | const stub = join(dir, "stub.cjs"); | |
| 22 | writeFileSync( | |
| 23 | stub, | |
| 24 | "module.exports = { Agent: {}, AuthenticationError: class {}, createAgentPlatform: () => ({}), Cursor: {}, CursorSdkError: class {}, InMemoryCredentialStore: class {} };", | |
| 25 | ); | |
| 26 | registerHooks({ | |
| 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 | ||
| 35 | const { layer } = await import(${JSON.stringify(cursorSdkUrl)}); | |
| 36 | const Effect = await import("effect/Effect"); | |
| 37 | const Layer = await import("effect/Layer"); | |
| 38 | const Exit = await import("effect/Exit"); | |
| 39 | const Scope = await import("effect/Scope"); | |
| 40 | const scope = Scope.makeUnsafe(); | |
| 41 | await Effect.runPromise(Layer.buildWithScope(layer, scope)); | |
| 42 | const mode = process.argv[1]; | |
| 43 | const missingCwd = join(tmpdir(), "t3-missing-cwd-" + process.pid); | |
| 44 | ||
| 45 | if (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 | ||
| 85 | function 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 | ||
| 121 | describe("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 | ||
| 150 | describe("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. | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | ||
| 6 | import { 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 | */ | |
| 21 | const CURSOR_SHELL_SPAWN_MARKERS = [ | |
| 22 | "dump_zsh_state", | |
| 23 | "dump_bash_state", | |
| 24 | "__CURSOR_SANDBOX_ENV_RESTORE", | |
| 25 | ] as const; | |
| 26 | ||
| 27 | export 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 | ||
| 46 | function 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 | */ | |
| 63 | export 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. */ | |
| 72 | export 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 | 28 | } |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | export interface CursorSdkCatalogShape { | |
| 32 | readonly read: (apiKey: string) => Effect.Effect<CursorSdkCatalogSnapshot, CursorSdkCatalogError>; | |
| 33 | } | |
| 34 | ||
| 35 | export class CursorSdkCatalog extends Context.Service<CursorSdkCatalog, CursorSdkCatalogShape>()( | |
| 36 | "@t3tools/provider-cursor/server/CursorSdkCatalog", | |
| 37 | ) {} | |
| 31 | export class CursorSdkCatalog extends Context.Service< | |
| 32 | CursorSdkCatalog, | |
| 33 | { | |
| 34 | readonly read: ( | |
| 35 | apiKey: string, | |
| 36 | ) => Effect.Effect<CursorSdkCatalogSnapshot, CursorSdkCatalogError>; | |
| 37 | } | |
| 38 | >()("@t3tools/provider-cursor/server/CursorSdkCatalog") {} | |
| 38 | 39 | |
| 39 | 40 | function isAuthenticationFailure(cause: unknown): boolean { |
| 40 | 41 | return ( |
| ... | ... | @@ -87,6 +88,8 @@ export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function* |
| 87 | 88 | |
| 88 | 89 | export const layer = Layer.effect(CursorSdkCatalog, makeCursorSdkCatalog()); |
| 89 | 90 | |
| 90 | export function layerTest(read: CursorSdkCatalogShape["read"]): Layer.Layer<CursorSdkCatalog> { | |
| 91 | export function layerTest( | |
| 92 | read: CursorSdkCatalog["Service"]["read"], | |
| 93 | ): Layer.Layer<CursorSdkCatalog> { | |
| 91 | 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 | 34 | import * as Deferred from "effect/Deferred"; |
| 35 | 35 | import * as Effect from "effect/Effect"; |
| 36 | 36 | import * as FileSystem from "effect/FileSystem"; |
| 37 | import * as Layer from "effect/Layer"; | |
| 38 | 37 | import * as Option from "effect/Option"; |
| 39 | 38 | import * as Path from "effect/Path"; |
| 40 | 39 | import * as Queue from "effect/Queue"; |
| ... | ... | @@ -2645,24 +2644,3 @@ export const CursorAdapterV2Driver: ProviderAdapterDriver< |
| 2645 | 2644 | ), |
| 2646 | 2645 | ), |
| 2647 | 2646 | }; |
| 2648 | ||
| 2649 | const 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 | 7 | import * as Option from "effect/Option"; |
| 8 | 8 | import * as Stream from "effect/Stream"; |
| 9 | 9 | import { vi } from "vite-plus/test"; |
| 10 | import { HttpClient } from "effect/http"; | |
| 10 | import * as HttpClient from "effect/http/HttpClient"; | |
| 11 | 11 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 12 | 12 | |
| 13 | 13 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 14 | 14 | import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; |
| 15 | 15 | import { CursorDriver } from "./driver.ts"; |
| 16 | 16 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; |
| 17 | import * as CursorSdk from "./CursorSdk.ts"; | |
| 17 | 18 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 18 | 19 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 19 | 20 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| ... | ... | @@ -23,6 +24,7 @@ const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe( |
| 23 | 24 | Layer.provideMerge(NodeServices.layer), |
| 24 | 25 | Layer.provideMerge(IdAllocator.layer), |
| 25 | 26 | Layer.provideMerge(McpProviderSessions.layer), |
| 27 | Layer.provideMerge(CursorSdk.layer), | |
| 26 | 28 | Layer.provideMerge( |
| 27 | 29 | Layer.mock(CursorAgentSdk.CursorAgentSdkRunner)({ |
| 28 | 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 | 13 | import * as FileSystem from "effect/FileSystem"; |
| 14 | 14 | import * as Path from "effect/Path"; |
| 15 | 15 | import * as Schema from "effect/Schema"; |
| 16 | import { HttpClient } from "effect/http"; | |
| 16 | import * as HttpClient from "effect/http/HttpClient"; | |
| 17 | 17 | import { readCursorUsageLimits } from "./usageLimits.ts"; |
| 18 | 18 | |
| 19 | 19 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| ... | ... | @@ -40,6 +40,7 @@ import { probeCursorSkills } from "./skills.ts"; |
| 40 | 40 | import { makeCursorAuth } from "./auth.ts"; |
| 41 | 41 | import * as CursorCredentialStore from "./credentialStore.ts"; |
| 42 | 42 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; |
| 43 | import * as CursorSdk from "./CursorSdk.ts"; | |
| 43 | 44 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); |
| 44 | 45 | const isSdkRunnerError = Schema.is(CursorAgentSdk.CursorAgentSdkRunnerError); |
| 45 | 46 | |
| ... | ... | @@ -55,6 +56,7 @@ export type CursorDriverEnv = |
| 55 | 56 | | FileSystem.FileSystem |
| 56 | 57 | | Path.Path |
| 57 | 58 | | HttpClient.HttpClient |
| 59 | | CursorSdk.CursorSdk | |
| 58 | 60 | | ProviderHost.ProviderHost; |
| 59 | 61 | |
| 60 | 62 | export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { |
| ... | ... | @@ -238,7 +240,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { |
| 238 | 240 | new ProviderDriverError({ |
| 239 | 241 | driver: DRIVER_KIND, |
| 240 | 242 | instanceId, |
| 241 | detail: `Failed to build Cursor snapshot: ${cause.message ?? String(cause)}`, | |
| 243 | detail: "Failed to build Cursor snapshot.", | |
| 242 | 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. | |
| 2 | import * as NodeChildProcess from "node:child_process"; | |
| 3 | import { describe, expect, it } from "vite-plus/test"; | |
| 4 | ||
| 5 | const 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. | |
| 10 | const probeProgram = ` | |
| 11 | import { mkdtempSync, writeFileSync } from "node:fs"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { registerHooks } from "node:module"; | |
| 15 | import { pathToFileURL } from "node:url"; | |
| 16 | import { spawn } from "node:child_process"; | |
| 17 | ||
| 18 | const dir = mkdtempSync(join(tmpdir(), "cursor-sdk-stub-")); | |
| 19 | const stub = join(dir, "stub.cjs"); | |
| 20 | writeFileSync( | |
| 21 | stub, | |
| 22 | "module.exports = { Agent: {}, AuthenticationError: class {}, createAgentPlatform: () => ({}), Cursor: {}, CursorSdkError: class {}, InMemoryCredentialStore: class {} };", | |
| 23 | ); | |
| 24 | registerHooks({ | |
| 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 | ||
| 33 | const { isCursorShellSpawnFailure } = await import(${JSON.stringify(cursorSdkUrl)}); | |
| 34 | const mode = process.argv[1]; | |
| 35 | const missingCwd = join(tmpdir(), "t3-missing-cwd-" + process.pid); | |
| 36 | ||
| 37 | if (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 | ||
| 83 | if (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 | ||
| 113 | function 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 | ||
| 149 | describe("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 | ||
| 166 | describe("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 | // @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. | |
| 2 | 2 | import * as NodeModule from "node:module"; |
| 3 | 3 | |
| 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 | */ | |
| 17 | const CURSOR_SHELL_SPAWN_MARKERS = [ | |
| 18 | "dump_zsh_state", | |
| 19 | "dump_bash_state", | |
| 20 | "__CURSOR_SANDBOX_ENV_RESTORE", | |
| 21 | ] as const; | |
| 22 | ||
| 23 | export 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 | ||
| 42 | let cursorShellSpawnGuardInstalled = false; | |
| 43 | ||
| 44 | function 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. | |
| 65 | installCursorShellSpawnGuard(); | |
| 66 | ||
| 67 | 4 | // Cursor's Webpack chunks and local helpers must stay beside the SDK entry. |
| 68 | 5 | // createRequire also loads that disk-backed package from a Node SEA executable. |
| 69 | 6 | const 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 | 5 | import * as Effect from "effect/Effect"; |
| 6 | 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | 7 | import * as Fiber from "effect/Fiber"; |
| 8 | import * as Layer from "effect/Layer"; | |
| 8 | 9 | import * as TestClock from "effect/testing/TestClock"; |
| 9 | 10 | import * as Schema from "effect/Schema"; |
| 10 | 11 | import { createModelSelection } from "@t3tools/shared/model"; |
| 11 | 12 | import { beforeEach, vi } from "vite-plus/test"; |
| 12 | 13 | |
| 13 | 14 | import { makeCursorTextGeneration } from "./textGeneration.ts"; |
| 15 | import * as CursorSdk from "./CursorSdk.ts"; | |
| 14 | 16 | |
| 15 | 17 | const cursorSdkMock = vi.hoisted(() => ({ |
| 16 | 18 | create: vi.fn<(options: unknown) => Promise<unknown>>(), |
| ... | ... | @@ -25,13 +27,17 @@ const cursorSdkMock = vi.hoisted(() => ({ |
| 25 | 27 | })), |
| 26 | 28 | })); |
| 27 | 29 | |
| 28 | vi.mock("./sdk.ts", () => ({ Agent: { create: cursorSdkMock.create } })); | |
| 30 | vi.mock("./sdk.ts", () => ({ | |
| 31 | Agent: { create: cursorSdkMock.create }, | |
| 32 | createAgentPlatform: vi.fn(), | |
| 33 | })); | |
| 29 | 34 | |
| 30 | 35 | let hasCustomPolicy = false; |
| 31 | 36 | const layerFs = FileSystem.layerNoop({ |
| 32 | 37 | exists: () => Effect.succeed(hasCustomPolicy), |
| 33 | 38 | makeTempDirectoryScoped: () => Effect.succeed("/isolated-text-generation"), |
| 34 | 39 | }); |
| 40 | const layerTest = Layer.merge(layerFs, CursorSdk.layer); | |
| 35 | 41 | |
| 36 | 42 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); |
| 37 | 43 | const cursorSettings = decodeCursorSettings({ enabled: true }); |
| ... | ... | @@ -90,7 +96,7 @@ describe("CursorTextGeneration", () => { |
| 90 | 96 | expect(cursorSdkMock.create).toHaveBeenLastCalledWith( |
| 91 | 97 | expect.objectContaining({ apiKey: "second-browser-key" }), |
| 92 | 98 | ); |
| 93 | }).pipe(Effect.provide(layerFs)), | |
| 99 | }).pipe(Effect.provide(layerTest)), | |
| 94 | 100 | ); |
| 95 | 101 | |
| 96 | 102 | it.effect("uses the Cursor SDK prompt API with model parameters and API key", () => |
| ... | ... | @@ -139,7 +145,7 @@ describe("CursorTextGeneration", () => { |
| 139 | 145 | enableAgentRetries: true, |
| 140 | 146 | }, |
| 141 | 147 | }); |
| 142 | }).pipe(Effect.provide(layerFs)), | |
| 148 | }).pipe(Effect.provide(layerTest)), | |
| 143 | 149 | ); |
| 144 | 150 | |
| 145 | 151 | it.effect("continues in the temp directory when the SDK cannot sandbox", () => |
| ... | ... | @@ -175,7 +181,7 @@ describe("CursorTextGeneration", () => { |
| 175 | 181 | enableAgentRetries: true, |
| 176 | 182 | }, |
| 177 | 183 | }); |
| 178 | }).pipe(Effect.provide(layerFs)), | |
| 184 | }).pipe(Effect.provide(layerTest)), | |
| 179 | 185 | ); |
| 180 | 186 | |
| 181 | 187 | it.effect("does not retry Agent.create for errors other than an unsupported sandbox", () => |
| ... | ... | @@ -199,7 +205,7 @@ describe("CursorTextGeneration", () => { |
| 199 | 205 | |
| 200 | 206 | expect(error.detail).toBe("Cursor SDK text generation failed."); |
| 201 | 207 | expect(cursorSdkMock.create).toHaveBeenCalledTimes(1); |
| 202 | }).pipe(Effect.provide(layerFs)), | |
| 208 | }).pipe(Effect.provide(layerTest)), | |
| 203 | 209 | ); |
| 204 | 210 | |
| 205 | 211 | it.effect("accepts json objects with extra assistant text around them", () => |
| ... | ... | @@ -227,7 +233,7 @@ describe("CursorTextGeneration", () => { |
| 227 | 233 | |
| 228 | 234 | expect(generated.subject).toBe("Update README dummy comment with attribution and date"); |
| 229 | 235 | expect(generated.body).toBe(""); |
| 230 | }).pipe(Effect.provide(layerFs)), | |
| 236 | }).pipe(Effect.provide(layerTest)), | |
| 231 | 237 | ); |
| 232 | 238 | |
| 233 | 239 | it.effect("generates thread titles through Cursor SDK text generation", () => |
| ... | ... | @@ -251,7 +257,7 @@ describe("CursorTextGeneration", () => { |
| 251 | 257 | }); |
| 252 | 258 | |
| 253 | 259 | expect(generated.title).toBe("Trim reconnect spinner status after resume."); |
| 254 | }).pipe(Effect.provide(layerFs)), | |
| 260 | }).pipe(Effect.provide(layerTest)), | |
| 255 | 261 | ); |
| 256 | 262 | |
| 257 | 263 | it.effect.each(["error", "cancelled"] as const)( |
| ... | ... | @@ -285,7 +291,7 @@ describe("CursorTextGeneration", () => { |
| 285 | 291 | : "Cursor SDK request finished with an error.", |
| 286 | 292 | ); |
| 287 | 293 | expect(cursorSdkMock.close).toHaveBeenCalledOnce(); |
| 288 | }).pipe(Effect.provide(layerFs)), | |
| 294 | }).pipe(Effect.provide(layerTest)), | |
| 289 | 295 | ); |
| 290 | 296 | |
| 291 | 297 | it.effect("fails closed when ambient sandbox policy can expand write access", () => |
| ... | ... | @@ -301,7 +307,7 @@ describe("CursorTextGeneration", () => { |
| 301 | 307 | ); |
| 302 | 308 | expect(failure.detail).toContain("custom ~/.cursor/sandbox.json"); |
| 303 | 309 | expect(cursorSdkMock.prompt).not.toHaveBeenCalled(); |
| 304 | }).pipe(Effect.provide(layerFs)), | |
| 310 | }).pipe(Effect.provide(layerTest)), | |
| 305 | 311 | ); |
| 306 | 312 | |
| 307 | 313 | it.effect("cancels the native run when text generation times out", () => |
| ... | ... | @@ -327,7 +333,7 @@ describe("CursorTextGeneration", () => { |
| 327 | 333 | expect((yield* Fiber.join(result)).detail).toContain("timed out"); |
| 328 | 334 | expect(cursorSdkMock.cancel).toHaveBeenCalledOnce(); |
| 329 | 335 | expect(cursorSdkMock.close).toHaveBeenCalledOnce(); |
| 330 | }).pipe(Effect.provide(layerFs), Effect.scoped), | |
| 336 | }).pipe(Effect.provide(layerTest), Effect.scoped), | |
| 331 | 337 | ); |
| 332 | 338 | |
| 333 | 339 | it.effect.each(["create", "send"] as const)( |
| ... | ... | @@ -389,7 +395,7 @@ describe("CursorTextGeneration", () => { |
| 389 | 395 | expect(cursorSdkMock.close).toHaveBeenCalledOnce(); |
| 390 | 396 | expect(wait).not.toHaveBeenCalled(); |
| 391 | 397 | if (phase === "send") expect(cursorSdkMock.cancel).toHaveBeenCalledOnce(); |
| 392 | }).pipe(Effect.provide(layerFs), Effect.scoped), | |
| 398 | }).pipe(Effect.provide(layerTest), Effect.scoped), | |
| 393 | 399 | ); |
| 394 | 400 | |
| 395 | 401 | it.effect("requires CURSOR_API_KEY before calling the SDK", () => |
| ... | ... | @@ -411,6 +417,6 @@ describe("CursorTextGeneration", () => { |
| 411 | 417 | |
| 412 | 418 | expect(error.detail).toBe("Sign in with Cursor or add CURSOR_API_KEY in provider settings."); |
| 413 | 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 | 2 | import * as FileSystem from "effect/FileSystem"; |
| 3 | 3 | |
| 4 | 4 | import type { AgentOptions, RunResult } from "@cursor/sdk"; |
| 5 | import { Agent } from "./sdk.ts"; | |
| 6 | 5 | import * as Effect from "effect/Effect"; |
| 7 | 6 | import * as Option from "effect/Option"; |
| 8 | 7 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -13,6 +12,7 @@ import type { CursorSettings } from "../settings.ts"; |
| 13 | 12 | import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; |
| 14 | 13 | import { cursorSdkModelSelection } from "./sdkModel.ts"; |
| 15 | 14 | import type { CursorAuth } from "./auth.ts"; |
| 15 | import * as CursorSdk from "./CursorSdk.ts"; | |
| 16 | 16 | |
| 17 | 17 | const CURSOR_TIMEOUT_MS = 180_000; |
| 18 | 18 | |
| ... | ... | @@ -58,6 +58,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu |
| 58 | 58 | withAccess?: CursorAuth["withAccess"], |
| 59 | 59 | ) { |
| 60 | 60 | const fs = yield* FileSystem.FileSystem; |
| 61 | const { Agent } = yield* CursorSdk.CursorSdk; | |
| 61 | 62 | const resolvedEnvironment = environment ?? process.env; |
| 62 | 63 | |
| 63 | 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 | 4 | import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | 6 | import * as Path from "effect/Path"; |
| 7 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 7 | import * as HttpClient from "effect/http/HttpClient"; | |
| 8 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; | |
| 8 | 9 | import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./usageLimits.ts"; |
| 9 | 10 | |
| 10 | 11 | const 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 | 9 | import * as Option from "effect/Option"; |
| 10 | 10 | import * as Path from "effect/Path"; |
| 11 | 11 | import * as Schema from "effect/Schema"; |
| 12 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | |
| 12 | import * as HttpClient from "effect/http/HttpClient"; | |
| 13 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; | |
| 14 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; | |
| 13 | 15 | import { |
| 14 | 16 | clampPercent, |
| 15 | 17 | makeUnavailableUsageLimits, |