From 17c6da5e328217c72dbdbd9c0259b3bd36357f90 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 16:23:31 -0500 Subject: [PATCH] feat(runner): carry explicit Cursor mode through configuration and transport Preserve candidate models, reject foreign mode settings, and bind Cursor mode into session identity plumbing. Native mode admission and Rust validation follow in coordinated commits. Co-authored-by: Paperclip --- packages/adapter-utils/src/index.ts | 1 + .../src/paperclip-runner-permissions.test.ts | 15 ++++++++++ .../src/paperclip-runner-permissions.ts | 15 ++++++++++ .../codex-local/src/ui/build-config.test.ts | 30 +++++++++++-------- .../codex-local/src/ui/build-config.ts | 10 ++++++- packages/paperclip-runner/README.md | 9 ++++++ .../src/backends/codex-acpx-native-backend.ts | 3 +- .../src/cli/acpx-runtime-sidecar.ts | 9 ++++++ .../src/contracts/harness-driver.ts | 2 ++ .../src/contracts/native-execution.test.ts | 14 +++++++++ .../src/contracts/native-execution.ts | 10 ++++++- .../src/drivers/acpx/codex-acpx-driver.ts | 4 +++ .../src/drivers/acpx/sidecar-protocol.ts | 2 ++ .../src/live/runnerd-codex-transport.test.ts | 10 +++++++ .../src/live/runnerd-codex-transport.ts | 6 ++++ server/src/adapters/registry.ts | 13 ++++++++ .../heartbeat-runner-provider-config.test.ts | 24 ++++++++++++++- .../native-agent-runtime-inheritance.test.ts | 7 +++++ .../native-agent-runtime-inheritance.ts | 1 + .../native-execution-input.test.ts | 25 ++++++++++++++++ .../native-runtime/native-execution-input.ts | 4 +++ .../native-runtime/native-session-executor.ts | 1 + .../native-runtime/provider-profile.ts | 13 ++++++++ .../codex-local/config-fields.test.tsx | 11 +++++++ ui/src/adapters/codex-local/config-fields.tsx | 20 +++++++++++-- 25 files changed, 241 insertions(+), 18 deletions(-) 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(`