fix(cursor): retain complete plan proofs and portable manifest digests

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-03 13:40:14 -05:00
1 parent 468c46ff1d
commit f71957ef76
6 files changed
+121 -6

No files matched your search

@@ -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.
@@ -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",
@@ -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])];
@@ -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: [] });
@@ -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" });
@@ -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<Record<string, any>> = [];
const ids = new Set<string>(), seqs = new Set<string>();
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))