diff --git a/doc/architecture/runner-cursor-capabilities.md b/doc/architecture/runner-cursor-capabilities.md index 9ed1201020c..e38aaf99033 100644 --- a/doc/architecture/runner-cursor-capabilities.md +++ b/doc/architecture/runner-cursor-capabilities.md @@ -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. diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-sdk-interactions.test.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-sdk-interactions.test.ts new file mode 100644 index 00000000000..1566e01cd7d --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-sdk-interactions.test.ts @@ -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 }; +async function withClient(onRequest: (method: string, params: Record, context: Context) => Promise, run: (client: InstanceType, received: unknown[]) => Promise) { + 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[] = []; 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(); const response = Promise.withResolvers(); 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([]); + }); +}); diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/production-v3-offline-build.json b/packages/paperclip-runner/test/fixtures/cursor-acp/production-v3-offline-build.json new file mode 100644 index 00000000000..21ea6f4e5d5 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/production-v3-offline-build.json @@ -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" + } +}