From ae24f0981221dc0b5694120f9bb46ff6c8859e07 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 3 Oct 2026 14:03:14 -0500 Subject: [PATCH] fix(cursor): preserve mainline contracts during selective integration Co-Authored-By: Paperclip --- .../2026-10-03-cursor-production-readiness.md | 14 ++++ .../codex-local/src/ui/build-config.test.ts | 16 ++++ .../codex-local/src/ui/build-config.ts | 4 +- .../runner-core/src/acpx_event_payload.rs | 11 +-- .../crates/runner-core/src/durable/state.rs | 24 ------ .../runner-core/tests/acpx_event_payload.rs | 74 ------------------- .../tests/acpx_provider_session.rs | 10 ++- .../tests/native_provider_backend.rs | 2 +- .../src/drivers/acpx/acp-question-adapter.ts | 9 --- tests/runner-e2e/cursor-native-evidence.ts | 2 +- tests/runner-e2e/cursor-native-flow.test.ts | 2 +- 11 files changed, 45 insertions(+), 123 deletions(-) diff --git a/doc/plans/2026-10-03-cursor-production-readiness.md b/doc/plans/2026-10-03-cursor-production-readiness.md index e8c2f5a47d4..0c819578147 100644 --- a/doc/plans/2026-10-03-cursor-production-readiness.md +++ b/doc/plans/2026-10-03-cursor-production-readiness.md @@ -170,3 +170,17 @@ Historical unbound committed proofs retain their original event selection and 1,000-row limit. Long-plan tests also reject foreign tools, changed sessions, tampered digests, and progress after completion; no later work is hidden by removing progress from the proof. + +The first new denied-write attempt retained a native permission for a different +command: Cursor escaped the literal's underscores. The original target stayed +absent and the observed process tree retired, but the exact command/denial gate +correctly failed. The fixture now uses a hyphenated literal while retaining exact +command digest, tool, request, run, and turn checks. This changes the fixture +identity and does not qualify the failed attempt retroactively. + +CI found an imported editable-default question test without its corresponding +contract. Editable defaults are outside this Cursor release and their partial +import was removed instead of extending all question surfaces. Cursor question +handling and plans remain intact. Configuration now preserves mainline behavior +for unavailable Pi/Copilot profiles while retaining Cursor's explicit model and +mode selection. 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..1dc2466f0a2 100644 --- a/packages/adapters/codex-local/src/ui/build-config.test.ts +++ b/packages/adapters/codex-local/src/ui/build-config.test.ts @@ -241,6 +241,22 @@ describe("buildPaperclipRunnerConfig", () => { }); }); + it.each(["agent", "plan", "ask"])("preserves Cursor's explicit model and %s mode", (mode) => { + expect(buildPaperclipRunnerConfig(makeValues({ + model: "explicit-cursor-model", + adapterSchemaValues: { provider: "acpx", acpxAgent: "cursor", acpxSessionMode: mode }, + }))).toMatchObject({ provider: "acpx", acpxAgent: "cursor", model: "explicit-cursor-model", acpxSessionMode: mode }); + }); + + it("defaults Cursor to Agent and requires an explicit model", () => { + expect(buildPaperclipRunnerConfig(makeValues({ + model: "explicit-cursor-model", adapterSchemaValues: { provider: "acpx", acpxAgent: "cursor" }, + }))).toMatchObject({ acpxAgent: "cursor", acpxSessionMode: "agent", model: "explicit-cursor-model" }); + expect(() => buildPaperclipRunnerConfig(makeValues({ + model: "", adapterSchemaValues: { provider: "acpx", acpxAgent: "cursor" }, + }))).toThrow("cursor requires an explicit provider model"); + }); + it("builds a Claude Managed profile reference with explicit retention and spend controls", () => { const config = buildPaperclipRunnerConfig(makeValues({ adapterType: "paperclip_runner", diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index 1a48826c2cd..078ec5327ac 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -97,7 +97,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record profile.value === schemaValues.acpxAgent)?.value ?? "claude"; + const acpxAgent = PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === schemaValues.acpxAgent && (profile.qualified || profile.value === "cursor"))?.value ?? "claude"; const cursorMode = resolvePaperclipRunnerCursorMode(provider, acpxAgent, schemaValues.acpxSessionMode); const schemaModel = typeof schemaValues.model === "string" @@ -106,7 +106,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record Result { }; // IDs, answer modes, and validation patterns are protocol values: // changing them would break response correlation or semantics. - redact_object_text(question, &["header", "prompt", "helpText", "initialText"]); + redact_object_text(question, &["header", "prompt", "helpText"]); if let Some(options) = question.get_mut("options").and_then(Value::as_array_mut) { for option in options { if let Some(option) = option.as_object_mut() { @@ -643,15 +643,6 @@ pub(crate) fn validate_question_set(value: &Value) -> Result<(), LocalRunnerErro "ACPX input question ids must be unique", )); } - if question - .get("initialText") - .and_then(Value::as_str) - .is_some_and(|text| text.chars().count() > 100_000) - { - return Err(LocalRunnerError::invalid( - "ACPX initial text exceeds its Unicode code-point bound", - )); - } let mut option_ids = BTreeSet::new(); for option in question .get("options") diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs index ee81eed9b40..894a00176c3 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs @@ -3128,30 +3128,6 @@ mod tests { ); } - #[test] - fn durable_question_initial_text_survives_serialized_reload_without_resolution() { - let mut config = config(PathBuf::from("unused")); - config.max_outbox_bytes = 512 * 1024; - config.max_frame_bytes = 256 * 1024; - let mut state = DurableState::new(&config); - let text = " Editable draft 漢字\n".repeat(1_000); - state.enqueue_event(&config, "runtime_request.created", EventPriority::P0, json!({ - "request": {"schema":"paperclip.runtime_request.v2", "requestKind":"runtime", "requestId":"edit-1", - "type":"input", "status":"pending", "input":{"schema":"paperclip.question_set.v1", "questions":[{ - "id":"draft", "prompt":"Edit", "answerMode":"text", "required":true, "initialText":text - }]}} - })).unwrap(); - let reloaded: DurableState = - serde_json::from_slice(&serde_json::to_vec(&state).unwrap()).unwrap(); - let request = reloaded.outbox[0] - .envelope - .pointer("/payload/payload/request") - .unwrap(); - assert_eq!(request["input"]["questions"][0]["initialText"], json!(text)); - assert_eq!(request["status"], "pending"); - assert!(request.get("response").is_none()); - } - #[test] fn durable_question_sets_preserve_safe_identity_and_redact_display_text() { let config = config(PathBuf::from("unused")); diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_event_payload.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_event_payload.rs index 6e0d554c16c..fc84238d5fe 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_event_payload.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_event_payload.rs @@ -402,80 +402,6 @@ fn rejects_payloads_before_decoding_when_scope_or_size_is_invalid() { assert!(error.to_string().contains("256 KiB")); } -#[test] -fn input_initial_text_survives_wire_admission_with_existing_redaction() { - let scope = active_scope(); - let input = |initial: Value, mode: &str| { - json!({"requestId":"request-1","questionSet":{ - "schema":"paperclip.question_set.v1","questions":[{"id":"draft","prompt":"Edit", "required":true, - "answerMode":mode,"initialText":initial}] - }}) - }; - let text = " Draft 漢字\n".repeat(1_000); - let decoded = decode_acpx_event( - &scope, - &event( - GeneratedAcpxSidecarEventType::RuntimeInputRequested, - input(json!(text), "text"), - ), - ) - .unwrap(); - match decoded { - AcpxEventPayload::InputRequested { question_set, .. } => { - assert_eq!(question_set["questions"][0]["initialText"], json!(text)) - } - _ => panic!("input request expected"), - } - let decoded = decode_acpx_event( - &scope, - &event( - GeneratedAcpxSidecarEventType::RuntimeInputRequested, - input(json!("token=private-prefill"), "text"), - ), - ) - .unwrap(); - match decoded { - AcpxEventPayload::InputRequested { question_set, .. } => { - assert_eq!( - question_set["questions"][0]["initialText"], - "token=[REDACTED]" - ); - assert!(question_set["description"] - .as_str() - .unwrap() - .contains("redacted")); - } - _ => panic!("input request expected"), - } - for code_points in [100_000, 100_001] { - let initial = format!("{}😀", "a".repeat(code_points - 1)); - let payload = input(json!(initial), "text"); - assert!(serde_json::to_vec(&payload).unwrap().len() < 196 * 1024); - let result = decode_acpx_event( - &scope, - &event( - GeneratedAcpxSidecarEventType::RuntimeInputRequested, - payload, - ), - ); - assert_eq!(result.is_ok(), code_points == 100_000); - } - for invalid in [ - input(json!(1), "text"), - input(json!("a".repeat(100_001)), "text"), - input(json!("default"), "single_select"), - ] { - assert!(decode_acpx_event( - &scope, - &event( - GeneratedAcpxSidecarEventType::RuntimeInputRequested, - invalid - ) - ) - .is_err()); - } -} - #[test] fn native_plan_parent_tool_is_bounded_origin_scoped_and_not_redacted() { let input = json!({"requestId":"input-1","toolCallId":"tool with spaces", diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs index e42e7a29083..f4c593ffe44 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs @@ -273,7 +273,15 @@ fn check_tool_receiver_admission(oversized: bool) { assert!(session.state().pending_tool("call-admission").is_some()); session.deliver_tool_result(&result).unwrap(); assert!(!session.state().has_pending_tools()); - assert!(session.deliver_tool_result(&result).is_err()); + // An exact durable delivery replay is acknowledged without a second + // sidecar resolution. The command journal below proves that boundary. + session.deliver_tool_result(&result).unwrap(); + let mut changed = result.clone(); + changed.result = json!({"id":"different-issue"}); + assert!(session.deliver_tool_result(&changed).is_err()); + changed = result.clone(); + changed.operation_id = "issues.update".to_owned(); + assert!(session.deliver_tool_result(&changed).is_err()); } session.shutdown("admission test complete").unwrap(); let rows: Vec = std::fs::read_to_string(&journal) diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index 5c0d915a386..9699a80278c 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -17,7 +17,7 @@ use sha2::{Digest, Sha256}; const CODEX_ACPX_DIGEST: &str = "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3"; const PI_ACPX_DIGEST: &str = - "sha256:5e1a4357fc108fa79a66111413f85f6353b3dd3ab802ddfb80fc66719bc12571"; + "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f"; fn temporary_directory(label: &str) -> PathBuf { let nonce = SystemTime::now() diff --git a/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.ts index e36ef686369..7250cc1a434 100644 --- a/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.ts @@ -148,7 +148,6 @@ function normalizeField( question: { ...base, answerMode: "text", - ...(property.default !== undefined ? { initialText: initialText(property.default, type) } : {}), textValidation: { inputType: "text", ...(minLength !== undefined ? { minLength } : {}), @@ -169,7 +168,6 @@ function normalizeField( question: { ...base, answerMode: "text", - ...(property.default !== undefined ? { initialText: initialText(property.default, type) } : {}), textValidation: { inputType: type, ...(minimum !== undefined ? { minimum } : {}), @@ -378,10 +376,3 @@ function finiteNonNegativeInteger(value: unknown): number | undefined { ? Number(value) : undefined; } - -/** Defaults are editable presentation, not acceptance or native response content. */ -function initialText(value: unknown, type: string): string { - if (type === "string" && typeof value === "string") return value; - if ((type === "number" || type === "integer") && typeof value === "number" && Number.isFinite(value)) return String(value); - throw new Error(`ACP ${type} text field default has an incompatible type`); -} diff --git a/tests/runner-e2e/cursor-native-evidence.ts b/tests/runner-e2e/cursor-native-evidence.ts index 08c8937ab17..c6d130022eb 100644 --- a/tests/runner-e2e/cursor-native-evidence.ts +++ b/tests/runner-e2e/cursor-native-evidence.ts @@ -40,7 +40,7 @@ export function readCursorToolEvidence(rows: readonly unknown[], runId: string): /** Exact absolute target removes any dependency on implicit native shell cwd. */ export function cursorDeniedCommand(path: string) { if (!isAbsolute(path) || /[\u0000-\u001f\u007f]/u.test(path)) throw new Error("Invalid denial target"); - const command = `printf 'MUST_NOT_EXIST' > '${path.replaceAll("'", "'\\''")}'`; + const command = `printf 'MUST-NOT-EXIST' > '${path.replaceAll("'", "'\\''")}'`; return { command, commandSha256: `sha256:${createHash("sha256").update(command).digest("hex")}` }; } export function hasCursorDeniedCommand(input: { diff --git a/tests/runner-e2e/cursor-native-flow.test.ts b/tests/runner-e2e/cursor-native-flow.test.ts index 59da838ae0a..e7f88d58ce1 100644 --- a/tests/runner-e2e/cursor-native-flow.test.ts +++ b/tests/runner-e2e/cursor-native-flow.test.ts @@ -43,7 +43,7 @@ it("awaits owned remote baseline before returning the exact remote denial comman const prepared = await prepareCursorRemoteAction({ fixture, ...binding, deniedRelative: "denied.txt", prompt: "Perform the actual test" }); order.push("publish-action"); expect(order).toEqual(["before-action-publication", "baseline-ready", "publish-action"]); - expect(prepared.prompt).toContain("printf 'MUST_NOT_EXIST' > '/home/daytona/paperclip-workspace/denied.txt'"); + expect(prepared.prompt).toContain("printf 'MUST-NOT-EXIST' > '/home/daytona/paperclip-workspace/denied.txt'"); expect(prepared.initial).toMatchObject({ phase: "before-request", absent: true }); expect(prepared.command?.commandSha256).toMatch(/^sha256:[a-f0-9]{64}$/); await expect(prepareCursorRemoteAction({ fixture, ...binding, runId: "wrong-run", deniedRelative: "denied.txt", prompt: "test" })).rejects.toThrow(/another run/);