mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
c73e26107f
commit
17c6da5e32
25 files changed
+241
-18
No files matched your search
@@ -124,6 +124,7 @@ export {
|
||||
paperclipRunnerTransitionConfig,
|
||||
normalizeLegacyRunnerProvider,
|
||||
resolvePaperclipRunnerPermissionMode,
|
||||
resolvePaperclipRunnerCursorMode,
|
||||
} from "./paperclip-runner-permissions.js";
|
||||
export {
|
||||
PAPERCLIP_RUNNER_INGRESS_PORT,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string
|
||||
const provider = isPaperclipRunnerProvider(providerCandidate)
|
||||
? providerCandidate
|
||||
: "codex";
|
||||
const acpxAgent = schemaValues.acpxAgent === "grok" ? "grok" : "claude";
|
||||
const acpxAgent = PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => 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<string
|
||||
const configuredModel = typeof config.model === "string"
|
||||
? config.model.trim()
|
||||
: "";
|
||||
if (provider === "acpx" && ["cursor", "copilot", "pi"].includes(acpxAgent) && !configuredModel && !schemaModel) {
|
||||
throw new Error(`${acpxAgent} requires an explicit provider model`);
|
||||
}
|
||||
const managedProfileId = typeof schemaValues.managedProfileId === "string"
|
||||
? schemaValues.managedProfileId.trim()
|
||||
: "";
|
||||
@@ -177,6 +183,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
|
||||
"codexPermissionMode",
|
||||
"opencodePermissionMode",
|
||||
"acpxPermissionMode",
|
||||
"acpxSessionMode",
|
||||
"managedProfileId",
|
||||
"managedAgentsRetentionAcknowledged",
|
||||
"maxSessionListCostUsd",
|
||||
@@ -235,6 +242,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
|
||||
...(provider === "acpx"
|
||||
? {
|
||||
acpxAgent,
|
||||
...(cursorMode === undefined ? {} : { acpxSessionMode: cursorMode }),
|
||||
model: configuredModel || schemaModel || (acpxAgent === "grok" ? "grok-4.7" : resolvePaperclipRunnerModel("acpx", undefined)),
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -60,6 +60,15 @@ and Daytona qualification passes. Their verified distributions are build-owned;
|
||||
no candidate accepts an arbitrary executable. See
|
||||
[the rich ACP capability report](../../doc/architecture/runner-rich-acp-capabilities.md).
|
||||
|
||||
Cursor candidate configuration accepts `acpxSessionMode: "agent" | "plan" | "ask"`
|
||||
(default `agent`). This selects the native Cursor mode independently of
|
||||
`acpxPermissionMode` and Paperclip task planning or company approvals. The mode
|
||||
is validated at the API boundary and is bound to provider admission and recovery;
|
||||
changing it cannot reuse an incompatible warm session. Other providers reject
|
||||
this setting. Candidate configuration requires an explicit model and still
|
||||
requires exact operator-controlled qualification admission.
|
||||
|
||||
|
||||
Remote Codex sessions relay assigned app tools through the server's configured
|
||||
gateway. Small catalogs are sent directly. When a catalog would exceed the
|
||||
runner's 256-operation or 768 KiB contract limit, the server exposes
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
|
||||
export interface CodexAcpxNativeSessionBackendOptions extends Omit<
|
||||
CodexAcpxDriverOptions,
|
||||
"model" | "permissionMode" | "systemInstructions" | "providerPolicy" | "runtimeContext"
|
||||
"model" | "permissionMode" | "cursorMode" | "systemInstructions" | "providerPolicy" | "runtimeContext"
|
||||
> {}
|
||||
|
||||
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" },
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof createCapabilityRunnerdCodexTransport>[0]))
|
||||
.toThrow("acpxCursorMode");
|
||||
});
|
||||
@@ -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<string, { durableTurnId: string; permission?: boolean }>();
|
||||
|
||||
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,
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -12,6 +12,7 @@ export const INHERITABLE_NATIVE_RUNNER_CONFIG_KEYS = [
|
||||
"codexPermissionMode",
|
||||
"opencodePermissionMode",
|
||||
"acpxPermissionMode",
|
||||
"acpxSessionMode",
|
||||
"lifecycleMode",
|
||||
"modelReasoningEffort",
|
||||
"maxIterations",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(`<option value="${selected}" selected="">`);
|
||||
});
|
||||
|
||||
it.each(["claude", "copilot", "pi"])("does not expose Cursor mode for %s", acpxAgent => {
|
||||
expect(renderRunner({ provider: "acpx", acpxAgent })).not.toContain('aria-label="Cursor mode"');
|
||||
});
|
||||
|
||||
it("falls back to the fail-closed Codex permission mode", () => {
|
||||
const html = renderRunner({ codexPermissionMode: "unrestricted" });
|
||||
|
||||
|
||||
@@ -218,11 +218,13 @@ export function CodexLocalConfigFields({
|
||||
adapterSchemaValues: {
|
||||
...values!.adapterSchemaValues,
|
||||
provider,
|
||||
acpxSessionMode: undefined,
|
||||
...(provider === "acpx" ? { acpxAgent: grok ? "grok" : "claude" } : {}),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
mark("adapterConfig", "provider", provider);
|
||||
mark("adapterConfig", "acpxSessionMode", undefined);
|
||||
mark("adapterConfig", "model", model);
|
||||
if (provider === "acpx") {
|
||||
mark("adapterConfig", "acpxAgent", grok ? "grok" : "claude");
|
||||
@@ -247,8 +249,8 @@ export function CodexLocalConfigFields({
|
||||
const profile = PAPERCLIP_RUNNER_ACPX_PROFILES.find(entry => entry.value === event.target.value);
|
||||
if (!profile?.qualified) return;
|
||||
if (isCreate) set!({ model: profile.value === "claude" ? defaultAcpxClaudeModel : "",
|
||||
adapterSchemaValues: { ...values!.adapterSchemaValues, acpxAgent: profile.value } });
|
||||
else { mark("adapterConfig", "acpxAgent", profile.value); mark("adapterConfig", "model", profile.value === "claude" ? defaultAcpxClaudeModel : ""); }
|
||||
adapterSchemaValues: { ...values!.adapterSchemaValues, acpxAgent: profile.value, acpxSessionMode: profile.value === "cursor" ? "agent" : undefined } });
|
||||
else { mark("adapterConfig", "acpxAgent", profile.value); mark("adapterConfig", "acpxSessionMode", profile.value === "cursor" ? "agent" : undefined); mark("adapterConfig", "model", profile.value === "claude" ? defaultAcpxClaudeModel : ""); }
|
||||
}}>
|
||||
{PAPERCLIP_RUNNER_ACPX_PROFILES.map(profile => <option key={profile.value} value={profile.value} disabled={!profile.qualified}>
|
||||
{profile.label}{profile.qualified ? "" : " — qualification pending"}
|
||||
@@ -256,6 +258,20 @@ export function CodexLocalConfigFields({
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{runnerManaged && runnerProvider === "acpx" && runnerSchemaValue("acpxAgent", "claude") === "cursor" && (
|
||||
<Field configSection="adapter" label="Cursor mode" hint="Select Cursor's session mode. Permissions and company approval rules still apply.">
|
||||
<select className={inputClass} aria-label="Cursor mode"
|
||||
value={String(runnerSchemaValue("acpxSessionMode", "agent"))}
|
||||
onChange={(event) => updateRunnerSchemaValue("acpxSessionMode", event.target.value)}>
|
||||
{!["agent", "plan", "ask"].includes(String(runnerSchemaValue("acpxSessionMode", "agent"))) && (
|
||||
<option value={String(runnerSchemaValue("acpxSessionMode", "agent"))} disabled>Unsupported saved mode — select Agent, Plan, or Ask</option>
|
||||
)}
|
||||
<option value="agent">Agent</option>
|
||||
<option value="plan">Plan</option>
|
||||
<option value="ask">Ask</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{runnerManaged && runnerProvider === "claude_managed" && (
|
||||
<>
|
||||
<Field
|
||||
|
||||
Reference in new issue
Block a user