diff --git a/doc/plans/2026-10-03-cursor-production-readiness.md b/doc/plans/2026-10-03-cursor-production-readiness.md index ddc57271747..e8c2f5a47d4 100644 --- a/doc/plans/2026-10-03-cursor-production-readiness.md +++ b/doc/plans/2026-10-03-cursor-production-readiness.md @@ -150,3 +150,23 @@ and timing failures under host contention. It was interrupted before completion; the partial log is retained. Earlier recursive typecheck, build, contracts/replay, token gates, and focused runtime checks passed. Full CI and the complete acceptance matrix are required before promotion. Cursor production admission remains disabled. + +## Review blockers and mainline integration + +The branch now preserves mainline `2a8a99e4a`, including stock-harness cleanup +checks and the updated skill semantic contract. Earlier candidate results retain +their original source identities; this rebase requires fresh artifact identities +and affected qualification. + +Two production blockers found by review were repaired. Unsupported Cursor targets +omit the provider field rather than hashing an undefined field that disappears +when the manifest is written. Disk JSON round-trip digest checks cover all three +supported targets, Linux ARM64, and Windows x64. + +Accepted-plan proof reads now budget 1,000 control events and 20,000 tool-progress +events separately, retaining every event hash and the terminal event. A single +bounded read includes an overflow row and fails closed beyond either budget. +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. diff --git a/packages/paperclip-runner/scripts/build-provider-pack.mjs b/packages/paperclip-runner/scripts/build-provider-pack.mjs index 99edce0533b..8cba2fca015 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -18,7 +18,7 @@ import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; -import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders } from "./candidate-provider-pack.mjs"; +import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders, providerPackManifestFields } from "./candidate-provider-pack.mjs"; import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -334,8 +334,7 @@ try { codex: "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", }, - providers: { cursor: candidateProviders.cursor }, - ...(candidates.length ? { candidateProviders: Object.fromEntries(candidates.map(provider => [provider, candidateProviders[provider]])) } : {}), + ...providerPackManifestFields(candidateProviders, candidates), artifacts: { grokLauncher: { path: "dist/providers/grok/launcher.cjs", diff --git a/packages/paperclip-runner/scripts/candidate-provider-pack.mjs b/packages/paperclip-runner/scripts/candidate-provider-pack.mjs index 6fda49e035e..f1389685149 100644 --- a/packages/paperclip-runner/scripts/candidate-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/candidate-provider-pack.mjs @@ -1,6 +1,14 @@ import { materializePinnedCursorDistribution } from "./materialize-cursor-distribution.mjs"; const CANDIDATES = new Set(["cursor", "copilot", "pi"]); +/** Only materialized providers enter the serialized manifest and its digest. */ +export function providerPackManifestFields(providers, candidates) { + return { + ...(providers.cursor ? { providers: { cursor: providers.cursor } } : {}), + ...(candidates.length ? { candidateProviders: Object.fromEntries(candidates.map(provider => [provider, providers[provider]])) } : {}), + }; +} + export function providerPackProviders(platform, architecture, candidates) { const cursorSupported = ["darwin-arm64", "darwin-x64", "linux-x64"].includes(`${platform}-${architecture}`); return [...new Set([...(cursorSupported ? ["cursor"] : []), ...candidates])]; diff --git a/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs b/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs index 7860a35d668..88a85c816df 100644 --- a/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs +++ b/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs @@ -1,6 +1,30 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders } from "./candidate-provider-pack.mjs"; +import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders, providerPackManifestFields } from "./candidate-provider-pack.mjs"; +import { createHash } from "node:crypto"; + +// Matches the canonical manifest hashing used by the builder and admission. +function digest(value) { + const canonical = value => Array.isArray(value) ? `[${value.map(canonical).join(",")}]` + : value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}` + : JSON.stringify(value); + return createHash("sha256").update(canonical(value)).digest("hex"); +} + +test("provider manifest digest survives disk JSON on supported and unsupported targets", () => { + const cursor = { version: "pinned", profileDigest: "sha256:profile", closureDigest: "sha256:closure" }; + for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "x64"], ["linux", "arm64"], ["win32", "x64"]]) { + const selected = providerPackProviders(platform, architecture, []); + const payload = { target: { platform, architecture }, ...providerPackManifestFields(selected.includes("cursor") ? { cursor } : {}, []) }; + const diskPayload = JSON.parse(JSON.stringify(payload)); + assert.deepEqual(diskPayload, payload); + assert.equal(digest(diskPayload), digest(payload)); + assert.equal(diskPayload.providers?.cursor?.version, selected.includes("cursor") ? "pinned" : undefined); + } + const candidate = providerPackManifestFields({ cursor }, ["cursor"]); + assert.deepEqual(candidate.candidateProviders, { cursor }); + assert.equal(digest(JSON.parse(JSON.stringify(candidate))), digest(candidate)); +}); test("candidate selection is explicit", () => { assert.deepEqual(parseProviderPackArguments(["--", "/pack"]), { output: "/pack", candidates: [] }); diff --git a/server/src/services/native-runtime/native-cursor-plan-wait.test.ts b/server/src/services/native-runtime/native-cursor-plan-wait.test.ts index 9d71ed4f0d4..6201e06f06c 100644 --- a/server/src/services/native-runtime/native-cursor-plan-wait.test.ts +++ b/server/src/services/native-runtime/native-cursor-plan-wait.test.ts @@ -42,7 +42,64 @@ function editEvent(f: CursorPlanWaitFacts, index: number, edit: (e: any) => void edit(e); row.sourcePayloadSha256 = nativeSha256(e); } +function resequence(f: CursorPlanWaitFacts) { + f.events.forEach((row, index) => { + const sourceSeq = index + 1; + Object.assign(row, { seq: sourceSeq, sourceSeq, sourceEventId: `instance:${sourceSeq}` }); + Object.assign((row.payload as any).prpEvent, { sourceSeq, sourceEventId: row.sourceEventId }); + row.sourcePayloadSha256 = nativeSha256((row.payload as any).prpEvent); + }); +} + +function withProgress(count: number) { + const f = fixture(); + const start = f.events.find(row => row.eventType === "tool.execution.started")!; + const rows = Array.from({ length: count }, () => { + const row = structuredClone(start); + row.eventType = "tool.execution.progressed"; + (row.payload as any).prpEvent.eventType = row.eventType; + return row; + }); + f.events.splice(3, 0, ...rows); + resequence(f); + return f; +} + describe("accepted Cursor plan passive-wait authority", () => { + it("settles a long plan while binding every progress event into its proof", () => { + const f = withProgress(2_000); + const proof = nativeCursorPlanWaitFromFacts(f); + expect(proof).not.toBeNull(); + const progress = f.events[1_200]!; + (progress.payload as any).prpEvent.payload.name = "updated diagnostic text"; + progress.sourcePayloadSha256 = nativeSha256((progress.payload as any).prpEvent); + const changed = nativeCursorPlanWaitFromFacts(f); + expect(changed).not.toBeNull(); + expect(changed!.source.toolLifecycleSha256).not.toBe(proof!.source.toolLifecycleSha256); + expect(changed!.source.authoritySha256).not.toBe(proof!.source.authoritySha256); + }); + it.each(["foreign tool", "foreign session", "tampered digest", "late progress"])("rejects %s beyond the first thousand events", kind => { + const f = withProgress(2_000), row = f.events[1_200]!; + const event = (row.payload as any).prpEvent; + if (kind === "foreign tool") event.payload.executionId = "unrelated"; + if (kind === "foreign session") event.normalizedSessionId = "other"; + if (kind === "late progress") { f.events.splice(1_200, 1); f.events.push(row); resequence(f); } + row.sourcePayloadSha256 = kind === "tampered digest" ? "tampered" : nativeSha256(event); + expect(nativeCursorPlanWaitFromFacts(f)).toBeNull(); + }); + it("fails closed only at the separate progress and control-event budgets", () => { + expect(nativeCursorPlanWaitFromFacts(withProgress(20_000))).not.toBeNull(); + expect(nativeCursorPlanWaitFromFacts(withProgress(20_001))).toBeNull(); + const f = withProgress(1_200); + const turnStart = structuredClone(f.events[0]!); + turnStart.eventType = "turn.started"; + Object.assign((turnStart.payload as any).prpEvent, { eventType: "turn.started", payload: {} }); + f.events.unshift(...Array.from({ length: 995 }, () => structuredClone(turnStart))); + resequence(f); + expect(nativeCursorPlanWaitFromFacts(f)).not.toBeNull(); + f.events.unshift(structuredClone(turnStart)); resequence(f); + expect(nativeCursorPlanWaitFromFacts(f)).toBeNull(); + }); it("records the accepted revision and explicitly unfinished Plan-mode continuation", () => { const value = nativeCursorPlanWaitFromFacts(fixture()); expect(value?.source).toMatchObject({ requestId: "request", planRevision: `plan-${"a".repeat(64)}`, terminalEventId: "instance:304", toolExecutionId: "native-plan-tool" }); diff --git a/server/src/services/native-runtime/native-cursor-plan-wait.ts b/server/src/services/native-runtime/native-cursor-plan-wait.ts index 9825fff4109..b8fc138a546 100644 --- a/server/src/services/native-runtime/native-cursor-plan-wait.ts +++ b/server/src/services/native-runtime/native-cursor-plan-wait.ts @@ -21,6 +21,10 @@ const PREFIX = "cursor-plan-wait:"; // 1,000-row budget. Completed tools were included and must stay included. const LEGACY_EVENT_TYPES = ["runtime_request.created", "runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired", "turn.started", "turn.completed", "turn.failed", "turn.cancelled", "tool.execution.started", "tool.execution.completed"]; const EVENT_TYPES = [...LEGACY_EVENT_TYPES, "tool.execution.progressed"]; +// Progress does not consume the control-event budget. Keep the complete proof +// bounded, including one overflow row, without truncating its terminal event. +const MAX_CONTROL_EVENTS = 1000; +const MAX_PROGRESS_EVENTS = 20_000; const SUMMARY = "Plan accepted. This task is waiting for your next message. This run used Plan mode; no implementation or task completion is claimed."; export interface NativeCursorPlanWaitSource extends Binding { @@ -74,7 +78,9 @@ function cursorPlanWaitFromFacts(facts: CursorPlanWaitFacts, committedSource?: N !Object.entries(b).every(([key, value]) => record(admission.binding)[key] === value) || provider.kind !== "acpx" || provider.agent !== "cursor" || provider.cursorMode !== "plan" || typeof provider.model !== "string" || !provider.model.trim() || record(admission.completionContract).id !== contract.id || record(admission.completionContract).sha256 !== contract.canonicalSha256 || nativeCompletionContractSha256(contract) !== contract.canonicalSha256 || run.completionContractId !== contract.id || run.completionContractSha256 !== contract.canonicalSha256 || !same(contract.contractJson, record(admission.completionContract).contract)) return null; const sessionId = record(admission.session).normalizedSessionId; - if (typeof sessionId !== "string" || !sessionId || facts.events.length === 0 || facts.events.length > 1000) return null; + if (typeof sessionId !== "string" || !sessionId || facts.events.length === 0 || facts.events.length > MAX_CONTROL_EVENTS + MAX_PROGRESS_EVENTS) return null; + const progressCount = facts.events.filter(row => row.eventType === "tool.execution.progressed").length; + if (progressCount > MAX_PROGRESS_EVENTS || facts.events.length - progressCount > MAX_CONTROL_EVENTS) return null; const events: Array> = []; const ids = new Set(), seqs = new Set(); let lastRowSeq = -1; @@ -183,7 +189,8 @@ async function readCursorPlanWaitProof(db: Db, binding: Binding, locked: boolean const [row] = await (locked ? q.for("share", { noWait: true }) : q); if (!row || record(record(row.run.runnerProfileJson).nativeExecutionInput).provider?.agent !== "cursor" || record(record(row.run.runnerProfileJson).nativeExecutionInput).provider?.cursorMode !== "plan") return null; const eventTypes = isHistoricalUnboundWait(committedSource) ? LEGACY_EVENT_TYPES : EVENT_TYPES; - const eqs = db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.companyId, binding.companyId), eq(heartbeatRunEvents.runId, binding.runId), inArray(heartbeatRunEvents.eventType, eventTypes))).orderBy(asc(heartbeatRunEvents.seq)).limit(1001); + const eventLimit = MAX_CONTROL_EVENTS + (isHistoricalUnboundWait(committedSource) ? 0 : MAX_PROGRESS_EVENTS) + 1; + const eqs = db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.companyId, binding.companyId), eq(heartbeatRunEvents.runId, binding.runId), inArray(heartbeatRunEvents.eventType, eventTypes))).orderBy(asc(heartbeatRunEvents.seq)).limit(eventLimit); const events = await (locked ? eqs.for("share", { noWait: true }) : eqs); const iq = db.select({ interaction: issueThreadInteractions, delivery: issueQuestionResponseDeliveries }).from(issueThreadInteractions) .innerJoin(issueQuestionResponseDeliveries, eq(issueQuestionResponseDeliveries.interactionId, issueThreadInteractions.id))