mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
Prove Cursor SDK interactions and retain profile v3 build evidence
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
abad2bd0da
commit
1f8316294e
3 files changed
+341
No files matched your search
@@ -211,3 +211,32 @@ Prior Product/Runner results above measured the earlier unpatched runtime. The
|
||||
profile must retain pending qualification until the rebuilt final foundation
|
||||
runtime passes the remaining native interaction, durable approval, cancellation,
|
||||
recovery, accounting, and authenticated target-platform gates.
|
||||
|
||||
### Frozen profile v3 build
|
||||
|
||||
[The retained build record](../../packages/paperclip-runner/test/fixtures/cursor-acp/production-v3-offline-build.json)
|
||||
identifies exact runtime source `526081b9d6c905de5e2da680e3a37c620899b380`,
|
||||
profile v3, the ARM64 portable package and matching release daemon, both lock
|
||||
hashes, the committed ACPX patch hash, and credential-free launch results.
|
||||
Dependencies were installed into a fresh private store with copied files to
|
||||
avoid borrowing mutable package bytes from another worktree. The initial frozen
|
||||
install failed because the committed lock's patch configuration differed; that
|
||||
failure is retained. A private derived staging lock then passed frozen install.
|
||||
The repository lock was not changed.
|
||||
|
||||
The package's real immutable lease passed ACP initialization, required
|
||||
authentication for model/session discovery and creation, rejected unsupported
|
||||
fork/resume methods, and closed with no stderr. The TypeScript and release daemon
|
||||
builds passed. Twenty-six installed-package contracts passed. Another 184 control
|
||||
tests passed, including two Cursor-specific tests using the real installed ACP
|
||||
SDK stream: sessionless native single/multi-selection and complete revision-bound
|
||||
plan replies preserve request ID zero, await response pipe delivery, reject stale
|
||||
plan revisions, and suppress late answers after cancellation. The surrounding
|
||||
suite covers mocked admission, permission identity, provider-loss expiry,
|
||||
recovery fences, and cleanup ownership. These are deterministic tests, not live
|
||||
native interaction, durable browser approval, or provider-death qualification.
|
||||
|
||||
The new `cursor-runtime-patch.mjs` is present in the Docker build context. It is
|
||||
also a required Daytona content-identity input: changing patch source must change
|
||||
the image identity even when another declared input is unchanged. Publishing or
|
||||
qualification must use an image whose content hash includes that script.
|
||||
@@ -0,0 +1,76 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, it } from "vitest";
|
||||
import { createCursorProfileExtensionAdapter } from "./cursor-extensions.js";
|
||||
const require = createRequire(import.meta.url);
|
||||
const acpxRoot = dirname(require.resolve("acpx/package.json"));
|
||||
const { k: AcpClient } = await import(pathToFileURL(join(acpxRoot, "dist/live-checkpoint-BSIrfgVo.js")).href);
|
||||
// The fixture uses native Cursor's sessionless extension shape and request ID 0.
|
||||
// The real installed ACP SDK handles the pipe; the production adapter normalizes
|
||||
// answers. This is credential-free protocol coverage, not native availability.
|
||||
const fixture = String.raw`
|
||||
const send=value=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',...value})+'\n');let prompt;
|
||||
require('node:readline').createInterface({input:process.stdin}).on('line',line=>{
|
||||
const m=JSON.parse(line);
|
||||
if(m.method==='initialize')send({id:m.id,result:{protocolVersion:1,agentCapabilities:{},authMethods:[]}});
|
||||
else if(m.method==='session/new')send({id:m.id,result:{sessionId:'cursor-session'}});
|
||||
else if(m.method==='session/prompt'){
|
||||
prompt=m.id;const plan=m.params.prompt[0].text==='plan';
|
||||
send({id:0,method:plan?'cursor/create_plan':'cursor/ask_question',params:plan
|
||||
?{toolCallId:'plan',name:'Complete revision',plan:'1. Read\n2. Verify',todos:[],phases:[]}
|
||||
:{toolCallId:'question',questions:[{id:'native-single',prompt:'Color',options:[{id:'c',label:'Cobalt'},{id:'a',label:'Amber'}]},{id:'native-multi',prompt:'Trees',allowMultiple:true,options:[{id:'cedar',label:'Cedar'},{id:'maple',label:'Maple'}]}]}});
|
||||
}else if(m.method==='session/cancel'){send({id:prompt,result:{stopReason:'cancelled'}});prompt=undefined;}
|
||||
else if(m.id===0&&!m.method&&prompt!==undefined){
|
||||
send({method:'session/update',params:{sessionId:'cursor-session',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:JSON.stringify(m)}}}});
|
||||
send({id:prompt,result:{stopReason:'end_turn'}});prompt=undefined;
|
||||
}
|
||||
});`;
|
||||
type Context = { requestId: string | number; signal: AbortSignal; responseDelivery?: Promise<void> };
|
||||
async function withClient(onRequest: (method: string, params: Record<string, unknown>, context: Context) => Promise<unknown>, run: (client: InstanceType<typeof AcpClient>, received: unknown[]) => Promise<void>) {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "cursor-sdk-")); const received: unknown[] = [];
|
||||
const client = new AcpClient({ agentCommand: "owned-fixture", cwd, permissionMode: "approve-all", fs: false, terminal: false, authPolicy: "skip", suppressSdkConsoleErrors: true,
|
||||
extensionMethods: ["cursor/ask_question", "cursor/create_plan"], onExtensionRequest: onRequest,
|
||||
spawnAgent: () => spawn(process.execPath, ["-e", fixture], { cwd, env: { PATH: "/usr/bin:/bin" }, stdio: ["pipe", "pipe", "pipe"] }),
|
||||
onSessionUpdate: (params: { update: { content?: { type: string; text: string } } }) => { if (params.update.content?.type === "text") received.push(JSON.parse(params.update.content.text)); },
|
||||
});
|
||||
try { await client.start(); await client.createSession(); await run(client, received); }
|
||||
finally { await client.close(); await rm(cwd, { recursive: true, force: true }); }
|
||||
}
|
||||
it("roundtrips Cursor multi-select and revision-bound plan decisions through the installed ACP SDK", async () => {
|
||||
const deliveries: Promise<void>[] = []; let turn = 0;
|
||||
await withClient(async (method, params, context) => {
|
||||
expect(context.requestId).toBe(0); expect(params).not.toHaveProperty("sessionId");
|
||||
expect(context.responseDelivery).toBeInstanceOf(Promise); deliveries.push(context.responseDelivery!);
|
||||
const adapter = createCursorProfileExtensionAdapter({ workspacePath: tmpdir(), sessionId: "cursor-session", turnId: `turn-${++turn}` });
|
||||
const request = await adapter.request(method, { ...params, sessionId: "cursor-session" });
|
||||
if (!("input" in request)) throw new Error("Expected blocking Cursor interaction");
|
||||
const questionId = request.input.questionSet.questions[0]!.id;
|
||||
const answers = method === "cursor/ask_question"
|
||||
? { "question-1": { selectedOptionIds: ["option-1"] }, "question-2": { selectedOptionIds: ["option-1", "option-2"] } }
|
||||
: { [questionId]: { selectedOptionIds: ["accept"] } };
|
||||
if (method === "cursor/create_plan") {
|
||||
expect(request.input.questionSet.description).toBe("1. Read\n2. Verify");
|
||||
expect(() => request.input.resolve({ action: "submit", response: { schema: "paperclip.question_response.v1", answers: { "plan-stale": { selectedOptionIds: ["accept"] } } } })).toThrow();
|
||||
}
|
||||
return request.input.resolve({ action: "submit", response: { schema: "paperclip.question_response.v1", answers } });
|
||||
}, async (client, received) => {
|
||||
expect((await client.prompt("cursor-session", "question")).stopReason).toBe("end_turn");
|
||||
expect((await client.prompt("cursor-session", "plan")).stopReason).toBe("end_turn"); await Promise.all(deliveries);
|
||||
expect(received).toEqual([
|
||||
{ jsonrpc: "2.0", id: 0, result: { outcome: { outcome: "answered", answers: [{ questionId: "native-single", selectedOptionIds: ["c"] }, { questionId: "native-multi", selectedOptionIds: ["cedar", "maple"] }] } } },
|
||||
{ jsonrpc: "2.0", id: 0, result: { outcome: { outcome: "accepted" } } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
it("cancels an unanswered Cursor request without delivering a late answer", async () => {
|
||||
const started = Promise.withResolvers<void>(); const response = Promise.withResolvers<unknown>(); let signal: AbortSignal | undefined;
|
||||
await withClient(async (_method, _params, context) => { signal = context.signal; started.resolve(); return response.promise; }, async (client, received) => {
|
||||
const prompt = client.prompt("cursor-session", "question"); await started.promise; await client.cancel("cursor-session"); await prompt;
|
||||
expect(signal?.aborted).toBe(true); response.resolve({ outcome: { outcome: "answered", answers: [] } });
|
||||
await new Promise(resolve => setImmediate(resolve)); expect(received).toEqual([]);
|
||||
});
|
||||
});
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
{
|
||||
"schema": "paperclip.cursor.production-offline-build.v1",
|
||||
"observedAt": "2026-09-29",
|
||||
"runtimeSourceRevision": "526081b9d6c905de5e2da680e3a37c620899b380",
|
||||
"providerVersion": "2026.09.26-dd393fe",
|
||||
"profileVersion": 3,
|
||||
"patchVersion": "paperclip-cursor-isolation-v1",
|
||||
"providerPackDigest": "sha256:7166a42d06192c53191aad7b15396e291675624001fbd95dd4b3cbda197f87a0",
|
||||
"candidate": {
|
||||
"version": "2026.09.26-dd393fe",
|
||||
"profileDigest": "sha256:c91aa592ec867071ec4457b9b7230399ce42ad918787ceed99b4b651ea5607e2",
|
||||
"closureDigest": "sha256:8a22016ca832ef8c8e6a83b955208d74c63f2c8dbe1dd688cbf27b81ad01886a",
|
||||
"qualification": "pending",
|
||||
"path": "provider-assets/cursor/darwin-arm64",
|
||||
"sha256": "sha256:e4c656debc32e8a6bf6e6fd7f620223b48ad0b8b18994096d09f056fdf9d809c"
|
||||
},
|
||||
"daemon": {
|
||||
"sourceRevision": "526081b9d6c905de5e2da680e3a37c620899b380",
|
||||
"platform": "darwin-arm64",
|
||||
"sha256": "826611379af7b8148933c58c6c77826c5a4f0773b789aac9eafbbc66b8e3dcb5"
|
||||
},
|
||||
"dependencyProvenance": {
|
||||
"source": "git archive of exact runtimeSourceRevision",
|
||||
"isolation": "fresh private pnpm store; copied package files, no shared hardlinks",
|
||||
"pnpmVersion": "9.15.4",
|
||||
"committedLockSha256": "e0c928a494f90ddad3c00791e83f09315ee8c82df2a0418a809dcf93649a8ab3",
|
||||
"stagingLockSha256": "9eea60187c6c808efb4d936a234a8d8000beac45253091abd4c5348132bd1408",
|
||||
"acpxPatchSha256": "8b698d853fe6df6a34a8f89dddaccc6c8f1e5b7763af5ab5866b77acd42bc127",
|
||||
"firstFrozenInstall": "failed: ERR_PNPM_LOCKFILE_CONFIG_MISMATCH",
|
||||
"resolution": "retained original lock, generated private staging lock only, then frozen installation passed; repository lock unchanged"
|
||||
},
|
||||
"checks": {
|
||||
"runnerTypescriptBuild": "passed",
|
||||
"runnerReleaseDaemonBuild": "passed",
|
||||
"freshPackageContracts": {
|
||||
"passed": 26
|
||||
},
|
||||
"runtimeControlsAndCursorSdkInteractions": {
|
||||
"passed": 184,
|
||||
"scope": "credential-free real ACP SDK fixtures plus mocked admission, recovery, request delivery, lifecycle and cancellation; excludes native provider availability"
|
||||
},
|
||||
"verifiedNativeLease": {
|
||||
"schema": "paperclip-cursor-offline-pack-proof/v1",
|
||||
"capturedAt": "2026-09-29T14:50:08.614Z",
|
||||
"runnerSourceRevision": "526081b9d6c905de5e2da680e3a37c620899b380",
|
||||
"packDigest": "sha256:7166a42d06192c53191aad7b15396e291675624001fbd95dd4b3cbda197f87a0",
|
||||
"target": {
|
||||
"platform": "darwin",
|
||||
"architecture": "arm64"
|
||||
},
|
||||
"candidate": {
|
||||
"version": "2026.09.26-dd393fe",
|
||||
"profileDigest": "sha256:c91aa592ec867071ec4457b9b7230399ce42ad918787ceed99b4b651ea5607e2",
|
||||
"closureDigest": "sha256:8a22016ca832ef8c8e6a83b955208d74c63f2c8dbe1dd688cbf27b81ad01886a",
|
||||
"qualification": "pending",
|
||||
"path": "provider-assets/cursor/darwin-arm64",
|
||||
"sha256": "sha256:e4c656debc32e8a6bf6e6fd7f620223b48ad0b8b18994096d09f056fdf9d809c"
|
||||
},
|
||||
"productionLock": "sha256:9eea60187c6c808efb4d936a234a8d8000beac45253091abd4c5348132bd1408",
|
||||
"launch": {
|
||||
"entrypoint": "verified bundled node + index.js",
|
||||
"arguments": [
|
||||
"--disable-project-configs",
|
||||
"--disable-auto-update",
|
||||
"acp"
|
||||
],
|
||||
"credentials": "none; private home/config/data/cache; memory credential store",
|
||||
"verification": "generic profile installation registry, whole closure digest verification, retained immutable native snapshot, closed module bootstrap",
|
||||
"modelAdmission": "offline-installation-probe is a factory identity sentinel only; no ACP model selection or prompt; model remains unverified"
|
||||
},
|
||||
"requests": [
|
||||
{
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": 1,
|
||||
"clientCapabilities": {
|
||||
"fs": {
|
||||
"readTextFile": false,
|
||||
"writeTextFile": false
|
||||
},
|
||||
"terminal": false,
|
||||
"_meta": {
|
||||
"subagents": true
|
||||
}
|
||||
},
|
||||
"clientInfo": {
|
||||
"name": "paperclip-offline-pack-probe",
|
||||
"version": "1"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "cursor/list_available_models",
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"method": "session/list",
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"method": "session/fork",
|
||||
"params": {
|
||||
"sessionId": "probe"
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "session/resume",
|
||||
"params": {
|
||||
"sessionId": "probe",
|
||||
"cwd": "/qualification/private/workspace",
|
||||
"mcpServers": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"cwd": "/qualification/private/workspace",
|
||||
"mcpServers": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": [
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"protocolVersion": 1,
|
||||
"agentCapabilities": {
|
||||
"loadSession": true,
|
||||
"mcpCapabilities": {
|
||||
"http": true,
|
||||
"sse": true
|
||||
},
|
||||
"promptCapabilities": {
|
||||
"audio": false,
|
||||
"embeddedContext": false,
|
||||
"image": true
|
||||
},
|
||||
"sessionCapabilities": {
|
||||
"list": {},
|
||||
"subagents": {}
|
||||
}
|
||||
},
|
||||
"authMethods": [
|
||||
{
|
||||
"id": "cursor_login",
|
||||
"name": "Cursor Login",
|
||||
"description": "Authenticate using existing Cursor login credentials. Run 'agent login' first if not logged in."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"error": {
|
||||
"code": -32000,
|
||||
"message": "Authentication required",
|
||||
"data": {
|
||||
"message": "Authentication required. Please run 'agent login' first, then call authenticate() with methodId 'cursor_login'."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"error": {
|
||||
"code": -32000,
|
||||
"message": "Authentication required",
|
||||
"data": {
|
||||
"message": "Authentication required. Please run 'agent login' first, then call authenticate() with methodId 'cursor_login'."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": "\"Method not found\": session/fork",
|
||||
"data": {
|
||||
"method": "session/fork"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": "\"Method not found\": session/resume",
|
||||
"data": {
|
||||
"method": "session/resume"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 6,
|
||||
"error": {
|
||||
"code": -32000,
|
||||
"message": "Authentication required",
|
||||
"data": {
|
||||
"message": "Authentication required. Please run 'agent login' first, then call authenticate() with methodId 'cursor_login'."
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"process": {
|
||||
"exitCode": 143,
|
||||
"signalCode": null,
|
||||
"leaseClosed": true,
|
||||
"stderrBytes": 0,
|
||||
"unexpectedMessageCount": 0
|
||||
},
|
||||
"qualification": {
|
||||
"status": "pending",
|
||||
"model": null,
|
||||
"billablePrompts": 0,
|
||||
"costUsd": 0,
|
||||
"environment": "local macOS ARM64; no Daytona execution"
|
||||
},
|
||||
"redactions": [
|
||||
"ephemeral workspace and private directories replaced with /qualification/private"
|
||||
]
|
||||
}
|
||||
},
|
||||
"qualification": {
|
||||
"status": "pending",
|
||||
"paidPrompts": 0,
|
||||
"authenticatedNativeInteractions": "not attempted",
|
||||
"durableRestrictiveApprovals": "not qualified",
|
||||
"authenticatedMacosX64": "not qualified",
|
||||
"authenticatedDaytona": "not qualified"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user