diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index eb02fb4efc4..83ac24730a6 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -124,6 +124,7 @@ export { paperclipRunnerTransitionConfig, normalizeLegacyRunnerProvider, resolvePaperclipRunnerPermissionMode, + resolvePaperclipRunnerCursorMode, } from "./paperclip-runner-permissions.js"; export { PAPERCLIP_RUNNER_INGRESS_PORT, diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.test.ts b/packages/adapter-utils/src/paperclip-runner-permissions.test.ts index 077f5336436..906863aa1b6 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.test.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.test.ts @@ -6,6 +6,7 @@ import { isPaperclipRunnerProvider, resolvePaperclipRunnerModel, resolvePaperclipRunnerPermissionMode, + resolvePaperclipRunnerCursorMode, } from "./paperclip-runner-permissions.js"; describe("Paperclip Runner permission defaults", () => { @@ -69,3 +70,17 @@ describe("Paperclip Runner permission defaults", () => { expect(resolvePaperclipRunnerModel("codex", " gpt-5.5 ")).toBe("gpt-5.5"); }); }); + + +describe("Cursor session mode admission", () => { + it.each([undefined, "agent", "plan", "ask"])("retains mode %s with an explicit Cursor default", mode => { + expect(resolvePaperclipRunnerCursorMode("acpx", "cursor", mode)).toBe(mode ?? "agent"); + }); + it.each([null, "", "auto", "PLAN", true, {}, ["plan"]])("rejects invalid mode %j", mode => { + expect(() => resolvePaperclipRunnerCursorMode("acpx", "cursor", mode)).toThrow("Cursor session mode"); + }); + it.each([["codex", "cursor"], ["acpx", "copilot"], ["acpx", "pi"], ["acpx", "claude"]])("rejects mode on %s/%s", (provider, agent) => { + expect(resolvePaperclipRunnerCursorMode(provider, agent, undefined)).toBeUndefined(); + expect(() => resolvePaperclipRunnerCursorMode(provider, agent, "plan")).toThrow("only for Cursor"); + }); +}); diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.ts b/packages/adapter-utils/src/paperclip-runner-permissions.ts index cb32c0fd78c..2fa8c9730a9 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.ts @@ -162,6 +162,21 @@ export function resolvePaperclipRunnerPermissionMode( : capability.defaultMode; } +/** Native session behavior is independent of tool permission policy. */ +export function resolvePaperclipRunnerCursorMode( + provider: unknown, + agent: unknown, + value: unknown, +): "agent" | "plan" | "ask" | undefined { + if (provider !== "acpx" || agent !== "cursor") { + if (value !== undefined) throw new Error("acpxSessionMode is supported only for Cursor"); + return undefined; + } + if (value === undefined) return "agent"; + if (value === "agent" || value === "plan" || value === "ask") return value; + throw new Error("Cursor session mode must be agent, plan, or ask"); +} + export function resolvePaperclipRunnerModel( provider: keyof typeof PAPERCLIP_RUNNER_DEFAULT_MODELS, value: unknown, diff --git a/packages/adapters/codex-local/src/ui/build-config.test.ts b/packages/adapters/codex-local/src/ui/build-config.test.ts index a431aea47a0..babdaed2a89 100644 --- a/packages/adapters/codex-local/src/ui/build-config.test.ts +++ b/packages/adapters/codex-local/src/ui/build-config.test.ts @@ -226,19 +226,25 @@ describe("buildPaperclipRunnerConfig", () => { expect(config).not.toHaveProperty("acpxAgent"); }); - it("does not materialize the unavailable ACPX Pi profile", () => { + it.each(["cursor", "copilot", "pi"])("requires an explicit %s model without substituting Claude", acpxAgent => { + expect(() => buildPaperclipRunnerConfig(makeValues({ + model: "", adapterSchemaValues: { provider: "acpx", acpxAgent }, + }))).toThrow("requires an explicit provider model"); expect(buildPaperclipRunnerConfig(makeValues({ - adapterType: "paperclip_runner", - model: "", - adapterSchemaValues: { - provider: "acpx", - acpxAgent: "pi", - }, - }))).toMatchObject({ - provider: "acpx", - acpxAgent: "claude", - model: "claude-sonnet-5", - }); + model: "exact-provider-model", adapterSchemaValues: { provider: "acpx", acpxAgent }, + }))).toMatchObject({ provider: "acpx", acpxAgent, model: "exact-provider-model" }); + }); + + it.each([undefined, "agent", "plan", "ask"])("preserves Cursor session mode %s for server admission", acpxSessionMode => { + expect(buildPaperclipRunnerConfig(makeValues({ + model: "exact-cursor-model", adapterSchemaValues: { provider: "acpx", acpxAgent: "cursor", acpxSessionMode }, + }))).toMatchObject({ provider: "acpx", acpxAgent: "cursor", acpxSessionMode: acpxSessionMode ?? "agent" }); + }); + + it("rejects a Cursor mode accidentally retained on another provider", () => { + expect(() => buildPaperclipRunnerConfig(makeValues({ + model: "exact-copilot-model", adapterSchemaValues: { provider: "acpx", acpxAgent: "copilot", acpxSessionMode: "plan" }, + }))).toThrow("only for Cursor"); }); it("builds a Claude Managed profile reference with explicit retention and spend controls", () => { diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index eb381bb7890..1a48826c2cd 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -5,6 +5,8 @@ import { resolvePaperclipRunnerModel, resolvePaperclipRunnerIdleTimeoutMs, resolvePaperclipRunnerPermissionMode, + resolvePaperclipRunnerCursorMode, + PAPERCLIP_RUNNER_ACPX_PROFILES, type CreateConfigValues, } from "@paperclipai/adapter-utils"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "../index.js"; @@ -95,7 +97,8 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record profile.value === schemaValues.acpxAgent)?.value ?? "claude"; + const cursorMode = resolvePaperclipRunnerCursorMode(provider, acpxAgent, schemaValues.acpxSessionMode); const schemaModel = typeof schemaValues.model === "string" ? schemaValues.model.trim() @@ -103,6 +106,9 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record {} export type AcpxNativeSessionBackendOptions = @@ -72,6 +72,7 @@ export function createAcpxNativeSessionBackend( agent: input.provider.agent, model: input.provider.model, permissionMode: input.provider.permissionMode ?? "approve-reads", + cursorMode: input.provider.cursorMode, systemInstructions, runtimeContext: "runtimeContext" in input ? input.runtimeContext : null, providerPolicy: { readOnly: "executionMode" in input && input.executionMode === "plan" }, diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 5fead66cd9c..917c62d2476 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -287,6 +287,7 @@ async function dispatch( clientCapabilities: acpxProfileClientCapabilities(params.agent), model: params.model, permissionMode: params.permissionMode, + cursorMode: params.cursorMode, providerPolicy: params.providerPolicy, systemInstructions: params.systemInstructions, runtimeContext: params.runtimeContext, @@ -1155,6 +1156,7 @@ function parseOpenParams( ): AcpxSidecarOpenParams { const agent = requireQualifiedAgent(value.agent); const model = requiredText(value.model, "model"); + if (value.cursorMode !== undefined && agent !== "cursor") throw new Error("cursorMode is supported only for Cursor"); resolveQualifiedAcpxProfile(agent, model); if ( value.providerSessionKey !== undefined && @@ -1174,6 +1176,7 @@ function parseOpenParams( agent, model, permissionMode: requiredPermissionMode(value.permissionMode), + ...(agent === "cursor" ? { cursorMode: requiredCursorMode(value.cursorMode === undefined ? "agent" : value.cursorMode) } : {}), permissionModePinned: value.permissionModePinned === true, ...(value.providerPolicy == null ? {} : { providerPolicy: parseProviderPolicy(value.providerPolicy) }), systemInstructions: boundedText( @@ -1243,6 +1246,7 @@ function parseExpectedIdentity(value: unknown): AcpxExpectedSessionIdentity { ...(input.permissionMode === undefined ? {} : { permissionMode: requiredPermissionMode(input.permissionMode) }), + ...(input.cursorMode === undefined ? {} : { cursorMode: requiredCursorMode(input.cursorMode) }), providerLifetimeFenceCandidates: requiredFenceCandidates( input.providerLifetimeFenceCandidates, ), @@ -1267,6 +1271,11 @@ function requiredFenceCandidates( return Object.freeze([...value]) as readonly [number, number, number]; } +function requiredCursorMode(value: unknown): "agent" | "plan" | "ask" { + if (value === "agent" || value === "plan" || value === "ask") return value; + throw new Error("cursorMode must be agent, plan, or ask"); +} + function requiredPermissionMode( value: unknown, ): AcpxSidecarOpenParams["permissionMode"] { diff --git a/packages/paperclip-runner/src/contracts/harness-driver.ts b/packages/paperclip-runner/src/contracts/harness-driver.ts index b6bbe6103bf..9be6d782feb 100644 --- a/packages/paperclip-runner/src/contracts/harness-driver.ts +++ b/packages/paperclip-runner/src/contracts/harness-driver.ts @@ -454,6 +454,8 @@ export interface AcpxSessionIdentity { effectiveModel: string; /** Missing on legacy snapshots; those used the historical approve-reads behavior. */ permissionMode?: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; + /** Effective native mode; required on current Cursor session identities. */ + cursorMode?: "agent" | "plan" | "ask"; providerLifetimeFenceCandidates: readonly [number, number, number]; } diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts index 46f2d858bbd..f14285af97b 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -504,6 +504,20 @@ describe("native task context ownership", () => { }); } + it.each(["agent", "plan", "ask"])("round-trips Cursor mode %s through the closed execution contract", cursorMode => { + const { qualificationModel: _model, reportedModelId: _reported, permissionPolicy: _permission, + modelPolicy: _policy, qualificationStatus: _status, ...profile } = QUALIFIED_ACPX_PROFILES.cursor; + const value = { ...currentInput(), session: { ...currentInput().session, driverKind: "acpx_runtime" }, + provider: { kind: "acpx", agent: "cursor", model: "explicit-model", permissionMode: "deny-all", cursorMode, profile } }; + const result = parseNativeExecutionInput(value); + expect(result.provider).toEqual(value.provider); + expect(parseNativeExecutionInput(result)).toEqual(result); + for (const mode of [null, "", "PLAN", "auto", true, { toString: () => "plan" }]) { + expect(() => parseNativeExecutionInput({ ...value, provider: { ...value.provider, cursorMode: mode } })).toThrow("cursorMode"); + } + expect(() => parseNativeExecutionInput({ ...value, provider: { ...value.provider, agent: "copilot" } })).toThrow("cursorMode"); + }); + it.each([ ["v4", "paperclip.native-execution-input.v4", "paperclip.native-model-envelope.v2"], ["v5", NATIVE_EXECUTION_INPUT_SCHEMA, "paperclip.native-model-envelope.v3"], diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index 25b6f160875..ce286ed27c8 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -84,6 +84,7 @@ export type NativeAcpxAgent = "pi" | "claude" | "codex" | "grok" | "cursor" | "c export type NativeCodexApprovalPolicy = "never" | "on-request" | "untrusted"; export type NativeOpenCodePermissionMode = "allow" | "ask" | "deny"; export type NativeAcpxPermissionMode = "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; +export type NativeCursorMode = "agent" | "plan" | "ask"; export interface NativeAcpxProfileSnapshot { driverKind: "acpx_runtime"; @@ -123,6 +124,7 @@ export type NativeProviderConfig = agent: NativeAcpxAgent; model: string; permissionMode?: NativeAcpxPermissionMode; + cursorMode?: NativeCursorMode; /** Present only in persisted v1-v3 inputs. */ permissionPolicy?: "interactive"; profile: NativeAcpxProfileSnapshot; @@ -137,6 +139,7 @@ export type NativeProviderConfigV4 = agent: NativeAcpxAgent; model: string; permissionMode: NativeAcpxPermissionMode; + cursorMode?: NativeCursorMode; profile: NativeAcpxProfileSnapshot; }; @@ -450,7 +453,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput : provider.kind === "aws_agentcore" ? ["kind", "model", "agentCoreProfile", "maxEstimatedSessionCostUsd", "invocationLimits"] : provider.kind === "acpx" - ? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile"] + ? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile", ...(isV4 ? ["cursorMode"] : [])] : provider.kind === "codex" && isV4 ? ["kind", "model", "approvalPolicy", ...(isV5 ? ["reasoningEffort"] : [])] : provider.kind === "opencode" && isV4 @@ -570,6 +573,10 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput invocationLimits: { maxIterations, maxOutputTokens, timeoutSeconds }, }; } else if (provider.kind === "acpx") { + if (provider.cursorMode !== undefined && (provider.agent !== "cursor" + || (provider.cursorMode !== "agent" && provider.cursorMode !== "plan" && provider.cursorMode !== "ask"))) { + throw new NativeExecutionInputError("input.provider.cursorMode must be agent, plan, or ask and is supported only for Cursor"); + } if (providerModel === null) { throw new NativeExecutionInputError("input.provider.model is required for acpx"); } @@ -617,6 +624,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput ...(isV4 ? { permissionMode: provider.permissionMode as NativeAcpxPermissionMode } : { permissionPolicy: "interactive" as const }), + ...(provider.cursorMode === undefined ? {} : { cursorMode: provider.cursorMode as NativeCursorMode }), profile: { driverKind: "acpx_runtime", protocolVersion: 1, diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index 7f8da29c6b2..9dbb9e53558 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -142,6 +142,7 @@ export interface CodexAcpxDriverOptions { runtimeDirectory: string; model: string; permissionMode?: NativeAcpxPermissionMode; + cursorMode?: "agent" | "plan" | "ask"; providerPolicy?: { readOnly: boolean }; runtimeContext?: OpenAcpxRuntimeHostOptions["runtimeContext"]; systemInstructions?: string; @@ -455,6 +456,7 @@ export class CodexAcpxDriver implements HarnessDriver { clientCapabilities: acpxProfileClientCapabilities(this.#options.agent ?? "codex"), model: this.#options.model, permissionMode: this.#options.permissionMode ?? "approve-all", + cursorMode: this.#options.cursorMode, providerPolicy: this.#options.providerPolicy, runtimeContext: this.#options.runtimeContext, systemInstructions: this.#options.systemInstructions, @@ -1270,6 +1272,7 @@ class CodexAcpxSession implements HarnessSession { requestedModel: identity.requestedModel, effectiveModel: identity.effectiveModel, permissionMode: identity.permissionMode, + ...(identity.cursorMode === undefined ? {} : { cursorMode: identity.cursorMode }), providerLifetimeFenceCandidates: identity.providerLifetimeFenceCandidates, }, @@ -2102,6 +2105,7 @@ function validateRecoverySnapshot(snapshot: PersistedHarnessSession): void { !["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes( identity.permissionMode, )) || + (identity.cursorMode !== undefined && !["agent", "plan", "ask"].includes(identity.cursorMode)) || !validProviderLifetimeFenceCandidates( identity.providerLifetimeFenceCandidates, ) diff --git a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts index 0d2abe1273b..6ff7fa153fd 100644 --- a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts +++ b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts @@ -45,6 +45,7 @@ export interface AcpxSidecarOpenParams { agent: QualifiedAcpxAgent; model: string; permissionMode: NativeAcpxPermissionMode; + cursorMode?: "agent" | "plan" | "ask"; permissionModePinned: boolean; providerPolicy?: { readOnly: boolean }; systemInstructions: string; @@ -65,6 +66,7 @@ export interface AcpxExpectedSessionIdentity { requestedModel: string; effectiveModel: string; permissionMode?: NativeAcpxPermissionMode; + cursorMode?: "agent" | "plan" | "ask"; providerLifetimeFenceCandidates: readonly [number, number, number]; } diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 4dc4f6271a8..b73754b8851 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -7642,3 +7642,13 @@ it("preserves prepared input through runnerd and the real OpenCode proxy boundar evidence: () => bundle.evidence(), }); }, 30_000); + + +it.each([ + { provider: "codex", acpxAgent: "cursor", acpxCursorMode: "plan" }, + { provider: "acpx", acpxAgent: "copilot", acpxCursorMode: "plan" }, + { provider: "acpx", acpxAgent: "cursor", acpxCursorMode: "auto" }, +] as const)("rejects invalid Cursor mode transport options before allocating resources: %j", options => { + expect(() => createCapabilityRunnerdCodexTransport(options as unknown as Parameters[0])) + .toThrow("acpxCursorMode"); +}); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 9faad912520..a94515ee6b9 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1126,6 +1126,7 @@ export interface CapabilityRunnerdCodexTransportOptions { /** Explicit evaluation-only candidate selection, never derived from persisted session input. */ acpxCandidateProfile?: "pi" | "cursor" | "copilot"; acpxPermissionMode?: NativeAcpxPermissionMode; + acpxCursorMode?: "agent" | "plan" | "ask"; acpxPermissionModePinned?: boolean; acpxSidecarPath?: string; /** SHA-256 verified by the provider-pack authority before runner startup. */ @@ -3470,6 +3471,10 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { readonly #bridgedRuntimeInputs = new Map(); constructor(readonly options: CapabilityRunnerdCodexTransportOptions) { + if (options.acpxCursorMode !== undefined && (options.provider !== "acpx" || options.acpxAgent !== "cursor" + || !["agent", "plan", "ask"].includes(options.acpxCursorMode))) { + throw new Error("acpxCursorMode must be agent, plan, or ask and is supported only for Cursor"); + } if (options.adoptExistingRunner && !options.stateDirectory?.trim()) { throw new Error("native_adopted_runner_state_directory_required"); } @@ -4645,6 +4650,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { cwd: String(params.cwd ?? tmpdir()), instructions: baseInstructions, providerPolicy: { readOnly: params.permissions === "paperclip-runner-workspace-read-only" }, + ...(acpxProfile!.agent === "cursor" ? { cursorMode: this.options.acpxCursorMode ?? "agent" } : {}), permissionMode: resolveRunnerdAcpxPermissionMode( this.options.acpxPermissionMode, ), diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 62cef753d7b..da8be96f896 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -563,6 +563,19 @@ const paperclipRunnerAdapter: ServerAdapterModule = { hint: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.opencode.description, meta: { visibleWhen: { key: "provider", value: "opencode" } }, }, + { + key: "acpxSessionMode", + label: "Cursor mode", + type: "select" as const, + default: "agent", + options: [ + { value: "agent", label: "Agent" }, + { value: "plan", label: "Plan" }, + { value: "ask", label: "Ask" }, + ], + hint: "Select Cursor's session mode. Permissions and company approval rules still apply.", + meta: { visibleWhen: { key: "acpxAgent", value: "cursor" } }, + }, { key: "acpxPermissionMode", label: "ACPX permission mode", diff --git a/server/src/services/heartbeat-runner-provider-config.test.ts b/server/src/services/heartbeat-runner-provider-config.test.ts index da0a675adbe..6e84cd2e24e 100644 --- a/server/src/services/heartbeat-runner-provider-config.test.ts +++ b/server/src/services/heartbeat-runner-provider-config.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { assertAgentCoreProfileRecoveryBinding, @@ -8,6 +8,28 @@ import { } from "./native-runtime/provider-profile.js"; describe("Paperclip Runner native provider configuration", () => { + afterEach(() => vi.unstubAllEnvs()); + + it.each([undefined, "agent", "plan", "ask"])("passes Cursor mode %s only after exact candidate admission", acpxSessionMode => { + const adapterConfig = { provider: "acpx", acpxAgent: "cursor", model: "exact-cursor-model", acpxSessionMode }; + vi.stubEnv("PAPERCLIP_RUNNER_ACPX_QUALIFICATION", ""); + expect(() => resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig })).toThrow("awaiting local and Daytona qualification"); + vi.stubEnv("PAPERCLIP_RUNNER_ACPX_QUALIFICATION", JSON.stringify([{ agent: "cursor", model: "exact-cursor-model" }])); + expect(resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig })).toMatchObject({ + provider: "acpx", acpxAgent: "cursor", model: "exact-cursor-model", acpxSessionMode: acpxSessionMode ?? "agent", acpxPermissionMode: "approve-all", + }); + }); + + it.each([ + { provider: "acpx", acpxAgent: "cursor", acpxSessionMode: "auto" }, + { provider: "acpx", acpxAgent: "cursor", acpxSessionMode: null }, + { provider: "acpx", acpxAgent: "copilot", acpxSessionMode: "plan" }, + { provider: "codex", acpxSessionMode: "plan" }, + ])("rejects invalid or foreign mode before provider admission: %j", adapterConfig => { + expect(() => resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig })) + .toThrowError(expect.objectContaining({ code: "paperclip_runner_cursor_mode_invalid" })); + }); + it.each([undefined, "approve-all", "approve-paperclip", "approve-reads", "deny-all"])( "passes Grok's full-auto default or explicit %s policy to the native runner", (acpxPermissionMode) => { diff --git a/server/src/services/native-runtime/native-agent-runtime-inheritance.test.ts b/server/src/services/native-runtime/native-agent-runtime-inheritance.test.ts index 8881e51a553..95aeb852a6f 100644 --- a/server/src/services/native-runtime/native-agent-runtime-inheritance.test.ts +++ b/server/src/services/native-runtime/native-agent-runtime-inheritance.test.ts @@ -3,6 +3,13 @@ import { inheritNativeRunnerAdapterConfig } from "./native-agent-runtime-inherit import { resolvePaperclipRunnerProviderProfile } from "./provider-profile.js"; describe("native hire runtime inheritance", () => { + it("preserves Cursor's selected mode without its live session or credentials", () => { + expect(inheritNativeRunnerAdapterConfig({ + provider: "acpx", acpxAgent: "cursor", acpxSessionMode: "plan", model: "exact-cursor-model", + runtimeSessionId: "parent-session", env: { CURSOR_API_KEY: "parent-secret" }, + })).toEqual({ provider: "acpx", acpxAgent: "cursor", acpxSessionMode: "plan", model: "exact-cursor-model" }); + }); + it.each([ ["claude_managed", "managedProfileId", "managedAgentsRetentionAcknowledged"], ["aws_agentcore", "agentCoreProfileId", "agentCoreRetentionAcknowledged"], diff --git a/server/src/services/native-runtime/native-agent-runtime-inheritance.ts b/server/src/services/native-runtime/native-agent-runtime-inheritance.ts index efdcb3b898f..bbe10f6d2de 100644 --- a/server/src/services/native-runtime/native-agent-runtime-inheritance.ts +++ b/server/src/services/native-runtime/native-agent-runtime-inheritance.ts @@ -12,6 +12,7 @@ export const INHERITABLE_NATIVE_RUNNER_CONFIG_KEYS = [ "codexPermissionMode", "opencodePermissionMode", "acpxPermissionMode", + "acpxSessionMode", "lifecycleMode", "modelReasoningEffort", "maxIterations", diff --git a/server/src/services/native-runtime/native-execution-input.test.ts b/server/src/services/native-runtime/native-execution-input.test.ts index 9df5a0f51fe..75194d98c80 100644 --- a/server/src/services/native-runtime/native-execution-input.test.ts +++ b/server/src/services/native-runtime/native-execution-input.test.ts @@ -585,3 +585,28 @@ describe("native completion references", () => { expect(input.completionContract.contract).toEqual(args.completionContract.contract); }); }); + + +describe("Cursor mode native execution projection", () => { + function fixture(acpxSessionMode?: "agent" | "plan" | "ask") { + return { + companyId: "company-1", runId: "run-1", agentId: "agent-1", + issue: { id: "issue-1", identifier: "MODE-1", title: "Review a plan", description: null, workMode: "standard" }, + taskPrompt: "Review the project.", + workspace: { id: "workspace-1", cwd: "/workspace", repoUrl: null, repoRef: null, branchName: null }, + normalizedSessionId: null, provider: "acpx" as const, acpxAgent: "cursor" as const, + model: "exact-cursor-model", acpxSessionMode, acpxPermissionMode: "deny-all" as const, + completionContract: { id: "contract-1", sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1", contract: { revision: "1", objective: "Review", criteria: [{ id: "output", requirement: "Review" }] } }, + runtimeContext: nativeRuntimeContextFixture(), + }; + } + it.each([undefined, "agent", "plan", "ask"] as const)("preserves mode %s independently of permissions and task planning", mode => { + const result = buildNativeExecutionInput(fixture(mode)); + expect(result.provider).toMatchObject({ kind: "acpx", agent: "cursor", cursorMode: mode ?? "agent", permissionMode: "deny-all" }); + expect(result.executionMode).toBe("default"); + expect(result.task.workMode).toBe("standard"); + }); + it("rejects a Cursor mode attached to another harness", () => { + expect(() => buildNativeExecutionInput({ ...fixture("plan"), acpxAgent: "copilot" })).toThrow("only for Cursor"); + }); +}); diff --git a/server/src/services/native-runtime/native-execution-input.ts b/server/src/services/native-runtime/native-execution-input.ts index 27f7c9f2c36..47e021baeb3 100644 --- a/server/src/services/native-runtime/native-execution-input.ts +++ b/server/src/services/native-runtime/native-execution-input.ts @@ -1,4 +1,5 @@ import type { PaperclipTurnContext } from "@paperclipai/adapter-utils/server-utils"; +import { resolvePaperclipRunnerCursorMode } from "@paperclipai/adapter-utils"; import { createHash } from "node:crypto"; import { buildNativeContinuationPrompt } from "./native-continuation.js"; import type { @@ -69,6 +70,7 @@ export function buildNativeExecutionInput(input: { codexReasoningEffort?: string; opencodePermissionMode?: NativeOpenCodePermissionMode; acpxPermissionMode?: NativeAcpxPermissionMode; + acpxSessionMode?: "agent" | "plan" | "ask"; model?: string | null; managedProfile?: Extract< NativeExecutionInputV5["provider"], @@ -100,6 +102,7 @@ export function buildNativeExecutionInput(input: { if (input.issue.workMode !== "standard" && input.issue.workMode !== "planning" && input.issue.workMode !== "ask") { throw new Error("native_execution_input_invalid: issue work mode must be standard, planning, or ask"); } + const cursorMode = resolvePaperclipRunnerCursorMode(input.provider, input.acpxAgent, input.acpxSessionMode); const executionMode = input.executionMode ?? (input.issue.workMode === "planning" ? "plan" : "default"); const acpxProfile = input.provider === "acpx" @@ -259,6 +262,7 @@ export function buildNativeExecutionInput(input: { agent: acpxProfile!.agent, model: input.model, permissionMode: input.acpxPermissionMode ?? "approve-all", + ...(cursorMode === undefined ? {} : { cursorMode }), profile: { driverKind: acpxProfile!.driverKind, protocolVersion: acpxProfile!.protocolVersion, diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 0bb30371312..aa5bba46996 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -12397,6 +12397,7 @@ async function createRunnerdBackendWithinSessionClaim( // Read only the server operator environment, never agent/runtime env. acpxCandidateProfile: resolveAcpxQualification(input.execution.provider, process.env), acpxPermissionMode: input.execution.provider.permissionMode, + acpxCursorMode: input.execution.provider.cursorMode, acpxPermissionModePinned: input.execution.schema === "paperclip.native-execution-input.v4" || input.execution.schema === "paperclip.native-execution-input.v5", diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts index 371c04ac593..d001bba8f6c 100644 --- a/server/src/services/native-runtime/provider-profile.ts +++ b/server/src/services/native-runtime/provider-profile.ts @@ -3,6 +3,7 @@ import { PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES, PAPERCLIP_RUNNER_ACPX_PROFILES, resolvePaperclipRunnerPermissionMode, + resolvePaperclipRunnerCursorMode, type PaperclipRunnerProvider, } from "@paperclipai/adapter-utils"; import { @@ -119,6 +120,7 @@ export type PaperclipRunnerNativeProviderInput = model: string; acpxAgent: AdmittedPaperclipRunnerAcpxAgent; acpxPermissionMode: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; + acpxSessionMode?: "agent" | "plan" | "ask"; }; export class PaperclipRunnerProviderProfileError extends Error { @@ -346,6 +348,14 @@ export function resolvePaperclipRunnerProviderProfile( } assertPermissionMode(candidate, config); + try { + resolvePaperclipRunnerCursorMode(candidate, config.acpxAgent, config.acpxSessionMode); + } catch (error) { + throw new PaperclipRunnerProviderProfileError( + "paperclip_runner_cursor_mode_invalid", + error instanceof Error ? error.message : "Invalid Cursor session mode", + ); + } const model = optionalString(config.model); if (candidate === "codex") { return { @@ -521,6 +531,9 @@ export function resolvePaperclipRunnerNativeProviderInput(input: { provider: "acpx", model: profile.model, acpxAgent: profile.acpxAgent, + ...(profile.acpxAgent === "cursor" ? { + acpxSessionMode: resolvePaperclipRunnerCursorMode("acpx", "cursor", config.acpxSessionMode), + } : {}), acpxPermissionMode: resolvePaperclipRunnerPermissionMode( "acpx", config.acpxPermissionMode, diff --git a/ui/src/adapters/codex-local/config-fields.test.tsx b/ui/src/adapters/codex-local/config-fields.test.tsx index 261b194aa32..53e923795d6 100644 --- a/ui/src/adapters/codex-local/config-fields.test.tsx +++ b/ui/src/adapters/codex-local/config-fields.test.tsx @@ -85,6 +85,17 @@ describe("Paperclip Runner Codex configuration", () => { expect(html).toContain("Allow Paperclip reads"); }); + it.each([undefined, "agent", "plan", "ask"])("displays saved Cursor mode %s", acpxSessionMode => { + const html = renderRunner({ provider: "acpx", acpxAgent: "cursor", acpxSessionMode }); + const selected = acpxSessionMode ?? "agent"; + expect(html).toContain('aria-label="Cursor mode"'); + expect(html).toContain(`