mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-08 11:13:44 +02:00
fix: validate Cursor plan waits against canonical contract policy
Reuse the production completion-contract envelope hash and exercise real contract creation and reuse in accepted-plan persistence tests. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b74ca5eb97
commit
c58a8881f2
4 files changed
+38
-15
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { ensureNativeCompletionContract } from "../services/native-runtime/completion-contracts.js";
|
||||
import { readNativeCursorPlanWait, hasCommittedNativeCursorPlanWait } from "../services/native-runtime/native-cursor-plan-wait.js";
|
||||
import { nativeSha256 } from "../services/native-runtime/canonical.js";
|
||||
import { buildQuestionResponseDeliveryEnvelope } from "../services/question-response-delivery.js";
|
||||
@@ -13735,14 +13736,17 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
|
||||
async function seedAcceptedCursorPlanWait(semanticFinish = false, sourceSequenceOffset = 0) {
|
||||
const f = await seedStrandedIssueFixture({ status: "in_progress", runStatus: "succeeded", livenessState: "advanced" });
|
||||
const contractId = randomUUID(), instance = randomUUID(), interactionId = randomUUID();
|
||||
const contract = { revision: "1", objective: "Review the plan before further work", criteria: [{ id: "objective", requirement: "Explicit completion required" }] };
|
||||
const instance = randomUUID(), interactionId = randomUUID();
|
||||
const contractInput = { db, companyId: f.companyId,
|
||||
issue: { id: f.issueId, title: "Review the plan before further work", description: "Explicit completion required" }, actorId: "test" };
|
||||
const { row: persistedContract, contract } = await ensureNativeCompletionContract(contractInput);
|
||||
const reused = await ensureNativeCompletionContract(contractInput);
|
||||
expect(reused.row.id).toBe(persistedContract.id);
|
||||
expect(reused.contract).toEqual(contract);
|
||||
const contractId = persistedContract.id, contractSha = persistedContract.canonicalSha256;
|
||||
// Production binds policy/schema as well as the body; a body-only hash is not a valid contract receipt.
|
||||
expect(contractSha).not.toBe(nativeSha256(contract));
|
||||
const model = "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]";
|
||||
const contractSha = nativeSha256(contract);
|
||||
await db.insert(completionContracts).values({ id: contractId, companyId: f.companyId, issueId: f.issueId,
|
||||
revision: 1, schemaVersion: "paperclip.completion-contract.v1", policyVersion: "phase6-v7", risk: "low",
|
||||
completionAuthority: "agent_claim_policy", incompleteCriteriaPolicy: "preserve_non_terminal", contractJson: contract,
|
||||
canonicalSha256: contractSha, createdByActorType: "system", createdByActorId: "test" });
|
||||
await db.update(agents).set({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "cursor", model, acpxSessionMode: "plan", acpxPermissionMode: "approve-all" } }).where(eq(agents.id, f.agentId));
|
||||
await db.update(agentWakeupRequests).set({ status: "completed" }).where(eq(agentWakeupRequests.id, f.wakeupRequestId));
|
||||
await db.update(heartbeatRuns).set({ runtimeMode: "native", nativeIssueId: f.issueId, nativeSessionId: f.runId,
|
||||
|
||||
@@ -10,6 +10,18 @@ import { nativeSha256 } from "./canonical.js";
|
||||
export const NATIVE_COMPLETION_CONTRACT_SCHEMA = "paperclip.completion-contract.v1";
|
||||
export const NATIVE_COMPLETION_POLICY_VERSION = "phase6-v4";
|
||||
|
||||
/** Canonical persisted receipt includes the policy that governs the contract. */
|
||||
export function nativeCompletionContractSha256(row: Pick<typeof completionContracts.$inferSelect,
|
||||
"schemaVersion" | "policyVersion" | "risk" | "completionAuthority" | "contractJson">): string {
|
||||
return nativeSha256({
|
||||
schemaVersion: row.schemaVersion,
|
||||
policyVersion: row.policyVersion,
|
||||
risk: row.risk,
|
||||
completionAuthority: row.completionAuthority,
|
||||
contract: row.contractJson,
|
||||
});
|
||||
}
|
||||
|
||||
type CompletionComment = {
|
||||
id?: string;
|
||||
body: string;
|
||||
@@ -162,11 +174,11 @@ export async function ensureNativeCompletionContract(input: {
|
||||
immediateRequests: input.immediateRequests,
|
||||
humanResponseId: input.humanResponseId,
|
||||
});
|
||||
const latestCandidateSha256 = nativeSha256({
|
||||
const latestCandidateSha256 = nativeCompletionContractSha256({
|
||||
schemaVersion: NATIVE_COMPLETION_CONTRACT_SCHEMA,
|
||||
policyVersion: NATIVE_COMPLETION_POLICY_VERSION,
|
||||
...policy,
|
||||
contract: latestCandidate,
|
||||
contractJson: latestCandidate as unknown as Record<string, unknown>,
|
||||
});
|
||||
if (latest?.canonicalSha256 === latestCandidateSha256) {
|
||||
return { row: latest, contract: latestCandidate, sources: buildNativeCompletionContractSources(input) };
|
||||
@@ -179,11 +191,11 @@ export async function ensureNativeCompletionContract(input: {
|
||||
immediateRequests: input.immediateRequests,
|
||||
humanResponseId: input.humanResponseId,
|
||||
});
|
||||
const canonicalSha256 = nativeSha256({
|
||||
const canonicalSha256 = nativeCompletionContractSha256({
|
||||
schemaVersion: NATIVE_COMPLETION_CONTRACT_SCHEMA,
|
||||
policyVersion: NATIVE_COMPLETION_POLICY_VERSION,
|
||||
...policy,
|
||||
contract,
|
||||
contractJson: contract as unknown as Record<string, unknown>,
|
||||
});
|
||||
const [row] = await tx.insert(completionContracts).values({
|
||||
companyId: input.companyId,
|
||||
|
||||
@@ -2,12 +2,15 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import * as runner from "../../vendor/paperclip-runner/index.js";
|
||||
import { resolveQualifiedAcpxProfile, validatePrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js";
|
||||
import { buildQuestionResponseDeliveryEnvelope } from "../question-response-delivery.js";
|
||||
import { NATIVE_COMPLETION_CONTRACT_SCHEMA, NATIVE_COMPLETION_POLICY_VERSION } from "./completion-contracts.js";
|
||||
import { nativeSha256 } from "./canonical.js";
|
||||
import { nativeCursorPlanWaitFromFacts, type CursorPlanWaitFacts } from "./native-cursor-plan-wait.js";
|
||||
|
||||
function fixture(): CursorPlanWaitFacts {
|
||||
const b = { companyId: "company", issueId: "issue", agentId: "agent", runId: "run" };
|
||||
const contract = { revision: "revision", criteria: [{ id: "criterion" }] };
|
||||
const contractMetadata = { schemaVersion: NATIVE_COMPLETION_CONTRACT_SCHEMA, policyVersion: NATIVE_COMPLETION_POLICY_VERSION, risk: "low", completionAuthority: "agent_claim_policy" };
|
||||
const contractSha = nativeSha256({ ...contractMetadata, contract });
|
||||
const planId = `plan-${"a".repeat(64)}`;
|
||||
const input = { schema: "paperclip.question_set.v1", title: "Plan", description: "Exact revised plan text", questions: [{ id: planId, prompt: "Proceed?", required: true, answerMode: "single_select", options: [{ id: "accept", label: "Accept" }, { id: "reject", label: "Reject" }, { id: "cancel", label: "Cancel" }] }] };
|
||||
const i = { id: "interaction", ...b, sourceRunId: b.runId, createdByAgentId: b.agentId, resolvedByUserId: "board", resolvedByAgentId: null, resolvedAt: new Date(0),
|
||||
@@ -21,8 +24,8 @@ function fixture(): CursorPlanWaitFacts {
|
||||
};
|
||||
return {
|
||||
binding: b,
|
||||
run: { id: b.runId, companyId: b.companyId, agentId: b.agentId, nativeIssueId: b.issueId, runtimeMode: "native", runnerInstanceId: "instance", status: "running", completionContractId: "contract", completionContractSha256: nativeSha256(contract), runnerProfileJson: { nativeExecutionInput: { binding: b, provider: { kind: "acpx", agent: "cursor", cursorMode: "plan", model: "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", profile: resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]") }, session: { normalizedSessionId: "session" }, completionContract: { id: "contract", sha256: nativeSha256(contract), contract } } } },
|
||||
contract: { id: "contract", canonicalSha256: nativeSha256(contract), contractJson: contract },
|
||||
run: { id: b.runId, companyId: b.companyId, agentId: b.agentId, nativeIssueId: b.issueId, runtimeMode: "native", runnerInstanceId: "instance", status: "running", completionContractId: "contract", completionContractSha256: contractSha, runnerProfileJson: { nativeExecutionInput: { binding: b, provider: { kind: "acpx", agent: "cursor", cursorMode: "plan", model: "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", profile: resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]") }, session: { normalizedSessionId: "session" }, completionContract: { id: "contract", sha256: contractSha, contract } } } },
|
||||
contract: { id: "contract", ...contractMetadata, canonicalSha256: contractSha, contractJson: contract },
|
||||
events: [
|
||||
event(275, "runtime_request.created", { request: { schema: "paperclip.runtime_request.v2", status: "pending", type: "input", requestKind: "runtime", requestId: "request", turnId: "turn", itemId: "native-plan-tool", origin: { provider: "cursor", method: "cursor/create_plan", adapter: "acpx-runtime-sidecar" }, input } }),
|
||||
event(294, "tool.execution.started", { schema: "paperclip.tool.execution.v1", executionId: "native-plan-tool", transport: "builtin", operation: "execute", status: "running", name: "arbitrary display name" }),
|
||||
@@ -110,6 +113,9 @@ describe("accepted Cursor plan passive-wait authority", () => {
|
||||
["cancelled run", (f: CursorPlanWaitFacts) => { f.run.status = "cancelled"; }],
|
||||
["Agent mode", (f: CursorPlanWaitFacts) => { (f.run.runnerProfileJson as any).nativeExecutionInput.provider.cursorMode = "agent"; }],
|
||||
["stale profile", (f: CursorPlanWaitFacts) => { (f.run.runnerProfileJson as any).nativeExecutionInput.provider.profile = { ...resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]"), commandDigest: "old" }; }],
|
||||
["changed contract policy", (f: CursorPlanWaitFacts) => { f.contract.policyVersion = "tampered-policy"; }],
|
||||
["changed completion authority", (f: CursorPlanWaitFacts) => { f.contract.completionAuthority = "server_arbiter"; }],
|
||||
["changed contract hash", (f: CursorPlanWaitFacts) => { f.contract.canonicalSha256 = "f".repeat(64); }],
|
||||
["changed contract", (f: CursorPlanWaitFacts) => { f.contract.contractJson.revision = "new"; }],
|
||||
["unknown origin", (f: CursorPlanWaitFacts) => editEvent(f, 0, e => { e.payload.request.origin.provider = "acpx"; })],
|
||||
["wrong method", (f: CursorPlanWaitFacts) => editEvent(f, 0, e => { e.payload.request.origin.method = "cursor/ask_question"; })],
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type PrpStructuredRunResult,
|
||||
} from "../../vendor/paperclip-runner/index.js";
|
||||
import { buildQuestionResponseDeliveryEnvelope } from "../question-response-delivery.js";
|
||||
import { nativeCompletionContractSha256 } from "./completion-contracts.js";
|
||||
import { nativeSha256 } from "./canonical.js";
|
||||
|
||||
type Binding = { companyId: string; issueId: string; runId: string; agentId: string };
|
||||
@@ -43,7 +44,7 @@ export interface NativeCursorPlanWaitSource extends Binding {
|
||||
export interface CursorPlanWaitFacts {
|
||||
binding: Binding;
|
||||
run: Pick<typeof heartbeatRuns.$inferSelect, "id" | "companyId" | "agentId" | "nativeIssueId" | "runtimeMode" | "status" | "runnerProfileJson" | "runnerInstanceId" | "completionContractId" | "completionContractSha256">;
|
||||
contract: Pick<typeof completionContracts.$inferSelect, "id" | "canonicalSha256" | "contractJson">;
|
||||
contract: Pick<typeof completionContracts.$inferSelect, "id" | "canonicalSha256" | "contractJson" | "schemaVersion" | "policyVersion" | "risk" | "completionAuthority">;
|
||||
events: Array<Pick<typeof heartbeatRunEvents.$inferSelect, "companyId" | "agentId" | "runId" | "seq" | "eventType" | "payload" | "sourceInstanceId" | "sourceEventId" | "sourceSeq" | "sourcePayloadSha256" | "protocolSchemaVersion">>;
|
||||
interactions: Array<{ interaction: typeof issueThreadInteractions.$inferSelect; delivery: typeof issueQuestionResponseDeliveries.$inferSelect }>;
|
||||
}
|
||||
@@ -71,7 +72,7 @@ function cursorPlanWaitFromFacts(facts: CursorPlanWaitFacts, committedSource?: N
|
||||
}
|
||||
if (run.id !== b.runId || run.companyId !== b.companyId || run.agentId !== b.agentId || run.nativeIssueId !== b.issueId || run.runtimeMode !== "native" || !["running", "succeeded"].includes(run.status) ||
|
||||
!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 || nativeSha256(contract.contractJson) !== contract.canonicalSha256 || run.completionContractId !== contract.id || run.completionContractSha256 !== contract.canonicalSha256 || !same(contract.contractJson, record(admission.completionContract).contract)) return null;
|
||||
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;
|
||||
const events: Array<Record<string, any>> = [];
|
||||
|
||||
Reference in new issue
Block a user