From 9a2b13d360642819a144285aaebee65075397789 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:08:17 -0700 Subject: [PATCH] fix(server): sandboxed Cursor threads keep working after a Full access thread The Cursor SDK caches whether local sandboxing works the first time any run starts, and only sandboxed runs point it at its cursorsandbox helper first. After one Full access run it caches "unsupported", and every later Supervised, Auto-accept edits, or Auto Cursor turn fails to start until the server restarts. Warm a bare sandboxed executor before the first unsandboxed agent opens so the SDK caches the real answer. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/CursorAgentSdk.ts | 35 ++++++++++- .../CursorOrchestratorV2.live.test.ts | 62 +++++++++++++++++++ apps/server/src/provider/cursorSdk.ts | 10 ++- 3 files changed, 104 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.ts b/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.ts index b060148fe6f9..96027ebe98be 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.ts @@ -16,7 +16,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { Agent } from "../../provider/cursorSdk.ts"; +import { Agent, createAgentPlatform } from "../../provider/cursorSdk.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; @@ -295,6 +295,36 @@ function makeCursorAgentSdkProtocolLogger(input: { .pipe(Effect.ignore); } +/** + * The Cursor SDK decides once per process whether local sandboxing works, and + * caches the answer the first time any run starts. Only sandboxed runs point + * it at its `cursorsandbox` helper first, so after an unsandboxed (Full access) + * run it caches "unsupported" and rejects every later sandboxed run until the + * server restarts. Warming a bare sandboxed executor before the first + * unsandboxed agent opens lets the SDK find the helper and cache the real + * answer. Warming is best effort: on a machine without sandbox support it + * fails, the SDK caches "unsupported", and sandboxed runs report that as + * before. + */ +let cursorSandboxSupportPrime: Promise | undefined; + +function primeCursorSandboxSupport(options: AgentOptions): Promise { + cursorSandboxSupportPrime ??= (async () => { + const cwd = typeof options.local?.cwd === "string" ? options.local.cwd : undefined; + const platform = await createAgentPlatform(cwd === undefined ? {} : { workspaceRef: cwd }); + const release = await platform.prewarmLocalWorkspace({ + ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }), + local: { + ...(cwd === undefined ? {} : { cwd }), + settingSources: [], + sandboxOptions: { enabled: true }, + }, + }); + await release(); + })().catch(() => undefined); + return cursorSandboxSupportPrime; +} + /** * Runs agents through the Cursor SDK, logging every frame to the protocol * logger chosen for each opened agent. The live layer writes the native @@ -306,6 +336,9 @@ export function makeCursorAgentSdkRunner( ): CursorAgentSdkRunnerShape { return CursorAgentSdkRunner.of({ open: Effect.fn("CursorAgentSdkRunner.open")(function* (input) { + if (input.options.local?.sandboxOptions?.enabled === false) { + yield* Effect.promise(() => primeCursorSandboxSupport(input.options)); + } const protocolLogger = protocolLoggerFor(input); const log = (event: CursorAgentSdkProtocolLogEvent) => protocolLogger === undefined ? Effect.void : protocolLogger(event); diff --git a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts index b2971990d12b..8a008a585126 100644 --- a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts @@ -220,6 +220,68 @@ describe.runIf(process.env.T3_CURSOR_LIVE_ORCHESTRATOR === "1")( 360_000, ); + it.live( + "runs a sandboxed thread after a full access thread in the same server", + () => + Effect.gen(function* () { + yield* runEffectWorkerDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); + const orchestrator = yield* OrchestratorV2; + const projectId = ProjectId.make("project:cursor-live-sandbox-after-full-access"); + + const runThread = Effect.fn("CursorOrchestratorV2Live.runThread")(function* (input: { + readonly name: string; + readonly runtimeMode: "full-access" | "approval-required"; + }) { + const threadId = ThreadId.make(`thread:cursor-live-sandbox:${input.name}`); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:cursor-live-sandbox:${input.name}:create`), + threadId, + projectId, + title: `Cursor live sandbox ${input.name}`, + modelSelection: CURSOR_MODEL_SELECTION, + runtimeMode: input.runtimeMode, + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:cursor-live-sandbox:${input.name}:message`), + threadId, + messageId: MessageId.make(`message:cursor-live-sandbox:${input.name}`), + text: "Respond with exactly: OK. Do not use any tools.", + attachments: [], + modelSelection: CURSOR_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }); + return yield* waitForIdle(threadId); + }); + + // The SDK decides once per process whether local sandboxing works. + // The unsandboxed thread must run first to catch a wrong verdict. + const fullAccess = yield* runThread({ name: "full-access", runtimeMode: "full-access" }); + const supervised = yield* runThread({ + name: "supervised", + runtimeMode: "approval-required", + }); + + assert.deepEqual( + fullAccess.runs.map((run) => run.status), + ["completed"], + ); + assert.deepEqual( + supervised.runs.map((run) => run.status), + ["completed"], + ); + }).pipe(Effect.provide(liveLayer), Effect.scoped), + 360_000, + ); + it.live( "spawns native subagents with child thread lineage", () => diff --git a/apps/server/src/provider/cursorSdk.ts b/apps/server/src/provider/cursorSdk.ts index 28593b4eee9d..abe231629b13 100644 --- a/apps/server/src/provider/cursorSdk.ts +++ b/apps/server/src/provider/cursorSdk.ts @@ -4,5 +4,11 @@ import * as NodeModule from "node:module"; // Cursor's Webpack chunks and local helpers must stay beside the SDK entry. // createRequire also loads that disk-backed package from a Node SEA executable. const requireCursorSdk = NodeModule.createRequire(import.meta.url); -export const { Agent, AuthenticationError, Cursor, CursorSdkError, InMemoryCredentialStore } = - requireCursorSdk("@cursor/sdk") as typeof import("@cursor/sdk"); +export const { + Agent, + AuthenticationError, + createAgentPlatform, + Cursor, + CursorSdkError, + InMemoryCredentialStore, +} = requireCursorSdk("@cursor/sdk") as typeof import("@cursor/sdk");