mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(cursor): retain complete plan proofs and portable manifest digests
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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))
|
||||
|
||||
Reference in new issue
Block a user