mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
fix(cursor): preserve mainline contracts during selective integration
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f71957ef76
commit
ae24f09812
11 files changed
+45
-123
No files matched your search
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -97,7 +97,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
|
||||
const provider = isPaperclipRunnerProvider(providerCandidate)
|
||||
? providerCandidate
|
||||
: "codex";
|
||||
const acpxAgent = PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => 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<string
|
||||
const configuredModel = typeof config.model === "string"
|
||||
? config.model.trim()
|
||||
: "";
|
||||
if (provider === "acpx" && ["cursor", "copilot", "pi"].includes(acpxAgent) && !configuredModel && !schemaModel) {
|
||||
if (provider === "acpx" && acpxAgent === "cursor" && !configuredModel && !schemaModel) {
|
||||
throw new Error(`${acpxAgent} requires an explicit provider model`);
|
||||
}
|
||||
const managedProfileId = typeof schemaValues.managedProfileId === "string"
|
||||
|
||||
@@ -406,7 +406,7 @@ fn sanitize_question_set(mut value: Value) -> Result<Value, LocalRunnerError> {
|
||||
};
|
||||
// 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")
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<serde_json::Value> = std::fs::read_to_string(&journal)
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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/);
|
||||
|
||||
Reference in new issue
Block a user